/** * Play Execution Governor — the single policy table. * * This is the ONE source of truth for every concurrency, budget, and pacing * number in the CJS/Node play runtime. See ADR 0007 and the "Play Execution * Governor" entry in CONTEXT.md. * * Tuning philosophy (product decision): * - Caps are explicit resource admission boundaries. A run should queue behind * them inside the executor instead of manufacturing thousands of promises, * receipts, sockets, or row payload copies up front. * - The real-time bounds on outbound work are still per-provider pacing and * provider latency. Row concurrency exists to keep the sandbox healthy while * enough work is admitted to saturate normal provider RPS. * - Every value is an EXPLICIT, finite cap. Nothing here is unbounded — there * is no `null`, no "off", no implicit infinity. Unaccounted resource use * must be impossible. * * The docs catalog (`src/lib/plays/limits-catalog.ts`) and its generated public * + internal pages source their numbers from this table. */ /** Concurrency ceilings — how much may run at once. */ export interface ExecutionConcurrencyPolicy { /** Map rows resolving their fields concurrently when no `concurrency` is given. */ readonly rowDefault: number; /** Hard ceiling for an explicit map `concurrency` value; larger is clamped. */ readonly rowMax: number; /** Global backstop on concurrently in-flight tool calls across all providers. */ readonly toolCalls: number; /** * Brief runtime-to-app HTTP requests concurrently using sockets. Logical * provider residence does not consume this admission. */ readonly integrationRequests: number; /** Runner-local cap on concurrently active tool scheduling groups. */ readonly toolDispatchGroups: number; /** Runner-local cap on active scheduling groups for one scalar/batch lane. */ readonly toolDispatchGroupsPerLane: number; } /** * Per-run budgets — total attempts allowed before a run is treated as runaway. * Applied inside one run. Inline `ctx.runPlay` composition has a separate * recursion-depth guard; it never creates a second scheduled run. */ export interface ExecutionBudgetPolicy { /** Max nesting depth of `ctx.runPlay` chains. Deeper is almost certainly a cycle. */ readonly maxPlayCallDepth: number; /** Max total tool calls in a run. */ readonly maxToolCallCount: number; /** Max total retries across all steps/tools. */ readonly maxRetryCount: number; /** Max total waterfall-step executions in a run. */ readonly maxWaterfallStepExecutions: number; } /** * Per-provider rate pacing — the real outbound throughput governor. * * Per-provider request rate and optional in-flight concurrency are enforced by * the same CJS/Node substrate. See PacingRule.maxConcurrency in * rate-state-backend.ts. */ export interface ExecutionPacingPolicy { /** RPS applied to a provider that declares no explicit rate limit. */ readonly defaultProviderRequestsPerSecond: number; /** Parallelism the scheduler suggests before a provider's own hints tighten it. */ readonly suggestedMaxParallelism: number; /** Elapsed isolate work window before the worker dispatcher yields. */ readonly workerYieldElapsedMs: number; /** Fallback batch parallelism when a tool declares no provider hints. */ readonly workerToolBatchDefaultParallelism: number; } export interface ResolvedExecutionPolicy { readonly concurrency: ExecutionConcurrencyPolicy; readonly budgets: ExecutionBudgetPolicy; readonly pacing: ExecutionPacingPolicy; } export const DEFAULT_MAX_CONCURRENT_EXTERNAL_CALLS = 64; export const MAX_CONFIGURABLE_CONCURRENT_EXTERNAL_CALLS = 256; export const MAX_CONFIGURABLE_CONCURRENT_ROWS = 1_000; // Dispatch groups are only a scheduler envelope. The external-call semaphore // remains the resource bound, so do not impose a lower hidden ceiling here. // This lets an explicit run-level call limit describe the actual concurrency // available to long-residence providers while still bounding live calls. export const DEFAULT_MAX_IN_FLIGHT_TOOL_DISPATCH_GROUPS = MAX_CONFIGURABLE_CONCURRENT_EXTERNAL_CALLS; // Launches persisted before this field existed used the original fixed // eight-slot policy. Preserve that value on their retries and resumes. export const LEGACY_MAX_CONCURRENT_EXTERNAL_CALLS = 8; export function resolveMaxConcurrentExternalCalls( requested?: number | null, ): number { if (requested === undefined || requested === null) { return DEFAULT_MAX_CONCURRENT_EXTERNAL_CALLS; } if ( !Number.isSafeInteger(requested) || requested < 1 || requested > MAX_CONFIGURABLE_CONCURRENT_EXTERNAL_CALLS ) { throw new Error( `maxConcurrentExternalCalls must be a whole number from 1 through ${MAX_CONFIGURABLE_CONCURRENT_EXTERNAL_CALLS}.`, ); } return requested; } export function resolveMaxConcurrentRows( requested?: number | null, ): number | null { if (requested === undefined || requested === null) return null; if ( !Number.isSafeInteger(requested) || requested < 1 || requested > MAX_CONFIGURABLE_CONCURRENT_ROWS ) { throw new Error( `maxConcurrentRows must be a whole number from 1 through ${MAX_CONFIGURABLE_CONCURRENT_ROWS}.`, ); } return requested; } /** * The shared default policy. Both substrates use this verbatim unless an entry * in {@link ADAPTER_POLICY_OVERRIDES} forces a documented difference. */ export const SHARED_EXECUTION_POLICY: ResolvedExecutionPolicy = { concurrency: { // Bound the complete live row promise graph, not only outbound calls. // Cached provider payloads can expand far beyond their encoded receipt size // while Node parses and delivers result lists. Twenty rows leave enough // headroom in a standard 1 GiB sandbox while still filling the default // external-call scheduling group. Pending rows remain compact indexes. // Pure-compute maps // use a separate fast path and do not pay this bound. rowDefault: 20, // Internal callers can request a wider window, but the default remains the // safe standard-sandbox resident set. The input-byte estimator may reduce // either value further for large source rows. rowMax: 1_000, // Global logical-call backstop. The default is intentionally close to the // standard row cohort so slow small responses do not serialize most rows. // Runs may request a value through the validated 1..256 launch contract; // provider pacing and the runner-owned hard ceiling still apply. toolCalls: DEFAULT_MAX_CONCURRENT_EXTERNAL_CALLS, // Physical runtime-to-app fetch/body admission. Keep short socket bursts // below the sandbox/Vercel transport cliff while allowing many more logical // calls to remain resident. This is deliberately independent of provider // rate limits: it protects Deepline's own transport edge. integrationRequests: 64, // Scheduling cohorts overlap provider-response tails, but each cohort may // itself contain many logical calls. Keep this materially above the legacy // eight-group ceiling without creating one independently flushed cohort // per configured tool slot. The latter fragments batchable providers and // adds receipt/gateway round trips under wide (128+) runs. toolDispatchGroups: DEFAULT_MAX_IN_FLIGHT_TOOL_DISPATCH_GROUPS, toolDispatchGroupsPerLane: DEFAULT_MAX_IN_FLIGHT_TOOL_DISPATCH_GROUPS, }, budgets: { // Runaway guards, not workload limits. A 5,000-row map calling several tools // per row is normal and must fit comfortably under these. maxPlayCallDepth: 8, maxToolCallCount: 5_000_000, maxRetryCount: 100_000, maxWaterfallStepExecutions: 5_000_000, }, pacing: { // Undeclared providers; declared providers (rate-limit-definitions.ts) win. defaultProviderRequestsPerSecond: 10, suggestedMaxParallelism: 256, // Worker isolate pacing knobs. The budget module owns platform accounting; // the Governor owns the policy values that decide when work is chunked. workerYieldElapsedMs: 45_000, // Fallback wave width for batched tool chunks that carry no provider pacing // hint. This is only a floor for the undeclared case: it stays bounded by // suggestedMaxParallelism above, and declared providers still get // tightened by their per-provider pacing (rate-limit-definitions.ts). Raised // 4 -> 16 so full batches fan out wide enough to close the map-throughput gap // without over-running providers that actually declare limits. workerToolBatchDefaultParallelism: 16, }, }; export type AdapterId = 'cjs_node20'; /** * One level deep on purpose: each policy section is a flat record of numbers, so * an override is `{ section: { key: value } }`. The merge in * {@link resolveExecutionPolicy} is one level; this type matches it exactly so * it can never advertise nested-override support the merge doesn't implement. */ type PolicyOverride = { [S in keyof ResolvedExecutionPolicy]?: Partial; }; /** * The ONLY sanctioned per-substrate divergence. Empty by design — both * substrates run the shared policy. Every entry added here MUST carry a * one-line comment citing the substrate constraint that forces it (e.g. isolate * CPU/memory). CI may assert this map stays small. This is the anti-drift seam: * differences are explicit and justified, never accidental. */ export const ADAPTER_POLICY_OVERRIDES: Record = { cjs_node20: {}, }; /** Merge the shared policy with any documented Adapter override. */ export function resolveExecutionPolicy( adapter: AdapterId, ): ResolvedExecutionPolicy { const override = ADAPTER_POLICY_OVERRIDES[adapter]; return { concurrency: { ...SHARED_EXECUTION_POLICY.concurrency, ...override.concurrency, }, budgets: { ...SHARED_EXECUTION_POLICY.budgets, ...override.budgets }, pacing: { ...SHARED_EXECUTION_POLICY.pacing, ...override.pacing }, }; } /** * Effective row concurrency for a map: an explicit positive request clamped to * `[1, rowMax]`, otherwise the default. Single helper, used by both substrates. */ export function resolveRowConcurrency( policy: ResolvedExecutionPolicy, requested?: number, runMaximum?: number | null, ): number { const normalizedRunMaximum = resolveMaxConcurrentRows(runMaximum); if ( typeof requested === 'number' && Number.isFinite(requested) && requested > 0 ) { return Math.min( Math.floor(requested), normalizedRunMaximum ?? policy.concurrency.rowMax, policy.concurrency.rowMax, ); } return Math.min( normalizedRunMaximum ?? policy.concurrency.rowDefault, policy.concurrency.rowMax, ); }