From 11d58519536bb05356a95fd0ec9b5354fd85e085 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 12:20:16 +0300 Subject: [PATCH] perf(team): defer task log stream work until opened --- .../discovery/TeamTranscriptSourceLocator.ts | 16 +++++++ .../team/taskLogs/exact/fileVersions.ts | 32 +++++++++++-- .../stream/BoardTaskLogStreamService.ts | 15 ++++-- .../team/taskLogs/TaskLogsPanel.tsx | 5 +- .../BoardTaskExactLogFileVersions.test.ts | 47 +++++++++++++++++++ .../team/taskLogs/TaskLogsPanel.test.ts | 28 +++++++++-- 6 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 test/main/services/team/BoardTaskExactLogFileVersions.test.ts diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts index 8dbd7e4d..83620a4d 100644 --- a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -1,10 +1,16 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { createLogger } from '@shared/utils/logger'; + import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver'; import type { TeamConfig } from '@shared/types'; +const logger = createLogger('Service:TeamTranscriptSourceLocator'); +const TRANSCRIPT_DISCOVERY_WARN_MS = 3_000; +const TRANSCRIPT_DISCOVERY_FILE_COUNT_WARN = 500; + export interface TeamTranscriptSourceContext { projectDir: string; projectId: string; @@ -25,7 +31,17 @@ export class TeamTranscriptSourceLocator { } const { projectDir, projectId, config, sessionIds } = context; + const startedAt = Date.now(); const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); + const elapsedMs = Date.now() - startedAt; + if ( + elapsedMs >= TRANSCRIPT_DISCOVERY_WARN_MS || + transcriptFiles.length >= TRANSCRIPT_DISCOVERY_FILE_COUNT_WARN + ) { + logger.warn( + `Large task-log transcript discovery: team=${teamName} sessions=${sessionIds.length} files=${transcriptFiles.length} elapsedMs=${elapsedMs}` + ); + } return { projectDir, projectId, config, sessionIds, transcriptFiles }; } diff --git a/src/main/services/team/taskLogs/exact/fileVersions.ts b/src/main/services/team/taskLogs/exact/fileVersions.ts index 879ab4b7..720b64ed 100644 --- a/src/main/services/team/taskLogs/exact/fileVersions.ts +++ b/src/main/services/team/taskLogs/exact/fileVersions.ts @@ -2,12 +2,38 @@ import * as fs from 'fs/promises'; import type { BoardTaskExactLogFileVersion } from './BoardTaskExactLogTypes'; +const FILE_VERSION_STAT_CONCURRENCY = process.platform === 'win32' ? 8 : 16; + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const currentIndex = index; + index += 1; + if (currentIndex >= items.length) { + return; + } + results[currentIndex] = await fn(items[currentIndex]!); + } + }); + await Promise.all(workers); + return results; +} + export async function getBoardTaskExactLogFileVersions( filePaths: Iterable ): Promise> { const uniqueFilePaths = [...new Set(filePaths)]; - const results = await Promise.all( - uniqueFilePaths.map(async (filePath) => { + const results = await mapLimit( + uniqueFilePaths, + FILE_VERSION_STAT_CONCURRENCY, + async (filePath) => { try { const stat = await fs.stat(filePath); if (!stat.isFile()) { @@ -21,7 +47,7 @@ export async function getBoardTaskExactLogFileVersions( } catch { return null; } - }) + } ); const byPath = new Map(); diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 49215e41..0a035df8 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -66,6 +66,7 @@ const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; const STREAM_LAYOUT_CACHE_TTL_MS = 1_000; const STREAM_LAYOUT_BUILD_WARN_MS = 3_000; +const RUNTIME_FALLBACK_WARN_MS = 3_000; const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([ 'task_complete', 'task_set_status', @@ -1925,9 +1926,17 @@ export class BoardTaskLogStreamService { const layout = await this.getStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - return ( - (await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId)) ?? emptyResponse() - ); + const startedAt = Date.now(); + const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { + logger.warn( + `Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( + fallback + )} elapsedMs=${elapsedMs}` + ); + } + return fallback ?? emptyResponse(); } const segments: BoardTaskLogSegment[] = []; diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx index 4c0582ff..ae98920c 100644 --- a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -64,8 +64,9 @@ export const TaskLogsPanel = ({ const pulseTimerRef = useRef | null>(null); const countReloadTimerRef = useRef | null>(null); const countRequestSeqRef = useRef(0); - const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream'); - const taskLogSummaryEnabled = availableTabs.includes('stream'); + const taskLogTrackingEnabled = + hasOpenedContent && task.status === 'in_progress' && availableTabs.includes('stream'); + const taskLogSummaryEnabled = hasOpenedContent && availableTabs.includes('stream'); useEffect(() => { setActiveTab(defaultTab); diff --git a/test/main/services/team/BoardTaskExactLogFileVersions.test.ts b/test/main/services/team/BoardTaskExactLogFileVersions.test.ts new file mode 100644 index 00000000..00127463 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogFileVersions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +const statMock = vi.hoisted(() => vi.fn()); + +vi.mock('fs/promises', () => ({ + stat: statMock, +})); + +import { getBoardTaskExactLogFileVersions } from '../../../../src/main/services/team/taskLogs/exact/fileVersions'; + +describe('getBoardTaskExactLogFileVersions', () => { + it('deduplicates paths and bounds concurrent stat calls', async () => { + let activeStats = 0; + let maxActiveStats = 0; + const uniquePaths = Array.from({ length: 40 }, (_value, index) => `/tmp/task-${index}.jsonl`); + + statMock.mockImplementation(async (filePath: string) => { + activeStats += 1; + maxActiveStats = Math.max(maxActiveStats, activeStats); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeStats -= 1; + + return { + isFile: () => !filePath.endsWith('17.jsonl'), + mtimeMs: 1000 + uniquePaths.indexOf(filePath), + size: 2000 + uniquePaths.indexOf(filePath), + }; + }); + + const result = await getBoardTaskExactLogFileVersions([ + ...uniquePaths, + uniquePaths[0]!, + uniquePaths[1]!, + ]); + + expect(statMock).toHaveBeenCalledTimes(uniquePaths.length); + expect(maxActiveStats).toBeGreaterThan(1); + expect(maxActiveStats).toBeLessThanOrEqual(16); + expect(result.size).toBe(uniquePaths.length - 1); + expect(result.has('/tmp/task-17.jsonl')).toBe(false); + expect(result.get('/tmp/task-0.jsonl')).toEqual({ + filePath: '/tmp/task-0.jsonl', + mtimeMs: 1000, + size: 2000, + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts index ea3734c7..b3a37cb8 100644 --- a/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogsPanel.test.ts @@ -370,7 +370,7 @@ describe('TaskLogsPanel', () => { expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false); }); - it('does not mount Task Log Stream content while the section is collapsed but still pulses on matching updates', async () => { + it('defers Task Log Stream work while collapsed, then starts tracking after first open', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.useFakeTimers(); @@ -401,23 +401,43 @@ describe('TaskLogsPanel', () => { expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull(); expect(taskLogStreamProps.calls).toHaveLength(0); + expect(apiState.getTaskLogStreamSummary).not.toHaveBeenCalled(); + expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled(); + expect(apiState.onTeamChange).not.toHaveBeenCalled(); + expect(handler).toBeNull(); + expect(activityStates).toEqual([false]); + + await act(async () => { + root.render( + React.createElement(TaskLogsPanel, { + teamName: 'demo', + task: makeTask(), + isOpen: true, + onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive), + }) + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull(); + expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1'); expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true); expect(handler).toBeTypeOf('function'); - expect(activityStates).toEqual([false]); await act(async () => { handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' }); await flushMicrotasks(); }); - expect(activityStates).toEqual([false, true]); + expect(activityStates).toEqual([false, false, true]); await act(async () => { vi.advanceTimersByTime(1800); await flushMicrotasks(); }); - expect(activityStates).toEqual([false, true, false]); + expect(activityStates).toEqual([false, false, true, false]); await act(async () => { root.unmount();