diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index b047447ec16..5ce4e4d603b 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2321,6 +2321,8 @@ async function loadToolsForExecution({ maxRequestTimeoutMs: resolveAttachedWorkspaceRequestTimeoutMs( codeExecutionContext.codeEnvironmentConfigSchema, ), + minCommandAdmissionMs: + codeExecutionContext.codeEnvironmentConfigSchema?.limits?.minCommandAdmissionMs, }) : createBashExecutionTool({ authHeaders, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 9c4d57eefda..e8156e9f6c9 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -2980,7 +2980,14 @@ describe('ToolService - Action Capability Gating', () => { environmentType: 'attached', environmentId: 'personal-machine', bridgeWorkerId: 'worker-abc', - codeEnvironmentConfigSchema: { limits: { maxCommandTimeoutMs: 120000, maxQueueWaitMs: 0 } }, + codeEnvironmentConfigSchema: { + limits: { + maxCommandTimeoutMs: 80_000, + maxQueueWaitMs: 0, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 15_000, + }, + }, }); const toolRegistry = new Map([ [AgentConstants.BASH_TOOL, { name: AgentConstants.BASH_TOOL }], @@ -3006,9 +3013,11 @@ describe('ToolService - Action Capability Gating', () => { baseUrl: 'http://attached-code.test/v1', workspaceId: 'project-a', gitIdentity: { name: 'LibreChat Agent', email: 'agent@example.com' }, - maxTimeoutMs: 120000, + maxTimeoutMs: 65_000, maxQueueWaitMs: 0, codeApiMaxRetryWaitMs: 0, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 15_000, }); expect(mockResolveCodeExecutionWorkspaceContext).toHaveBeenCalledWith( expect.objectContaining({ requestedSelections: req.body.codeWorkspaces }), diff --git a/e2e/specs/mock/activity-fold.spec.ts b/e2e/specs/mock/activity-fold.spec.ts index 05d689900c0..7a588ce22fe 100644 --- a/e2e/specs/mock/activity-fold.spec.ts +++ b/e2e/specs/mock/activity-fold.spec.ts @@ -89,7 +89,7 @@ test.describe('activity fold', () => { const pill = messagesView(page).getByTestId('failed-reveal-pill'); const peek = messagesView(page).getByTestId('activity-phase-failed-peek'); await expect(pill).toBeVisible(); - await expect(pill).toHaveAccessibleName('Show failed call'); + await expect(pill).toHaveAccessibleName('Show 1 failed call out of 4 calls'); await expect(peek).toBeVisible(); await expect(peek).toContainText('Failed:'); await expect(peek).toContainText('Show error'); diff --git a/e2e/specs/mock/steering-escalation.spec.ts b/e2e/specs/mock/steering-escalation.spec.ts index 615bfc33659..dfb1b9f1eb3 100644 --- a/e2e/specs/mock/steering-escalation.spec.ts +++ b/e2e/specs/mock/steering-escalation.spec.ts @@ -206,14 +206,14 @@ test.describe('escalating waiting messages to an interrupt', () => { // The toggle lives in the row menu's separated Preferences section. await row.getByRole('button', { name: 'More options' }).click(); await expect(page.getByText('Preferences', { exact: true })).toBeVisible({ timeout: 5000 }); - await page.getByRole('menuitem', { name: 'Always interrupt instead', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Steer sooner by default', exact: true }).click(); // Verify the preference flips while this row is guaranteed to remain // parked. After the interrupt is submitted the run may seal and auto-drain // the row before another locator action can observe it. await row.getByRole('button', { name: 'More options' }).click(); await expect( - page.getByRole('menuitem', { name: 'Wait for tool steps instead', exact: true }), + page.getByRole('menuitem', { name: 'Wait for the next step instead', exact: true }), ).toBeVisible({ timeout: 5000 }); await page.keyboard.press('Escape'); diff --git a/librechat.example.yaml b/librechat.example.yaml index 2f8caedec6d..fcacb78c34c 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -728,9 +728,16 @@ endpoints: # # ingress, and caller can all outlast this value. The Nginx example # # (120s) cannot establish the live limit. Omission keeps 30s admission. # # With 125s total, a read can queue <=90s, a default command <=85s, - # # a 90s command <=25s. Commands >115s cannot fit; the 120s command - # # ceiling above requires a larger VERIFIED total budget to be usable. + # # a 90s command <=25s. With the default 10s admission reserve, + # # commands >105s cannot fit; the 120s command ceiling above requires + # # a VERIFIED total budget of at least 140s to be usable. # # maxRequestTimeoutMs: 125000 + # # Optional command admission allowance before local dispatch overhead. + # # Omission reserves 10s; valid range: 1000–300000 ms. The HTTP budget + # # must exceed this allowance plus 10s settlement/delivery grace; + # # with the 10s default, a budget of 20s or less is rejected. + # # Smaller allowances give a busy worker less time to accept the call. + # # minCommandAdmissionMs: 10000 # permissions: # fileWrite: # allowed: [allow, ask, deny] diff --git a/packages/api/src/code/command.spec.ts b/packages/api/src/code/command.spec.ts index b4cc19e0c95..b027583ef6a 100644 --- a/packages/api/src/code/command.spec.ts +++ b/packages/api/src/code/command.spec.ts @@ -395,6 +395,153 @@ describe('createAttachedWorkspaceBashTool', () => { expect(foregroundRequest).toMatchObject({ timeoutMs: 30_000 }); }); + test('fits the command ceiling inside a configured total HTTP budget', () => { + expect( + resolveAttachedWorkspaceCommandTimeoutMax({ + limits: { maxCommandTimeoutMs: 80_000, maxRequestTimeoutMs: 90_000 }, + }), + ).toBe(70_000); + expect( + resolveAttachedWorkspaceCommandTimeoutMax({ + limits: { maxCommandTimeoutMs: 60_000, maxRequestTimeoutMs: 90_000 }, + }), + ).toBe(60_000); + expect( + resolveAttachedWorkspaceCommandTimeoutMax({ + limits: { + maxCommandTimeoutMs: 80_000, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 15_000, + }, + }), + ).toBe(65_000); + expect( + resolveAttachedWorkspaceCommandTimeoutMax({ + limits: { + maxCommandTimeoutMs: 80_000, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 1_000, + }, + }), + ).toBe(79_000); + expect( + resolveAttachedWorkspaceCommandTimeoutMax( + { limits: { maxCommandTimeoutMs: 120_000, maxRequestTimeoutMs: 125_000 } }, + 90_000, + ), + ).toBe(90_000); + expect( + resolveAttachedWorkspaceCommandTimeoutMax({ limits: { maxCommandTimeoutMs: 120_000 } }), + ).toBe(120_000); + }); + + test('starts an omitted background timeout that the configured budget can carry', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_000); + const configSchema = { limits: { maxCommandTimeoutMs: 80_000, maxRequestTimeoutMs: 90_000 } }; + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + workspaceId: 'project-a', + maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax(configSchema), + maxRequestTimeoutMs: resolveAttachedWorkspaceRequestTimeoutMs(configSchema), + fetchImpl, + }); + + await bashTool.invoke( + { command: 'npm test' }, + { configurable: { [BACKGROUND_TOOL_INVOCATION_CONFIG_KEY]: true } }, + ); + + const [, init] = (fetchImpl as jest.Mock).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ timeoutMs: 70_000 }); + expect(init?.headers['X-LibreChat-Workspace-Queue-Wait-Ms']).toBe('10000'); + jest.restoreAllMocks(); + }); + + test('reserves the configured admission allowance for an omitted background timeout', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_000); + const configSchema = { + limits: { + maxCommandTimeoutMs: 80_000, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 15_000, + }, + }; + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + workspaceId: 'project-a', + maxTimeoutMs: resolveAttachedWorkspaceCommandTimeoutMax(configSchema), + maxRequestTimeoutMs: resolveAttachedWorkspaceRequestTimeoutMs(configSchema), + minCommandAdmissionMs: configSchema.limits.minCommandAdmissionMs, + fetchImpl, + }); + + expect(bashTool.schema).toMatchObject({ + properties: { timeoutMs: expect.objectContaining({ maximum: 65_000 }) }, + }); + await bashTool.invoke( + { command: 'npm test' }, + { configurable: { [BACKGROUND_TOOL_INVOCATION_CONFIG_KEY]: true } }, + ); + const [, init] = (fetchImpl as jest.Mock).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ timeoutMs: 65_000 }); + expect(init?.headers['X-LibreChat-Workspace-Queue-Wait-Ms']).toBe('15000'); + await expect( + bashTool.func({ command: 'npm test', timeoutMs: 80_000 }, undefined, {}), + ).rejects.toThrow('deployment limit of 65000 milliseconds'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + jest.restoreAllMocks(); + }); + + test('still dispatches at the smallest supported reserve after credential-signing time', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1_000); + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => { + now.mockReturnValue(1_200); + return {}; + }, + workspaceId: 'project-a', + maxTimeoutMs: 80_000, + maxRequestTimeoutMs: 90_000, + minCommandAdmissionMs: 1_000, + fetchImpl, + }); + + await bashTool.invoke( + { command: 'npm test' }, + { configurable: { [BACKGROUND_TOOL_INVOCATION_CONFIG_KEY]: true } }, + ); + const [, init] = (fetchImpl as jest.Mock).mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ timeoutMs: 79_000 }); + expect(init?.headers['X-LibreChat-Workspace-Queue-Wait-Ms']).toBe('800'); + jest.restoreAllMocks(); + }); + + test('advertises and enforces only the timeout that fits when given both limits directly', async () => { + const fetchImpl: CodeBridgeFetch = jest.fn(async () => commandResponse()); + const bashTool = createAttachedWorkspaceBashTool({ + baseUrl: 'https://code.example.com/v1', + authHeaders: () => ({}), + workspaceId: 'project-a', + maxTimeoutMs: 80_000, + maxRequestTimeoutMs: 90_000, + fetchImpl, + }); + + expect(bashTool.schema).toMatchObject({ + properties: { timeoutMs: expect.objectContaining({ maximum: 70_000 }) }, + }); + await expect( + bashTool.func({ command: 'npm test', timeoutMs: 80_000 }, undefined, {}), + ).rejects.toThrow('deployment limit of 70000 milliseconds'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + test('resolves the administrator-configured retry horizon', () => { expect(resolveAttachedWorkspaceQueueWaitMs()).toBe(5 * 60_000); expect(resolveAttachedWorkspaceQueueWaitMs({ limits: { maxQueueWaitMs: 30_000 } })).toBe( diff --git a/packages/api/src/code/command.ts b/packages/api/src/code/command.ts index da191a5ec9b..7981204f2ad 100644 --- a/packages/api/src/code/command.ts +++ b/packages/api/src/code/command.ts @@ -17,6 +17,7 @@ import type { WorkspaceExecuteCommandResult } from './workspace'; import type { CodeExecutionContext } from '~/agents/execution'; import type { CodeBridgeFetch } from './bridge'; import { + fitWorkspaceCommandTimeoutToBudget, executeWorkspaceTool, WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, WORKSPACE_COMMAND_MAX_TIMEOUT_MS, @@ -102,7 +103,23 @@ export function resolveAttachedWorkspaceCommandTimeoutMax( } else if (upstreamMaxTimeoutMs != null) { requested = upstream; } - return Math.min(requested, upstream); + return fitCommandTimeoutMaxToBudget( + Math.min(requested, upstream), + resolveAttachedWorkspaceRequestTimeoutMs(configSchema), + configSchema?.limits?.minCommandAdmissionMs, + ); +} + +function fitCommandTimeoutMaxToBudget( + maxTimeoutMs: number, + maxRequestTimeoutMs?: number, + minCommandAdmissionMs?: number, +): number { + if (maxRequestTimeoutMs == null) return maxTimeoutMs; + return Math.min( + maxTimeoutMs, + fitWorkspaceCommandTimeoutToBudget(maxRequestTimeoutMs, minCommandAdmissionMs), + ); } /** @@ -304,6 +321,7 @@ export function createAttachedWorkspaceBashTool({ maxQueueWaitMs, codeApiMaxRetryWaitMs, maxRequestTimeoutMs, + minCommandAdmissionMs, fetchImpl, }: { baseUrl: string; @@ -319,9 +337,15 @@ export function createAttachedWorkspaceBashTool({ codeApiMaxRetryWaitMs?: number; /** Verified total HTTP budget; omission keeps the legacy per-attempt timeout. */ maxRequestTimeoutMs?: number; + /** Minimum time for command admission inside an opted-in HTTP budget. */ + minCommandAdmissionMs?: number; fetchImpl?: CodeBridgeFetch; }): DynamicStructuredTool { - const effectiveMaxTimeoutMs = normalizeAttachedWorkspaceCommandTimeoutMax(maxTimeoutMs); + const effectiveMaxTimeoutMs = fitCommandTimeoutMaxToBudget( + normalizeAttachedWorkspaceCommandTimeoutMax(maxTimeoutMs), + maxRequestTimeoutMs, + minCommandAdmissionMs, + ); const schema = structuredClone( buildAttachedWorkspaceBashSchema(effectiveMaxTimeoutMs, environment), ); diff --git a/packages/api/src/code/workspace.ts b/packages/api/src/code/workspace.ts index 7f43a2ea984..c953dffbfa9 100644 --- a/packages/api/src/code/workspace.ts +++ b/packages/api/src/code/workspace.ts @@ -1,5 +1,6 @@ import { CODE_ENVIRONMENT_ADMISSION_MAX_MS, + CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS, CODE_ENVIRONMENT_QUEUE_WAIT_DEFAULT_MS, CODE_ENVIRONMENT_REQUEST_TIMEOUT_HARD_MAX_MS, } from 'librechat-data-provider'; @@ -31,6 +32,24 @@ const WORKSPACE_QUEUE_WAIT_HEADER = 'X-LibreChat-Workspace-Queue-Wait-Ms'; export const WORKSPACE_QUEUE_MAX_WAIT_MS: number = CODE_ENVIRONMENT_QUEUE_WAIT_DEFAULT_MS; const WORKSPACE_QUEUE_RETRY_DELAY_MS = 1_000; const WORKSPACE_COMMAND_SETTLEMENT_GRACE_MS = 5_000; + +/** + * Longest command timeout a total HTTP budget can carry: the budget minus settlement and delivery + * grace and a minimum admission allowance. A command whose reserve reaches the budget is refused + * before dispatch, so a larger ceiling would only advertise timeouts that can never start. + */ +export function fitWorkspaceCommandTimeoutToBudget( + maxRequestTimeoutMs: number, + minCommandAdmissionMs: number = CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS, +): number { + return Math.max( + 1, + maxRequestTimeoutMs - + WORKSPACE_COMMAND_SETTLEMENT_GRACE_MS - + WORKSPACE_COMMAND_TRANSPORT_GRACE_MS - + minCommandAdmissionMs, + ); +} const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_ERROR_BODY_BYTES = 4096; const ERROR_BODY_TIMEOUT_MS = 1000; diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 2dd82eaa88a..6ef043dbe16 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -7,6 +7,7 @@ import { bedrockModels, configSchema, codeEnvironmentUserConfigSchema, + CODE_ENVIRONMENT_ADMISSION_MAX_MS, excludedKeys, resolveEndpointType, webSearchSchema, @@ -583,6 +584,105 @@ describe('attached code environment user config schema', () => { expect(codeEnvironmentUserConfigSchema.parse({ limits: {} })).toEqual({ limits: {} }); }); + it.each([1_000, 15_000, CODE_ENVIRONMENT_ADMISSION_MAX_MS])( + 'accepts a bounded %i ms command admission allowance', + (minCommandAdmissionMs) => { + expect(codeEnvironmentUserConfigSchema.parse({ limits: { minCommandAdmissionMs } })).toEqual({ + limits: { minCommandAdmissionMs }, + }); + }, + ); + + it.each([0, -1, 0.5, 999, CODE_ENVIRONMENT_ADMISSION_MAX_MS + 1, NaN, Infinity])( + 'rejects an invalid command admission allowance of %s', + (minCommandAdmissionMs) => { + expect( + codeEnvironmentUserConfigSchema.safeParse({ limits: { minCommandAdmissionMs } }).success, + ).toBe(false); + }, + ); + + it.each([20_001, 90_000])( + 'preserves omission of the command admission allowance with a fitting %i ms budget', + (maxRequestTimeoutMs) => { + expect(codeEnvironmentUserConfigSchema.parse({ limits: { maxRequestTimeoutMs } })).toEqual({ + limits: { maxRequestTimeoutMs }, + }); + }, + ); + + it.each([5_000, 10_002, 15_000, 20_000])( + 'rejects an undersized %i ms request budget with the default command reserve', + (maxRequestTimeoutMs) => { + const parsed = codeEnvironmentUserConfigSchema.safeParse({ limits: { maxRequestTimeoutMs } }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['limits', 'maxRequestTimeoutMs'] }), + ]), + ); + } + }, + ); + + it.each([ + { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 79_999 }, + { maxRequestTimeoutMs: 11_001, minCommandAdmissionMs: 1_000 }, + { maxRequestTimeoutMs: 610_000, minCommandAdmissionMs: 300_000 }, + ])('accepts an admission reserve with execution time left: %j', (limits) => { + expect(codeEnvironmentUserConfigSchema.parse({ limits })).toEqual({ limits }); + }); + + it.each([ + { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 80_000 }, + { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 100_000 }, + { maxRequestTimeoutMs: 11_000, minCommandAdmissionMs: 1_000 }, + ])('rejects a command reserve that cannot fit inside its request budget: %j', (limits) => { + const parsed = codeEnvironmentUserConfigSchema.safeParse({ limits }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['limits', 'minCommandAdmissionMs'] }), + ]), + ); + } + }); + + it('allows a command reserve without a request budget (the legacy per-attempt path)', () => { + expect( + codeEnvironmentUserConfigSchema.parse({ limits: { minCommandAdmissionMs: 300_000 } }), + ).toEqual({ limits: { minCommandAdmissionMs: 300_000 } }); + }); + + it.each([ + { maxRequestTimeoutMs: 90_000, minCommandAdmissionMs: 100_000 }, + { maxRequestTimeoutMs: 15_000 }, + ])('rejects an impossible command reserve in the top-level deployment config: %j', (limits) => { + expect( + configSchema.safeParse({ + version: '1.0', + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://code.example.com/v1', + configSchema: { limits }, + }, + ], + }, + }, + }, + }).success, + ).toBe(false); + }); + it('accepts typed permission controls exposed by the administrator', () => { const result = configSchema.safeParse({ version: '1.0', @@ -602,7 +702,11 @@ describe('attached code environment user config schema', () => { fileWrite: { allowed: ['allow', 'ask', 'deny'], default: 'ask' }, commandExecution: { allowed: ['ask', 'deny'], default: 'ask' }, }, - limits: { maxCommandTimeoutMs: 120000, maxRequestTimeoutMs: 125_000 }, + limits: { + maxCommandTimeoutMs: 120000, + maxRequestTimeoutMs: 125_000, + minCommandAdmissionMs: 15_000, + }, }, }, ], @@ -621,7 +725,9 @@ describe('attached code environment user config schema', () => { statefulCodeSessions: { environments: [ { - configSchema: { limits: { maxRequestTimeoutMs: 125_000 } }, + configSchema: { + limits: { maxRequestTimeoutMs: 125_000, minCommandAdmissionMs: 15_000 }, + }, }, ], }, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index bc84677a2fd..815ec69d87e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1207,9 +1207,15 @@ export const CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS = 5 * 60_000; export const CODE_ENVIRONMENT_QUEUE_WAIT_DEFAULT_MS = 5 * 60_000; /** Code API's per-request admission ceiling, independent of the retry horizon. */ export const CODE_ENVIRONMENT_ADMISSION_MAX_MS = 5 * 60_000; -/** Maximum opt-in HTTP budget: five minutes of admission and execution plus ten seconds for settlement and delivery. */ +/** Minimum command admission time reserved inside an opted-in HTTP budget. */ +export const CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS = 10_000; +/** Five seconds each for command settlement and transport delivery. */ +const CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS = 10_000; +/** Maximum opt-in HTTP budget: five minutes of admission and execution plus settlement and delivery. */ export const CODE_ENVIRONMENT_REQUEST_TIMEOUT_HARD_MAX_MS = - CODE_ENVIRONMENT_ADMISSION_MAX_MS + CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS + 10_000; + CODE_ENVIRONMENT_ADMISSION_MAX_MS + + CODE_ENVIRONMENT_COMMAND_TIMEOUT_HARD_MAX_MS + + CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS; /** * Typed user-tunable surface for one attached code environment. Omitted fields @@ -1256,8 +1262,33 @@ export const codeEnvironmentUserConfigSchema = z .min(1) .max(CODE_ENVIRONMENT_REQUEST_TIMEOUT_HARD_MAX_MS) .optional(), + /** Admission allowance before local dispatch overhead for a Bash command inside + * maxRequestTimeoutMs. Omission reserves ten seconds; ignored without a total HTTP budget. */ + minCommandAdmissionMs: z + .number() + .int() + .min(1_000) + .max(CODE_ENVIRONMENT_ADMISSION_MAX_MS) + .optional(), }) .strict() + .superRefine((limits, context) => { + if ( + limits.maxRequestTimeoutMs == null || + limits.maxRequestTimeoutMs > + (limits.minCommandAdmissionMs ?? CODE_ENVIRONMENT_COMMAND_ADMISSION_DEFAULT_MS) + + CODE_ENVIRONMENT_COMMAND_BUDGET_GRACE_MS + ) { + return; + } + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [ + limits.minCommandAdmissionMs == null ? 'maxRequestTimeoutMs' : 'minCommandAdmissionMs', + ], + message: 'Command admission and settlement reserves must leave time for execution', + }); + }) .optional(), }) .strict();