fix(team): refine change extraction evidence

This commit is contained in:
777genius 2026-05-09 15:30:51 +03:00
parent 609f4f8d99
commit fa829f92c8
2 changed files with 108 additions and 6 deletions

View file

@ -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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
(timeoutId as ReturnType<typeof setTimeout> & { 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<void> => {
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,
};

View file

@ -358,6 +358,12 @@ function makeTaskChangeResult(
};
}
function pendingTaskChangeResult(): Promise<ReturnType<typeof makeTaskChangeResult>> {
return new Promise<ReturnType<typeof makeTaskChangeResult>>(() => {
// 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