diff --git a/docs/src/content/docs/ai-summaries.md b/docs/src/content/docs/ai-summaries.md index 1899c5a4..46d454b7 100644 --- a/docs/src/content/docs/ai-summaries.md +++ b/docs/src/content/docs/ai-summaries.md @@ -25,6 +25,7 @@ changelog: | --- | --- | --- | | `levels` | `[release]` | Select visible summaries; omitted or empty arrays use `[release]`. | | `minItems` | `5` | Strict entry-count threshold. `0` includes every nonempty selected group. Category counts include descendants; release counts include the whole changelog. | +| `timeout` | `120` | Total generation budget in seconds (30–1800). Raise it for long changelogs, especially with `scope` and `category` levels; a run that runs out reports `reason=deadline` and keeps the original entries. | | `style` | Neutral | Optional writing guidance (1–500 characters), applied at every level. Facts, coverage, length and plain-text safeguards still apply. | | `llm.endpoint` | `https://api.githubcopilot.com/responses` | Full POST URL; HTTPS required except loopback HTTP. | | `llm.model` | `gpt-5.4-nano` | Model ID supported by the endpoint and credential. | @@ -129,7 +130,10 @@ inherited object. Endpoints ending in `/responses` use Responses; others use Chat Completions. Both require structured JSON output. Invalid settings are rejected; missing tokens and provider errors retain the original output. Failure warnings identify the generation stage and a fixed reason code, plus HTTP status -for rejected requests. Credentials, source content and provider bodies are omitted. +for rejected requests. `reason=request-timeout` means one call took longer than +30 seconds; `reason=deadline` means the whole run used up its `timeout`. Debug +logs (`CRAFT_LOG_LEVEL=Debug`) show the duration of each model call. +Credentials, source content and provider bodies are omitted. ## How summaries are generated @@ -164,8 +168,8 @@ The model also receives each node's character budget as planning context. At every level, summaries without a complete sentence ending or with malformed control text are rejected and get the same bounded repair attempt. Invalid structured model output gets one repair attempt within the same -64-request and 120-second budgets. Transport and authentication failures do not -trigger retries or provider switching. Source-linked critical facts are carried +64-request and total `timeout` budgets. Transport and authentication failures do +not trigger retries or provider switching. Source-linked critical facts are carried and validated internally without appending their text to every displayed summary. All original entries remain expanded beneath their summaries, including links and nested content. Craft does not add accordion wrappers; any expandable @@ -178,8 +182,8 @@ category/release summaries. Unscoped entries contribute to both parent levels. Generation sends changelog content and critical PR titles to the chosen provider. Breaking/security facts retain source IDs. Generated prose is escaped; review it for accuracy. Requests are bounded to 64 calls, 3 concurrent calls, -30 seconds per request, 120 seconds total, 24,000 input bytes and 8,192 output -tokens. Any failure preserves the entire original changelog without retries or +30 seconds per request, `timeout` seconds total (120 by default), 24,000 input +bytes and 8,192 output tokens. Any failure preserves the entire original changelog without retries or provider switching. Existing leftover-entry limits still apply. Dry runs, highlighted PR previews, `prepare --no-changelog` and version analysis diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 27f170d1..e80c1b3b 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -166,10 +166,27 @@ describe('validateConfiguration', () => { apiKeyEnv: 'GITHUB_TOKEN', }, minItems: 5, + timeout: 120, levels: ['release'], }); }); + test('accepts a longer AI summary timeout', () => { + loadConfigurationFromString( + 'changelog:\n policy: auto\n aiSummary:\n timeout: 600', + ); + expect(getChangelogConfig().aiSummary).toMatchObject({ timeout: 600 }); + }); + + test.each([29, 1801, 1.5, '600'])( + 'rejects invalid AI summary timeout %j', + timeout => { + expect(() => + validateConfiguration({ changelog: { aiSummary: { timeout } } }), + ).toThrow(/changelog/); + }, + ); + test('normalizes optional AI writing style', () => { loadConfigurationFromString( 'changelog:\n policy: auto\n aiSummary:\n style: " Concise and friendly. "', diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index c5fba8b7..e899bd0a 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -9,6 +9,7 @@ import { literal, looseObject, maxLength, + maxValue, minLength, minValue, number, @@ -379,6 +380,11 @@ type SummaryLevel = 'release' | 'category' | 'scope'; export const AiSummaryConfigSchema = strictObject({ llm: optional(nonArray(AiSummaryLlmSchema), {}), minItems: optional(pipe(number(), integer(), minValue(0)), 5), + /** Total generation budget in seconds; large changelogs need more time. */ + timeout: optional( + pipe(number(), integer(), minValue(30), maxValue(1800)), + 120, + ), style: optional(pipe(string(), trim(), minLength(1), maxLength(500))), levels: optional( pipe( diff --git a/src/utils/__tests__/ai-http.test.ts b/src/utils/__tests__/ai-http.test.ts index 1992a063..3584fab0 100644 --- a/src/utils/__tests__/ai-http.test.ts +++ b/src/utils/__tests__/ai-http.test.ts @@ -632,7 +632,7 @@ test('HTTP rejection reports its status even if response cancellation stalls', a } }, 1000); -test('the full summarizer reports its request deadline without exposing the abort error', async () => { +test('the full summarizer reports its request timeout without exposing the abort error', async () => { vi.useFakeTimers(); vi.stubGlobal( 'fetch', @@ -662,7 +662,7 @@ test('the full summarizer reports its request deadline without exposing the abor await vi.advanceTimersByTimeAsync(30001); expect(await result).toBeNull(); const log = warn.mock.calls.flat().join(' '); - expect(log).toContain('stage=scope; reason=deadline'); + expect(log).toContain('stage=scope; reason=request-timeout'); expect(log).not.toContain('private-source-token'); } finally { vi.useRealTimers(); diff --git a/src/utils/__tests__/ai-summary.test.ts b/src/utils/__tests__/ai-summary.test.ts index 9e7e8859..680a66ae 100644 --- a/src/utils/__tests__/ai-summary.test.ts +++ b/src/utils/__tests__/ai-summary.test.ts @@ -26,6 +26,7 @@ const config = { apiKeyEnv: 'AI_SUMMARY_TOKEN', }, minItems: 5, + timeout: 120, levels: ['release', 'category', 'scope'] as ( | 'release' | 'category' @@ -1400,7 +1401,7 @@ describe('hierarchical HTTP changelog summaries', () => { ), ).toBeNull(); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining(`stage=${level}; reason=deadline`), + expect.stringContaining(`stage=${level}; reason=request-timeout`), ); }, ); @@ -1461,6 +1462,37 @@ describe('hierarchical HTTP changelog summaries', () => { expect(requestMock.mock.calls.at(-1)![0]!.signal!.aborted).toBe(true); }); + it('uses the configured total timeout for long-running changelogs', async () => { + vi.useFakeTimers(); + requestMock.mockImplementation( + async options => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve(modelResponse(options)), + 25000, + ); + options!.signal!.addEventListener('abort', () => { + clearTimeout(timer); + reject(new Error('aborted')); + }); + }), + ); + const groups = Array.from({ length: 50 }, (_, i) => ({ + id: `g${i}`, + title: 'G', + items: ['entry'], + })); + // Five sequential waves of 25-second calls outlast the default 120 seconds. + const pending = summarizeChangelog([{ id: 'a', title: 'A', groups }], { + ...config, + minItems: 0, + timeout: 600, + }); + await vi.advanceTimersByTimeAsync(200000); + expect((await pending)?.release).toBeTruthy(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + it.each(['scope', 'release'] as const)( 'rejects array-shaped %s summary maps without coercing them to objects', async level => { diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 04293ef0..3c211f52 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -226,6 +226,7 @@ describe('generateChangesetFromGit', () => { apiKeyEnv: 'AI_SUMMARY_TOKEN', }, minItems: 5, + timeout: 120, levels: ['release', 'category', 'scope'] as ( | 'release' | 'category' diff --git a/src/utils/ai-summary-error.ts b/src/utils/ai-summary-error.ts index 2aa38e90..9c0af620 100644 --- a/src/utils/ai-summary-error.ts +++ b/src/utils/ai-summary-error.ts @@ -5,6 +5,7 @@ export type SummaryFailureReason = | 'http' | 'network' | 'deadline' + | 'request-timeout' | 'response-size' | 'response-body' | 'response-envelope' diff --git a/src/utils/ai-summary.ts b/src/utils/ai-summary.ts index 73c1cfd9..f4d1ca3c 100644 --- a/src/utils/ai-summary.ts +++ b/src/utils/ai-summary.ts @@ -320,7 +320,6 @@ function summaryResponseSchema(level: SummaryLevel, nodes: Node[]) { const MAX_INPUT_BYTES = 24000; const MAX_NODES = 8; const MAX_REQUESTS = 64; -const TOTAL_TIMEOUT = 120000; const REQUEST_TIMEOUT = 30000; const CONCURRENCY = 3; const size = (value: unknown) => @@ -357,13 +356,17 @@ function fragments(text: string): string[] { class SummaryAttempt { private requests = 0; private controller = new AbortController(); - private deadline = Date.now() + TOTAL_TIMEOUT; - private timer = setTimeout(() => this.controller.abort(), TOTAL_TIMEOUT); + private deadline: number; + private timer: ReturnType; constructor( private provider: SummaryProvider, + timeout: number, private style?: string, - ) {} + ) { + this.deadline = Date.now() + timeout; + this.timer = setTimeout(() => this.controller.abort(), timeout); + } async close(): Promise { clearTimeout(this.timer); @@ -384,8 +387,14 @@ class SummaryAttempt { const requestController = new AbortController(); const abort = () => requestController.abort(); this.controller.signal.addEventListener('abort', abort, { once: true }); - const deadline = Math.min(Date.now() + REQUEST_TIMEOUT, this.deadline); - const timer = setTimeout(abort, Math.max(0, deadline - Date.now())); + const started = Date.now(); + const deadline = Math.min(started + REQUEST_TIMEOUT, this.deadline); + const timer = setTimeout(abort, Math.max(0, deadline - started)); + // Tell a slow single call apart from running out of the total budget. + const expired = () => + this.controller.signal.aborted || Date.now() >= this.deadline + ? 'deadline' + : 'request-timeout'; try { const input = summaryInput(level, nodes); if (size(input) > MAX_INPUT_BYTES) @@ -411,8 +420,11 @@ class SummaryAttempt { : instructions, schema: summaryResponseSchema(level, nodes), }); + logger.debug( + `AI ${level} summary request ${this.requests} (${nodes.length} nodes${repair ? ', repair' : ''}) took ${Date.now() - started}ms.`, + ); if (requestController.signal.aborted || Date.now() >= deadline) - throw new SummaryFailure('deadline'); + throw new SummaryFailure(expired()); const parsed = safeParse( level === 'release' ? releaseResponseSchema : responseSchema, output, @@ -502,6 +514,12 @@ class SummaryAttempt { .parts.reduce((n, part) => n + part.count, 0), })); } catch (error) { + if ( + error instanceof SummaryFailure && + error.reason === 'deadline' && + requestController.signal.aborted + ) + throw new SummaryFailure(expired(), level); // Retry malformed model output once, never transport/auth failures. The // retry consumes the same global request/deadline budgets as any call. if ( @@ -696,6 +714,7 @@ export async function summarizeChangelog( return null; const attempt = new SummaryAttempt( createSummaryProvider(config), + config.timeout * 1000, config.style, ); try {