diff --git a/defaults/assistant.yaml b/defaults/assistant.yaml index e2e4842d8..55f009be1 100644 --- a/defaults/assistant.yaml +++ b/defaults/assistant.yaml @@ -762,7 +762,6 @@ steps: - "outputs['build-config'] != null" guarantee: "(output?.text ?? '').length > 0" ai: - timeout_mode: probe max_iterations: "{{ inputs.max_iterations }}" enableDelegate: "{{ inputs.enableDelegate }}" enableTasks: "{{ inputs.enableTasks }}" diff --git a/defaults/code-talk.yaml b/defaults/code-talk.yaml index 7a3b56fd3..f416adf41 100644 --- a/defaults/code-talk.yaml +++ b/defaults/code-talk.yaml @@ -297,7 +297,6 @@ steps: guarantee: "(output?.projects?.length ?? 0) > 0 || (output?.notes?.length ?? 0) > 0" fail_if: "(output?.projects?.length ?? 0) === 0 && !(output?.notes?.length > 0)" ai: - timeout_mode: probe skip_code_context: true prompt_type: general system_prompt: | @@ -471,7 +470,6 @@ steps: assume: - "outputs['route-projects']?.projects?.length > 0" ai: - timeout_mode: probe skip_code_context: true enableDelegate: true enableExecutePlan: false diff --git a/src/ai-review-service.ts b/src/ai-review-service.ts index 60c5e0c0e..071cb0f1a 100644 --- a/src/ai-review-service.ts +++ b/src/ai-review-service.ts @@ -11,6 +11,21 @@ import { processDiffWithOutline } from './utils/diff-processor'; import { shouldFilterVisorReviewComment } from './utils/comment-metadata'; import { extractFileSections, replaceFileSections } from './slack/markdown'; +/** + * Grace period (ms) subtracted from Visor's hard timeout to derive Probe's + * maxOperationTimeout. This gives Probe enough headroom to inject its + * "TIME LIMIT REACHED" message and run 4 bonus wind-down steps before + * Visor's external Promise.race fires. + */ +const PROBE_GRACEFUL_MARGIN_MS = 90_000; + +/** + * Minimum Visor timeout (ms) required before we subtract the grace margin. + * If the timeout is shorter than this, the full value is forwarded to Probe + * as-is because there isn't enough room for a meaningful margin. + */ +const MIN_TIMEOUT_FOR_MARGIN_MS = PROBE_GRACEFUL_MARGIN_MS + 30_000; // 120 000 + /** * Helper function to log debug messages using the centralized logger */ @@ -288,10 +303,10 @@ export interface AIReviewConfig { completionPrompt?: string; /** Shared concurrency limiter for global AI call gating */ concurrencyLimiter?: any; - // Timeout mode: 'probe' lets Probe handle timeout with graceful wind-down, - // 'visor' uses Visor's external Promise.race hard kill (legacy behavior). - // Default: 'probe' - timeoutMode?: 'probe' | 'visor'; + // Probe-level timeout (maxOperationTimeout) for graceful wind-down. + // When set, Probe will inject "TIME LIMIT REACHED" and give bonus steps before hard abort. + // Defaults to timeout - 90s when not explicitly set. + aiTimeout?: number; } export interface AIDebugInfo { @@ -500,14 +515,11 @@ export class AIReviewService { try { const call = this.callProbeAgent(prompt, schema, debugInfo, checkName, sessionId); const timeoutMs = Math.max(0, this.config.timeout || 0); - const useProbeTimeout = (this.config.timeoutMode || 'probe') === 'probe'; const { response, effectiveSchema, sessionId: usedSessionId, - } = timeoutMs > 0 && !useProbeTimeout - ? await this.withTimeout(call, timeoutMs, 'AI review') - : await call; + } = timeoutMs > 0 ? await this.withTimeout(call, timeoutMs, 'AI review') : await call; const processingTime = Date.now() - startTime; if (debugInfo) { @@ -669,11 +681,8 @@ export class AIReviewService { checkName ); const timeoutMs = Math.max(0, this.config.timeout || 0); - const useProbeTimeout = (this.config.timeoutMode || 'probe') === 'probe'; const { response, effectiveSchema } = - timeoutMs > 0 && !useProbeTimeout - ? await this.withTimeout(call, timeoutMs, 'AI review (session)') - : await call; + timeoutMs > 0 ? await this.withTimeout(call, timeoutMs, 'AI review (session)') : await call; const processingTime = Date.now() - startTime; if (debugInfo) { @@ -1453,11 +1462,15 @@ ${this.escapeXml(processedFallbackDiff)} log(`⚙️ Model: ${this.config.model || 'default'}, Provider: ${this.config.provider || 'auto'}`); // Update maxOperationTimeout for this reuse call (timeout may differ from original) + // Use explicit aiTimeout if set, otherwise default to timeout - 90s const reuseTimeoutMs = this.config.timeout || 0; - if (reuseTimeoutMs > 120000) { - (agent as any).maxOperationTimeout = reuseTimeoutMs - 90000; - } else if (reuseTimeoutMs > 0) { - (agent as any).maxOperationTimeout = reuseTimeoutMs; + const reuseAiTimeout = + this.config.aiTimeout || + (reuseTimeoutMs > MIN_TIMEOUT_FOR_MARGIN_MS + ? reuseTimeoutMs - PROBE_GRACEFUL_MARGIN_MS + : reuseTimeoutMs); + if (reuseAiTimeout > 0) { + (agent as any).maxOperationTimeout = reuseAiTimeout; } try { @@ -1975,15 +1988,17 @@ ${'='.repeat(60)} (options as any).allowEdit = this.config.allowEdit; } - // Wire Visor's check timeout into Probe's graceful timeout mechanism. - // Set maxOperationTimeout 90s before Visor's hard kill so Probe can wind - // down (4 bonus steps + 60s safety net) before the external Promise.race fires. - const timeoutMs = this.config.timeout || 0; - if (timeoutMs > 120000) { - (options as any).maxOperationTimeout = timeoutMs - 90000; - } else if (timeoutMs > 0) { - // Too short for graceful wind-down; let Probe use the full timeout - (options as any).maxOperationTimeout = timeoutMs; + // Wire Probe's graceful timeout (maxOperationTimeout). + // Use explicit aiTimeout if set, otherwise default to timeout - 90s so Probe's + // wind-down (4 bonus steps + 60s safety net) fires before Visor's hard kill. + const visorTimeout = this.config.timeout || 0; + const aiTimeout = + this.config.aiTimeout || + (visorTimeout > MIN_TIMEOUT_FOR_MARGIN_MS + ? visorTimeout - PROBE_GRACEFUL_MARGIN_MS + : visorTimeout); + if (aiTimeout > 0) { + (options as any).maxOperationTimeout = aiTimeout; } // Pass tool filtering options to ProbeAgent (native in rc168+) diff --git a/src/generated/config-schema.ts b/src/generated/config-schema.ts index e36e4ceb3..996351b1f 100644 --- a/src/generated/config-schema.ts +++ b/src/generated/config-schema.ts @@ -1071,7 +1071,7 @@ export const configSchema = { description: 'Arguments/inputs for the workflow', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14812-29498-src_types_config.ts-0-58065%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', description: 'Override specific step configurations in the workflow', }, output_mapping: { @@ -1088,7 +1088,7 @@ export const configSchema = { 'Config file path - alternative to workflow ID (loads a Visor config file as workflow)', }, workflow_overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14812-29498-src_types_config.ts-0-58065%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', description: 'Alias for overrides - workflow step overrides (backward compatibility)', }, ref: { @@ -1407,11 +1407,10 @@ export const configSchema = { description: 'Enable the execute_plan DSL orchestration tool (replaces analyze_all when enabled)', }, - timeout_mode: { - type: 'string', - enum: ['probe', 'visor'], + ai_timeout: { + type: 'number', description: - "Timeout mode: 'probe' lets Probe handle timeout with graceful wind-down (injects \"TIME LIMIT REACHED\" message and bonus steps), 'visor' uses Visor's external Promise.race hard kill (legacy behavior). Default: 'probe'", + 'Probe-level timeout in milliseconds for graceful wind-down (maxOperationTimeout). When set, Probe injects "TIME LIMIT REACHED" and gives bonus steps before hard abort. Defaults to (timeout - 90s) when not explicitly set. The main `timeout` field controls Visor\'s external hard kill (always active).', }, }, additionalProperties: false, @@ -1810,7 +1809,7 @@ export const configSchema = { description: 'Custom output name (defaults to workflow name)', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14812-29498-src_types_config.ts-0-58065%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E%3E', description: 'Step overrides', }, output_mapping: { @@ -1825,14 +1824,14 @@ export const configSchema = { '^x-': {}, }, }, - 'Record>': + 'Record>': { type: 'object', additionalProperties: { - $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14812-29498-src_types_config.ts-0-58065%3E', + $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-14886-29572-src_types_config.ts-0-58139%3E', }, }, - 'Partial': { + 'Partial': { type: 'object', additionalProperties: false, }, diff --git a/src/providers/ai-check-provider.ts b/src/providers/ai-check-provider.ts index 1018ddd1b..8767f1306 100644 --- a/src/providers/ai-check-provider.ts +++ b/src/providers/ai-check-provider.ts @@ -843,8 +843,9 @@ export class AICheckProvider extends CheckProvider { const resolvedTimeout = (await resolveLiquid(aiAny.timeout)) ?? aiAny.timeout; aiConfig.timeout = Number(resolvedTimeout); } - if (aiAny.timeout_mode !== undefined) { - aiConfig.timeoutMode = aiAny.timeout_mode as 'probe' | 'visor'; + if (aiAny.ai_timeout !== undefined) { + const resolvedAiTimeout = (await resolveLiquid(aiAny.ai_timeout)) ?? aiAny.ai_timeout; + aiConfig.aiTimeout = Number(resolvedAiTimeout); } if (aiAny.max_iterations !== undefined || aiAny.maxIterations !== undefined) { const raw = aiAny.max_iterations ?? aiAny.maxIterations; diff --git a/src/providers/check-provider.interface.ts b/src/providers/check-provider.interface.ts index 5945e1903..cd6424ffa 100644 --- a/src/providers/check-provider.interface.ts +++ b/src/providers/check-provider.interface.ts @@ -88,6 +88,12 @@ export interface ExecutionContext { /** reset per-run guard state before grouped execution */ resetPerRunState?: boolean; }; + /** + * Absolute timestamp (ms since epoch) by which this execution must complete. + * Set by the engine from `Date.now() + timeout` and inherited by sub-workflows + * so nested steps know how much time the parent has left. + */ + deadline?: number; /** Optional event bus for emitting integration events (e.g., HumanInputRequested) */ eventBus?: import('../event-bus/event-bus').EventBus; /** Optional webhook context (e.g., Slack Events API payload) */ diff --git a/src/state-machine/dispatch/execution-invoker.ts b/src/state-machine/dispatch/execution-invoker.ts index 9b3be5c39..7e2f8cdfe 100644 --- a/src/state-machine/dispatch/execution-invoker.ts +++ b/src/state-machine/dispatch/execution-invoker.ts @@ -614,6 +614,19 @@ export async function executeSingleCheck( require('../../providers/check-provider-registry').CheckProviderRegistry.getInstance(); const provider = providerRegistry.getProviderOrThrow(providerType); + // Compute effective timeout, capped by any inherited parent deadline. + const configTimeout = checkConfig.timeout || checkConfig.ai?.timeout || 1800000; + const parentDeadline = context.executionContext?.deadline; + let effectiveTimeout = configTimeout; + if (parentDeadline) { + const remaining = parentDeadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Parent deadline exceeded: no time remaining for check '${checkId}'`); + } + effectiveTimeout = Math.min(effectiveTimeout, remaining); + } + const deadline = Date.now() + effectiveTimeout; + const outputHistory = buildOutputHistoryFromJournal(context); // Resolve workflow inputs from config or context (centralized logic) const workflowInputs = resolveWorkflowInputs(checkConfig, context); @@ -638,7 +651,7 @@ export async function executeSingleCheck( workflowInputs, ai: { ...(checkConfig.ai || {}), - timeout: checkConfig.timeout || checkConfig.ai?.timeout || 1800000, + timeout: effectiveTimeout, debug: !!context.debug, }, }; @@ -695,6 +708,8 @@ export async function executeSingleCheck( _parentState: state, // Explicitly propagate workspace reference for nested workflows workspace: context.workspace, + // Propagate deadline so sub-workflows can cap their own timeouts + deadline, }; // Attach session reuse hints for providers that support them (AI, Claude Code, etc). @@ -759,7 +774,7 @@ export async function executeSingleCheck( context, prInfo, dependencyResults, - checkConfig.timeout || checkConfig.ai?.timeout || 1800000, + effectiveTimeout, () => provider.execute(prInfo, providerConfig, dependencyResults, executionContext) ); // Capture output in span for trace visualization diff --git a/src/types/config.ts b/src/types/config.ts index 9b0c07214..e4352c86d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -398,11 +398,12 @@ export interface AIProviderConfig { /** Enable the execute_plan DSL orchestration tool (replaces analyze_all when enabled) */ enableExecutePlan?: boolean; /** - * Timeout mode: 'probe' lets Probe handle timeout with graceful wind-down - * (injects "TIME LIMIT REACHED" message and bonus steps), 'visor' uses - * Visor's external Promise.race hard kill (legacy behavior). Default: 'probe' + * Probe-level timeout in milliseconds for graceful wind-down (maxOperationTimeout). + * When set, Probe injects "TIME LIMIT REACHED" and gives bonus steps before hard abort. + * Defaults to (timeout - 90s) when not explicitly set. + * The main `timeout` field controls Visor's external hard kill (always active). */ - timeout_mode?: 'probe' | 'visor'; + ai_timeout?: number; } /** diff --git a/tests/unit/ai-timeout-and-graceful-margin.test.ts b/tests/unit/ai-timeout-and-graceful-margin.test.ts new file mode 100644 index 000000000..cce1682f4 --- /dev/null +++ b/tests/unit/ai-timeout-and-graceful-margin.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for ai_timeout config field and PROBE_GRACEFUL_MARGIN_MS / MIN_TIMEOUT_FOR_MARGIN_MS + * constants used when computing Probe's maxOperationTimeout from Visor's hard timeout. + */ + +// These constants mirror the values in src/ai-review-service.ts +const PROBE_GRACEFUL_MARGIN_MS = 90_000; +const MIN_TIMEOUT_FOR_MARGIN_MS = PROBE_GRACEFUL_MARGIN_MS + 30_000; // 120_000 + +/** + * Mirrors the maxOperationTimeout derivation logic from ai-review-service.ts + */ +function deriveProbeTimeout(visorTimeout: number, aiTimeout?: number): number { + return ( + aiTimeout || + (visorTimeout > MIN_TIMEOUT_FOR_MARGIN_MS + ? visorTimeout - PROBE_GRACEFUL_MARGIN_MS + : visorTimeout) + ); +} + +describe('ai_timeout and graceful margin', () => { + describe('deriveProbeTimeout (mirrors ai-review-service logic)', () => { + it('should use explicit aiTimeout when provided', () => { + expect(deriveProbeTimeout(1800000, 600000)).toBe(600000); + }); + + it('should use explicit aiTimeout even when shorter than visor timeout', () => { + expect(deriveProbeTimeout(1800000, 30000)).toBe(30000); + }); + + it('should use explicit aiTimeout even when longer than visor timeout', () => { + // User might want Probe to run longer than Visor's hard kill + // (Visor's Promise.race will still terminate, but Probe starts winding down later) + expect(deriveProbeTimeout(60000, 120000)).toBe(120000); + }); + + it('should subtract margin when visor timeout > MIN_TIMEOUT_FOR_MARGIN_MS', () => { + // 30 minutes → 30min - 90s = 1710000 + expect(deriveProbeTimeout(1800000)).toBe(1800000 - PROBE_GRACEFUL_MARGIN_MS); + expect(deriveProbeTimeout(1800000)).toBe(1710000); + }); + + it('should use full visor timeout when at exactly MIN_TIMEOUT_FOR_MARGIN_MS', () => { + // At exactly 120s, condition is >, so it does NOT subtract + expect(deriveProbeTimeout(MIN_TIMEOUT_FOR_MARGIN_MS)).toBe(MIN_TIMEOUT_FOR_MARGIN_MS); + expect(deriveProbeTimeout(120000)).toBe(120000); + }); + + it('should use full visor timeout when below MIN_TIMEOUT_FOR_MARGIN_MS', () => { + expect(deriveProbeTimeout(60000)).toBe(60000); + expect(deriveProbeTimeout(30000)).toBe(30000); + expect(deriveProbeTimeout(1000)).toBe(1000); + }); + + it('should use full visor timeout when just above MIN_TIMEOUT_FOR_MARGIN_MS', () => { + // 120001ms → subtracts margin + expect(deriveProbeTimeout(MIN_TIMEOUT_FOR_MARGIN_MS + 1)).toBe( + MIN_TIMEOUT_FOR_MARGIN_MS + 1 - PROBE_GRACEFUL_MARGIN_MS + ); + // = 120001 - 90000 = 30001 + expect(deriveProbeTimeout(120001)).toBe(30001); + }); + + it('should handle zero visor timeout', () => { + expect(deriveProbeTimeout(0)).toBe(0); + }); + + it('should prefer explicit aiTimeout=0 over default derivation', () => { + // aiTimeout=0 is falsy, so falls through to default derivation + // This is by design: 0 means "not set" + expect(deriveProbeTimeout(1800000, 0)).toBe(1710000); + }); + }); + + describe('constant relationships', () => { + it('PROBE_GRACEFUL_MARGIN_MS should be 90 seconds', () => { + expect(PROBE_GRACEFUL_MARGIN_MS).toBe(90_000); + }); + + it('MIN_TIMEOUT_FOR_MARGIN_MS should be margin + 30s headroom', () => { + expect(MIN_TIMEOUT_FOR_MARGIN_MS).toBe(PROBE_GRACEFUL_MARGIN_MS + 30_000); + }); + + it('margin should leave at least 30s for Probe when subtracting', () => { + // The minimum visor timeout that triggers subtraction is MIN_TIMEOUT_FOR_MARGIN_MS + 1 + const minSubtractedResult = MIN_TIMEOUT_FOR_MARGIN_MS + 1 - PROBE_GRACEFUL_MARGIN_MS; + expect(minSubtractedResult).toBeGreaterThanOrEqual(30_000); + }); + }); + + describe('integration: aiTimeout overrides default derivation', () => { + it('should allow user to set precise Probe timeout independent of Visor', () => { + // User sets visor timeout=600s, ai_timeout=300s + // Probe winds down at 300s, Visor hard kills at 600s + const probeTimeout = deriveProbeTimeout(600000, 300000); + expect(probeTimeout).toBe(300000); + // Without ai_timeout, would be 600000 - 90000 = 510000 + expect(deriveProbeTimeout(600000)).toBe(510000); + }); + + it('should allow user to disable margin subtraction via ai_timeout = visor timeout', () => { + const visor = 1800000; + expect(deriveProbeTimeout(visor, visor)).toBe(visor); + }); + }); +}); diff --git a/tests/unit/deadline-budget-propagation.test.ts b/tests/unit/deadline-budget-propagation.test.ts new file mode 100644 index 000000000..c9c4154c0 --- /dev/null +++ b/tests/unit/deadline-budget-propagation.test.ts @@ -0,0 +1,176 @@ +/** + * Tests for deadline-based budget propagation through execution context. + * + * When a parent check sets a deadline, sub-workflows should have their + * effective timeout capped to the remaining time budget. + */ + +/** + * Mirrors the logic from execution-invoker.ts for computing effective timeout + */ +function computeEffectiveTimeout( + checkConfig: { timeout?: number; ai?: { timeout?: number } }, + parentDeadline?: number +): { effectiveTimeout: number; deadline: number } { + const configTimeout = checkConfig.timeout || checkConfig.ai?.timeout || 1800000; + let effectiveTimeout = configTimeout; + if (parentDeadline) { + const remaining = parentDeadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Parent deadline exceeded: no time remaining`); + } + effectiveTimeout = Math.min(effectiveTimeout, remaining); + } + const deadline = Date.now() + effectiveTimeout; + return { effectiveTimeout, deadline }; +} + +describe('deadline budget propagation', () => { + describe('effective timeout computation', () => { + it('should cap effective timeout to remaining parent budget', () => { + const parentDeadline = Date.now() + 60000; // 60s remaining + const { effectiveTimeout } = computeEffectiveTimeout( + { timeout: 1800000 }, // 30 min config + parentDeadline + ); + expect(effectiveTimeout).toBeLessThanOrEqual(60100); // small tolerance for test execution time + expect(effectiveTimeout).toBeGreaterThan(59000); + }); + + it('should use config timeout when no parent deadline exists', () => { + const { effectiveTimeout } = computeEffectiveTimeout({ timeout: 1800000 }, undefined); + expect(effectiveTimeout).toBe(1800000); + }); + + it('should throw when parent deadline is already exceeded', () => { + const parentDeadline = Date.now() - 1000; // 1 second ago + expect(() => computeEffectiveTimeout({ timeout: 1800000 }, parentDeadline)).toThrow( + 'Parent deadline exceeded' + ); + }); + + it('should throw when parent deadline is exactly now', () => { + const parentDeadline = Date.now() - 1; // just passed + expect(() => computeEffectiveTimeout({ timeout: 1800000 }, parentDeadline)).toThrow( + 'Parent deadline exceeded' + ); + }); + + it('should use config timeout when shorter than remaining budget', () => { + const parentDeadline = Date.now() + 300000; // 5 min remaining + const { effectiveTimeout } = computeEffectiveTimeout( + { timeout: 30000 }, // 30s config — shorter than budget + parentDeadline + ); + expect(effectiveTimeout).toBe(30000); + }); + + it('should fall back to ai.timeout when no check-level timeout', () => { + const { effectiveTimeout } = computeEffectiveTimeout( + { ai: { timeout: 600000 } }, // 10 min AI timeout + undefined + ); + expect(effectiveTimeout).toBe(600000); + }); + + it('should fall back to default 1800000 when no timeout configured', () => { + const { effectiveTimeout } = computeEffectiveTimeout({}, undefined); + expect(effectiveTimeout).toBe(1800000); + }); + + it('should cap ai.timeout against parent deadline', () => { + const parentDeadline = Date.now() + 30000; // 30s remaining + const { effectiveTimeout } = computeEffectiveTimeout( + { ai: { timeout: 600000 } }, // 10 min AI timeout + parentDeadline + ); + expect(effectiveTimeout).toBeLessThanOrEqual(30100); + expect(effectiveTimeout).toBeGreaterThan(29000); + }); + + it('should handle very small remaining budget', () => { + const parentDeadline = Date.now() + 100; // 100ms remaining + const { effectiveTimeout } = computeEffectiveTimeout({ timeout: 1800000 }, parentDeadline); + expect(effectiveTimeout).toBeLessThanOrEqual(200); + expect(effectiveTimeout).toBeGreaterThan(0); + }); + }); + + describe('deadline propagation through nested contexts', () => { + it('should propagate deadline through execution context to child', () => { + // Parent sets a 60s deadline + const { deadline: parentDeadline } = computeEffectiveTimeout({ timeout: 60000 }, undefined); + + const executionContext = { deadline: parentDeadline }; + + // Child reads parent deadline — its 30-min config gets capped + const { effectiveTimeout: childTimeout } = computeEffectiveTimeout( + { timeout: 1800000 }, + executionContext.deadline + ); + + expect(childTimeout).toBeLessThanOrEqual(60100); + expect(childTimeout).toBeGreaterThan(59000); + }); + + it('should propagate budget through multiple nesting levels', () => { + // Level 0: 2 minute budget + const { deadline: l0Deadline } = computeEffectiveTimeout({ timeout: 120000 }, undefined); + + // Level 1: inherits from level 0, configured at 5 min (capped to ~2 min) + const { deadline: l1Deadline, effectiveTimeout: l1Timeout } = computeEffectiveTimeout( + { timeout: 300000 }, + l0Deadline + ); + expect(l1Timeout).toBeLessThanOrEqual(120100); + + // Level 2: inherits from level 1, configured at 30 min (capped to ~2 min) + const { effectiveTimeout: l2Timeout } = computeEffectiveTimeout( + { timeout: 1800000 }, + l1Deadline + ); + expect(l2Timeout).toBeLessThanOrEqual(120100); + expect(l2Timeout).toBeGreaterThan(0); + }); + + it('should shrink budget at each nesting level as time elapses', async () => { + // Level 0: 500ms budget + const { deadline: l0Deadline } = computeEffectiveTimeout({ timeout: 500 }, undefined); + + // Simulate 200ms of work + await new Promise(resolve => setTimeout(resolve, 200)); + + // Level 1: should have ~300ms remaining + const { effectiveTimeout: l1Timeout } = computeEffectiveTimeout( + { timeout: 1800000 }, + l0Deadline + ); + expect(l1Timeout).toBeLessThanOrEqual(350); + expect(l1Timeout).toBeGreaterThan(200); + }); + + it('should fail fast when parent budget exhausted between levels', async () => { + // Level 0: 100ms budget + const { deadline: l0Deadline } = computeEffectiveTimeout({ timeout: 100 }, undefined); + + // Simulate 150ms of work — exceeds budget + await new Promise(resolve => setTimeout(resolve, 150)); + + // Level 1: should throw + expect(() => computeEffectiveTimeout({ timeout: 1800000 }, l0Deadline)).toThrow( + 'Parent deadline exceeded' + ); + }); + }); + + describe('check-level timeout takes precedence over check.ai.timeout', () => { + it('should prefer check timeout over ai timeout', () => { + const { effectiveTimeout } = computeEffectiveTimeout( + { timeout: 60000, ai: { timeout: 300000 } }, + undefined + ); + // check.timeout is truthy, so it wins + expect(effectiveTimeout).toBe(60000); + }); + }); +});