From fa829f92c817c80bb330afdbc573bde1a4f3934f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 15:30:51 +0300 Subject: [PATCH] fix(team): refine change extraction evidence --- .../services/team/ChangeExtractorService.ts | 49 ++++++++++++-- .../team/ChangeExtractorService.test.ts | 65 +++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index feb1cf5f..d625c109 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -60,6 +60,8 @@ const OPEN_CODE_MAX_DISCOVERED_LANES = 500; const TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT = 1_000; const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200; const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3; +const TEAM_TASK_CHANGE_SUMMARY_TASK_TIMEOUT_MS = 15_000; +const TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS = 30_000; /** Кеш-запись: данные + mtime файла + время протухания */ interface CacheEntry { @@ -88,6 +90,20 @@ interface OpenCodeBackfillAttempt { backfilled: boolean; } +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeoutId: ReturnType | null = null; + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + (timeoutId as ReturnType & { unref?: () => void }).unref?.(); + }); + + return Promise.race([promise, timeout]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); +} + interface OpenCodeDeliveryContextTempFile { filePath: string | null; hash: string | null; @@ -361,6 +377,8 @@ export class ChangeExtractorService { changeSet: null, })); let cursor = 0; + let timedOut = false; + const batchDeadline = Date.now() + TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS; const runNext = async (): Promise => { while (cursor < cappedRequests.length) { @@ -368,14 +386,27 @@ export class ChangeExtractorService { cursor += 1; const request = cappedRequests[index]; const taskId = request.taskId.trim(); + const remainingBatchMs = batchDeadline - Date.now(); + if (remainingBatchMs <= 0) { + timedOut = true; + continue; + } + try { - const changeSet = await this.getTaskChanges(teamName, taskId, { - ...request.options, - summaryOnly: true, - }); + const changeSet = await withTimeout( + this.getTaskChanges(teamName, taskId, { + ...request.options, + summaryOnly: true, + }), + Math.max(1, Math.min(TEAM_TASK_CHANGE_SUMMARY_TASK_TIMEOUT_MS, remainingBatchMs)), + 'Task change summary timed out; refresh later or open the task logs.' + ); items[index] = { taskId, changeSet }; } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (message.toLowerCase().includes('timed out')) { + timedOut = true; + } items[index] = { taskId, changeSet: null, @@ -392,12 +423,18 @@ export class ChangeExtractorService { ) ); + const responseItems = timedOut + ? items.filter((item) => item.changeSet !== null || Boolean(item.error)) + : items; + return { teamName, - items, + items: responseItems, computedAt: new Date().toISOString(), truncated: - uniqueRequests.length > cappedRequests.length || inspectedRequests < inputRequests.length + timedOut || + uniqueRequests.length > cappedRequests.length || + inspectedRequests < inputRequests.length ? true : undefined, }; diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 28a0d545..12ef8ad2 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -358,6 +358,12 @@ function makeTaskChangeResult( }; } +function pendingTaskChangeResult(): Promise> { + return new Promise>(() => { + // Keep pending to exercise summary timeout handling. + }); +} + function createService(params: { logPaths: string[]; projectPath?: string; @@ -458,6 +464,65 @@ describe('ChangeExtractorService', () => { }); }); + it('times out slow team task change summaries without blocking faster items', async () => { + vi.useFakeTimers(); + try { + const { service } = createService({ logPaths: [] }); + const getTaskChanges = vi + .spyOn(service, 'getTaskChanges') + .mockImplementation((_teamName, taskId) => { + if (taskId === 'slow-task') { + return pendingTaskChangeResult(); + } + return Promise.resolve(makeTaskChangeResult(taskId, { taskId })); + }); + + const responsePromise = service.getTeamTaskChangeSummaries(TEAM_NAME, [ + { taskId: 'slow-task', options: SUMMARY_OPTIONS }, + { taskId: 'fast-task', options: SUMMARY_OPTIONS }, + ]); + + await vi.advanceTimersByTimeAsync(31_000); + const response = await responsePromise; + + expect(getTaskChanges).toHaveBeenCalledTimes(2); + expect(response.truncated).toBe(true); + expect(response.items[0]).toMatchObject({ + taskId: 'slow-task', + changeSet: null, + error: expect.stringContaining('timed out'), + }); + expect(response.items[1].changeSet?.taskId).toBe('fast-task'); + } finally { + vi.useRealTimers(); + } + }); + + it('omits unscanned task summary placeholders after the batch deadline', async () => { + vi.useFakeTimers(); + try { + const { service } = createService({ logPaths: [] }); + vi.spyOn(service, 'getTaskChanges').mockImplementation(() => pendingTaskChangeResult()); + + const responsePromise = service.getTeamTaskChangeSummaries( + TEAM_NAME, + Array.from({ length: 8 }, (_, index) => ({ + taskId: `slow-task-${index}`, + options: SUMMARY_OPTIONS, + })) + ); + + await vi.advanceTimersByTimeAsync(31_000); + const response = await responsePromise; + + expect(response.truncated).toBe(true); + expect(response.items).toHaveLength(6); + expect(response.items.every((item) => item.error?.includes('timed out'))).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('deduplicates team task change summary requests before loading', async () => { const { service } = createService({ logPaths: [] }); const getTaskChanges = vi