fix(team): refine change extraction evidence
This commit is contained in:
parent
609f4f8d99
commit
fa829f92c8
2 changed files with 108 additions and 6 deletions
|
|
@ -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_INSPECT_LIMIT = 1_000;
|
||||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200;
|
const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200;
|
||||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
|
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 файла + время протухания */
|
/** Кеш-запись: данные + mtime файла + время протухания */
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
|
|
@ -88,6 +90,20 @@ interface OpenCodeBackfillAttempt {
|
||||||
backfilled: boolean;
|
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 {
|
interface OpenCodeDeliveryContextTempFile {
|
||||||
filePath: string | null;
|
filePath: string | null;
|
||||||
hash: string | null;
|
hash: string | null;
|
||||||
|
|
@ -361,6 +377,8 @@ export class ChangeExtractorService {
|
||||||
changeSet: null,
|
changeSet: null,
|
||||||
}));
|
}));
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
let timedOut = false;
|
||||||
|
const batchDeadline = Date.now() + TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS;
|
||||||
|
|
||||||
const runNext = async (): Promise<void> => {
|
const runNext = async (): Promise<void> => {
|
||||||
while (cursor < cappedRequests.length) {
|
while (cursor < cappedRequests.length) {
|
||||||
|
|
@ -368,14 +386,27 @@ export class ChangeExtractorService {
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
const request = cappedRequests[index];
|
const request = cappedRequests[index];
|
||||||
const taskId = request.taskId.trim();
|
const taskId = request.taskId.trim();
|
||||||
|
const remainingBatchMs = batchDeadline - Date.now();
|
||||||
|
if (remainingBatchMs <= 0) {
|
||||||
|
timedOut = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const changeSet = await this.getTaskChanges(teamName, taskId, {
|
const changeSet = await withTimeout(
|
||||||
...request.options,
|
this.getTaskChanges(teamName, taskId, {
|
||||||
summaryOnly: true,
|
...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 };
|
items[index] = { taskId, changeSet };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.toLowerCase().includes('timed out')) {
|
||||||
|
timedOut = true;
|
||||||
|
}
|
||||||
items[index] = {
|
items[index] = {
|
||||||
taskId,
|
taskId,
|
||||||
changeSet: null,
|
changeSet: null,
|
||||||
|
|
@ -392,12 +423,18 @@ export class ChangeExtractorService {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const responseItems = timedOut
|
||||||
|
? items.filter((item) => item.changeSet !== null || Boolean(item.error))
|
||||||
|
: items;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
items,
|
items: responseItems,
|
||||||
computedAt: new Date().toISOString(),
|
computedAt: new Date().toISOString(),
|
||||||
truncated:
|
truncated:
|
||||||
uniqueRequests.length > cappedRequests.length || inspectedRequests < inputRequests.length
|
timedOut ||
|
||||||
|
uniqueRequests.length > cappedRequests.length ||
|
||||||
|
inspectedRequests < inputRequests.length
|
||||||
? true
|
? true
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
function createService(params: {
|
||||||
logPaths: string[];
|
logPaths: string[];
|
||||||
projectPath?: 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 () => {
|
it('deduplicates team task change summary requests before loading', async () => {
|
||||||
const { service } = createService({ logPaths: [] });
|
const { service } = createService({ logPaths: [] });
|
||||||
const getTaskChanges = vi
|
const getTaskChanges = vi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue