deepline 0.3.113 → 0.3.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +3 -1
  2. package/dist/bundling-sources/sdk/src/compat.ts +167 -11
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/skills-version.ts +27 -1
  5. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +6 -2
  6. package/dist/bundling-sources/shared_libs/observability/queue-health.ts +291 -6
  7. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
  18. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1394 -22
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
  29. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
  30. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
  31. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
  32. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
  33. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
  34. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
  35. package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
  36. package/dist/cli/index.js +574 -114
  37. package/dist/cli/index.mjs +592 -133
  38. package/dist/index.d.mts +54 -1
  39. package/dist/index.d.ts +54 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.mjs +8 -3
  42. package/dist/install-integrity.json +7 -0
  43. package/dist/plays/bundle-play-file.mjs +1 -1
  44. package/dist/release.d.mts +1 -1
  45. package/dist/release.d.ts +1 -1
  46. package/dist/release.js +1 -1
  47. package/dist/release.mjs +1 -1
  48. package/package.json +1 -1
@@ -164,6 +164,8 @@ import {
164
164
  readPostgresSchedulerRunnerLiveness,
165
165
  readPostgresSchedulerRuntimeResourcesForRun,
166
166
  requestComputeBillingDispatchToken,
167
+ blockPostgresSchedulerCronSchedule,
168
+ PostgresSchedulerCronScheduleConfigurationError,
167
169
  releasePostgresSchedulerReceiptsForAttempt,
168
170
  runtimeTerminalReasonFromError,
169
171
  settlePostgresSchedulerRuntimeResources,
@@ -196,6 +198,8 @@ import { runtimeSandboxProviderCapabilities } from './absurd-worker-required-env
196
198
  import {
197
199
  acquirePgNotifyHub,
198
200
  isRuntimeSchedulerCapacityError,
201
+ isRuntimeSchedulerRetryableTransactionError,
202
+ isConnectionCategoryError,
199
203
  parseRuntimeSchedulerCapacityError,
200
204
  RuntimeSchedulerCapacityError,
201
205
  runtimeSchedulerCapacityRetryAfterMs,
@@ -211,6 +215,7 @@ import {
211
215
  import {
212
216
  ABSURD_PLAY_RUN_MAX_ATTEMPTS,
213
217
  ABSURD_PLAY_RUN_RETRY_STRATEGY,
218
+ ABSURD_CRON_FIRE_TASK,
214
219
  ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK,
215
220
  ABSURD_PLAY_RUN_TASK,
216
221
  ABSURD_RUNS_QUEUE,
@@ -234,6 +239,7 @@ import {
234
239
  normalizeAbsurdSchedulerOptions,
235
240
  resolveAbsurdPoolOptions,
236
241
  type AbsurdPlayRunTaskParams,
242
+ type AbsurdCronFireTaskParams,
237
243
  type AbsurdDaytonaSandboxCleanupTaskParams,
238
244
  type AbsurdRuntimeResult,
239
245
  type AbsurdSchedulerOptions,
@@ -659,6 +665,26 @@ export type AbsurdRuntimeWorkerDeps = {
659
665
  * suspenders lane-mismatch guard without mutating process env. Defaults to
660
666
  * `currentAbsurdReleaseId()`. */
661
667
  currentReleaseId: () => string;
668
+ /** Raw release registry identity stamped onto new child Run envelopes. */
669
+ currentLaunchReleaseId: () => string;
670
+ /**
671
+ * Cron has a different preparation boundary than ordinary worker execution:
672
+ * the sleeping task asks the current Vercel deployment to build a fresh
673
+ * authority, then that deployment commits the Run, task, outbox fact, and
674
+ * successor cursor in one Runtime Postgres transaction. The task itself
675
+ * never reuses the seed-time launch envelope.
676
+ */
677
+ admitCronFire: (input: {
678
+ scheduleId: string;
679
+ generation: number;
680
+ fireGeneration: number;
681
+ dueAtMs: number;
682
+ schedulerSchema?: string | null;
683
+ }) => Promise<
684
+ | { status: 'admitted'; runId: string; successorDueAtMs: number }
685
+ | { status: 'superseded' }
686
+ | { status: 'blocked' }
687
+ >;
662
688
  /** Best-effort deletion of an orphaned runtime sandbox by id (push-execution
663
689
  * B4 GC on reclaim/terminal-failure). Injectable so tests assert the GC call
664
690
  * without touching the Daytona SDK. Defaults to `deleteDaytonaSandboxById`. */
@@ -757,6 +783,8 @@ function buildDefaultDeps(absurd: Absurd): AbsurdRuntimeWorkerDeps {
757
783
  expectedOrganizationId: routingDomain,
758
784
  }),
759
785
  currentReleaseId: () => currentAbsurdReleaseId(),
786
+ currentLaunchReleaseId: () => currentAbsurdLaunchReleaseId(),
787
+ admitCronFire: requestCronFireAdmissionViaApp,
760
788
  };
761
789
  }
762
790
 
@@ -930,6 +958,149 @@ export async function requestAttemptExecutorToken(input: {
930
958
  return body.executorToken;
931
959
  }
932
960
 
961
+ type CronFireAdmissionResult =
962
+ | { status: 'admitted'; runId: string; successorDueAtMs: number }
963
+ | { status: 'superseded' }
964
+ | { status: 'blocked' };
965
+
966
+ /** A Vercel-side admission failure that tells the durable timer whether to defer. */
967
+ class CronFireAdmissionGatewayError extends Error {
968
+ constructor(
969
+ message: string,
970
+ readonly retryable: boolean,
971
+ readonly retryAfterMs: number | null = null,
972
+ ) {
973
+ super(message);
974
+ this.name = 'CronFireAdmissionGatewayError';
975
+ }
976
+ }
977
+
978
+ function cronFireAdmissionGatewayUrl(): string {
979
+ const raw = process.env.DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL?.trim();
980
+ if (!raw) {
981
+ throw new CronFireAdmissionGatewayError(
982
+ 'DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL is required for cron-fire fresh admission.',
983
+ true,
984
+ );
985
+ }
986
+ try {
987
+ const url = new URL(raw);
988
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
989
+ throw new Error('non-HTTP protocol');
990
+ }
991
+ return url.origin;
992
+ } catch {
993
+ throw new CronFireAdmissionGatewayError(
994
+ 'DEEPLINE_RUNTIME_ADMISSION_GATEWAY_URL must be an absolute HTTP(S) URL.',
995
+ true,
996
+ );
997
+ }
998
+ }
999
+
1000
+ function cronFireRetryAfterMs(headers: Headers): number | null {
1001
+ const value = headers.get('retry-after')?.trim();
1002
+ if (!value) return null;
1003
+ const seconds = Number(value);
1004
+ return Number.isFinite(seconds) && seconds >= 0
1005
+ ? Math.round(seconds * 1_000)
1006
+ : null;
1007
+ }
1008
+
1009
+ /**
1010
+ * Make the timer's only external call after wake. The target is a stable
1011
+ * Vercel alias, not the seed-time runtimeApiBaseUrl, so it always mints fresh
1012
+ * run authority under the currently serving application release.
1013
+ */
1014
+ export async function requestCronFireAdmissionViaApp(input: {
1015
+ scheduleId: string;
1016
+ generation: number;
1017
+ fireGeneration: number;
1018
+ dueAtMs: number;
1019
+ schedulerSchema?: string | null;
1020
+ }): Promise<CronFireAdmissionResult> {
1021
+ const internalToken = process.env.DEEPLINE_INTERNAL_TOKEN?.trim();
1022
+ if (!internalToken) {
1023
+ throw new CronFireAdmissionGatewayError(
1024
+ 'DEEPLINE_INTERNAL_TOKEN is required for cron-fire fresh admission.',
1025
+ true,
1026
+ );
1027
+ }
1028
+ const admissionGatewayUrl = cronFireAdmissionGatewayUrl();
1029
+ const protectionHeaders = await vercelProtectionBypassHeaders({
1030
+ baseUrl: admissionGatewayUrl,
1031
+ token: process.env.VERCEL_PROTECTION_BYPASS_TOKEN ?? null,
1032
+ });
1033
+ let response: Response;
1034
+ try {
1035
+ response = await fetch(
1036
+ `${admissionGatewayUrl}/api/v2/internal/runtime/cron-fire`,
1037
+ {
1038
+ method: 'POST',
1039
+ signal: AbortSignal.timeout(30_000),
1040
+ headers: {
1041
+ 'content-type': 'application/json',
1042
+ 'x-deepline-internal-token': internalToken,
1043
+ 'x-deepline-runtime-trace-id': `cron-fire:${input.scheduleId}:${input.generation}:${input.fireGeneration}:${input.dueAtMs}`,
1044
+ ...protectionHeaders,
1045
+ },
1046
+ body: JSON.stringify(input),
1047
+ },
1048
+ );
1049
+ } catch (error) {
1050
+ throw new CronFireAdmissionGatewayError(
1051
+ `Cron-fire admission gateway transport failed: ${error instanceof Error ? error.message : String(error)}`,
1052
+ true,
1053
+ );
1054
+ }
1055
+ const raw = await response.text();
1056
+ const body = (() => {
1057
+ try {
1058
+ return JSON.parse(raw) as {
1059
+ status?: unknown;
1060
+ runId?: unknown;
1061
+ successorDueAtMs?: unknown;
1062
+ error?: unknown;
1063
+ retryable?: unknown;
1064
+ };
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ })();
1069
+ if (
1070
+ response.ok &&
1071
+ body?.status === 'admitted' &&
1072
+ typeof body.runId === 'string' &&
1073
+ typeof body.successorDueAtMs === 'number'
1074
+ ) {
1075
+ return {
1076
+ status: 'admitted',
1077
+ runId: body.runId,
1078
+ successorDueAtMs: body.successorDueAtMs,
1079
+ };
1080
+ }
1081
+ if (
1082
+ response.ok &&
1083
+ (body?.status === 'superseded' || body?.status === 'blocked')
1084
+ ) {
1085
+ return { status: body.status };
1086
+ }
1087
+ const retryable =
1088
+ typeof body?.retryable === 'boolean'
1089
+ ? body.retryable
1090
+ : response.status === 401 ||
1091
+ response.status === 403 ||
1092
+ response.status === 408 ||
1093
+ response.status === 429 ||
1094
+ response.status >= 500;
1095
+ throw new CronFireAdmissionGatewayError(
1096
+ typeof body?.error === 'string'
1097
+ ? body.error
1098
+ : `Cron-fire admission gateway returned HTTP ${response.status}.`,
1099
+ retryable,
1100
+ cronFireRetryAfterMs(response.headers),
1101
+ );
1102
+ }
1103
+
933
1104
  function signalData(
934
1105
  record: PostgresSchedulerSignalRecord | null,
935
1106
  ): Record<string, unknown> | null {
@@ -1268,6 +1439,142 @@ export async function executeAbsurdRuntimeLaunch(
1268
1439
  }
1269
1440
  }
1270
1441
 
1442
+ /**
1443
+ * Advance one Runtime-owned cron cursor. The durable sleep is intentionally in
1444
+ * the task rather than in Vercel: it releases the worker claim until due and a
1445
+ * database outage after wake defers this same task without acknowledging the
1446
+ * occurrence or consuming customer-code attempts.
1447
+ */
1448
+ export async function executeAbsurdCronFire(
1449
+ params: AbsurdCronFireTaskParams,
1450
+ ctx: TaskContext,
1451
+ deps: Pick<AbsurdRuntimeWorkerDeps, 'runWithClient' | 'admitCronFire'>,
1452
+ ): Promise<
1453
+ | { status: 'admitted'; runId: string; successorDueAtMs: number }
1454
+ | { status: 'superseded' }
1455
+ | { status: 'blocked' }
1456
+ > {
1457
+ // Reader-first ABI compatibility: a timer written by the immediately
1458
+ // preceding reader did not carry the repair generation. Its only safe
1459
+ // interpretation is the original generation one.
1460
+ const fireGeneration = params.fireGeneration ?? 1;
1461
+ if (
1462
+ !params.scheduleId?.trim() ||
1463
+ !Number.isSafeInteger(params.generation) ||
1464
+ params.generation <= 0 ||
1465
+ !Number.isSafeInteger(fireGeneration) ||
1466
+ fireGeneration <= 0 ||
1467
+ !Number.isSafeInteger(params.dueAtMs) ||
1468
+ params.dueAtMs <= 0
1469
+ ) {
1470
+ throw new Error(
1471
+ 'cron-fire requires a schedule id, generation, and due time.',
1472
+ );
1473
+ }
1474
+ try {
1475
+ // `sleepUntil` persists a checkpoint and a wake through the scheduler
1476
+ // store before it throws SuspendTask. A transient failure there is no less
1477
+ // retryable than the later atomic fire CTE, so keep this boundary inside
1478
+ // the same durable deferral policy.
1479
+ await ctx.sleepUntil('due', new Date(params.dueAtMs));
1480
+ const result = await deps.admitCronFire({
1481
+ scheduleId: params.scheduleId,
1482
+ generation: params.generation,
1483
+ fireGeneration,
1484
+ dueAtMs: params.dueAtMs,
1485
+ schedulerSchema: params.schedulerSchema ?? null,
1486
+ });
1487
+ console.info('[play.cron_fire]', {
1488
+ scheduleId: params.scheduleId,
1489
+ generation: params.generation,
1490
+ dueAtMs: params.dueAtMs,
1491
+ status: result.status,
1492
+ ...(result.status === 'admitted'
1493
+ ? { runId: result.runId, successorDueAtMs: result.successorDueAtMs }
1494
+ : {}),
1495
+ });
1496
+ return result;
1497
+ } catch (error) {
1498
+ if (
1499
+ error instanceof SuspendTask ||
1500
+ error instanceof CancelledTask ||
1501
+ error instanceof FailedTask
1502
+ ) {
1503
+ throw error;
1504
+ }
1505
+ if (error instanceof PostgresSchedulerCronScheduleConfigurationError) {
1506
+ const reason = error.message;
1507
+ let blocked: boolean;
1508
+ try {
1509
+ blocked = await deps.runWithClient((client) =>
1510
+ blockPostgresSchedulerCronSchedule(
1511
+ client,
1512
+ {
1513
+ scheduleId: params.scheduleId,
1514
+ generation: params.generation,
1515
+ fireGeneration,
1516
+ dueAtMs: params.dueAtMs,
1517
+ reason,
1518
+ },
1519
+ { schema: params.schedulerSchema ?? undefined },
1520
+ ),
1521
+ );
1522
+ } catch (blockError) {
1523
+ if (isRetryableCronFireStoreError(blockError)) {
1524
+ throw deferCronFireStoreError(blockError);
1525
+ }
1526
+ throw blockError;
1527
+ }
1528
+ console.error('[play.cron_fire.blocked]', {
1529
+ scheduleId: params.scheduleId,
1530
+ generation: params.generation,
1531
+ dueAtMs: params.dueAtMs,
1532
+ blocked,
1533
+ reason,
1534
+ });
1535
+ return { status: 'blocked' };
1536
+ }
1537
+ // A failed transaction has created neither Run nor successor. Parking the
1538
+ // same durable fire preserves the cron retry owner (Absurd) through a
1539
+ // PgBouncer or control-plane outage; DeferTask does not spend an engine
1540
+ // attempt or create a second task.
1541
+ if (isRetryableCronFireStoreError(error)) {
1542
+ throw deferCronFireStoreError(error);
1543
+ }
1544
+ throw error;
1545
+ }
1546
+ }
1547
+
1548
+ function isRetryableCronFireStoreError(error: unknown): boolean {
1549
+ if (error instanceof CronFireAdmissionGatewayError) return error.retryable;
1550
+ if (
1551
+ isRuntimeSchedulerCapacityError(error) ||
1552
+ isConnectionCategoryError(error) ||
1553
+ isRuntimeSchedulerRetryableTransactionError(error)
1554
+ ) {
1555
+ return true;
1556
+ }
1557
+ const code =
1558
+ error && typeof error === 'object'
1559
+ ? (error as { code?: unknown }).code
1560
+ : null;
1561
+ if (code === '57014') return true; // query cancelled / statement timeout
1562
+ return (
1563
+ error instanceof Error &&
1564
+ /no longer accepts new launches/i.test(error.message)
1565
+ );
1566
+ }
1567
+
1568
+ function deferCronFireStoreError(error: unknown): DeferTask {
1569
+ const reason = error instanceof Error ? error.message : String(error);
1570
+ const retryAfterMs =
1571
+ error instanceof CronFireAdmissionGatewayError ? error.retryAfterMs : null;
1572
+ return new DeferTask(
1573
+ `cron-fire atomic admission deferred: ${reason}`,
1574
+ retryAfterMs === null ? 30 : Math.max(1, Math.ceil(retryAfterMs / 1_000)),
1575
+ );
1576
+ }
1577
+
1271
1578
  /**
1272
1579
  * ADR 0016 rule 3: load the run's durable terminal truth and fold it into the
1273
1580
  * progress reporter BEFORE its terminal finalize flush.
@@ -4248,6 +4555,12 @@ export function createAbsurdRuntimeTasks(
4248
4555
  resolved,
4249
4556
  ),
4250
4557
  );
4558
+ // Register on both the current lane and every adopted continuation lane.
4559
+ // The task may sleep for days, but its handler schedules children on the
4560
+ // current release through `resolved.currentReleaseId` after waking.
4561
+ absurd.registerTask({ name: ABSURD_CRON_FIRE_TASK, queue }, (params, ctx) =>
4562
+ executeAbsurdCronFire(params as AbsurdCronFireTaskParams, ctx, resolved),
4563
+ );
4251
4564
  absurd.registerTask(
4252
4565
  { name: ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK, queue },
4253
4566
  async (params) => {
@@ -10,6 +10,7 @@
10
10
  * absurd-specific wiring (queue, event names, checkpoint boundary shaping).
11
11
  */
12
12
  import { createHash } from 'node:crypto';
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
14
  import type {
14
15
  PlayCheckpoint,
15
16
  PlayExecutionEvent,
@@ -20,6 +21,11 @@ import type { PlayRunLedgerEvent } from '../run-ledger';
20
21
  import type { PlaySchedulerSubmitInput } from '../scheduler-backend';
21
22
  import type { WorkerRuntimeApiContext } from '../app-runtime-api';
22
23
  import { RUNTIME_CAPACITY_POLICY } from '../runtime-capacity-policy';
24
+ import {
25
+ PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS,
26
+ PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES,
27
+ PLAY_ADMISSION_MAX_PARALLELISM,
28
+ } from '../admission-transport-policy';
23
29
  import { assertKnownResolvedBoundary } from '../checkpoint-boundaries';
24
30
  import type { Absurd, Log, RetryStrategy } from '@deepline/absurd/sdk';
25
31
  import { isIsolatedRuntimeSchedulerSchema } from '../runtime-scheduler-topology';
@@ -503,6 +509,15 @@ export {
503
509
  ABSURD_PLAY_RUN_TASK,
504
510
  type AbsurdPlayRunTaskParams,
505
511
  } from './absurd-play-run-task';
512
+ export {
513
+ ABSURD_CRON_FIRE_MAX_ATTEMPTS,
514
+ ABSURD_CRON_FIRE_RETRY_STRATEGY,
515
+ ABSURD_CRON_FIRE_TASK,
516
+ buildAbsurdCronFireTask,
517
+ buildCronFireRunIdentity,
518
+ cronFireIdempotencyKey,
519
+ type AbsurdCronFireTaskParams,
520
+ } from './absurd-cron-fire-task';
506
521
  /** Worker-owned cleanup for a Daytona sandbox in this release lane. */
507
522
  export const ABSURD_DAYTONA_SANDBOX_CLEANUP_TASK = 'daytona-sandbox-cleanup';
508
523
 
@@ -548,6 +563,87 @@ export const ABSURD_RUNTIME_POOL_MAX_CLIENTS =
548
563
  */
549
564
  export const ABSURD_SERVERLESS_POOL_MAX_CLIENTS = 1;
550
565
 
566
+ /**
567
+ * A Vercel isolate may survive long after its one webhook has returned. Keep
568
+ * its single scheduler client only long enough to reuse it for an immediate
569
+ * follow-up, then return that scarce PgBouncer client before the source's
570
+ * delayed Workpool recovery is due. The worker fleet deliberately keeps the
571
+ * ordinary 30-second idle lifetime.
572
+ */
573
+ export const ABSURD_SERVERLESS_POOL_IDLE_TIMEOUT_MS = 1_000;
574
+
575
+ // A normal Vercel request submits exactly one Run and must retain the one-client
576
+ // ceiling. The private batch adapter is different: Workpool elects one global
577
+ // up-to-1,024-source collection before entering this context, so one batch
578
+ // request may use the bounded scheduler capacity without multiplying it per
579
+ // source request.
580
+ // Other one-client Workpool adapters can still overlap during rollout.
581
+ type ServerlessAdmissionPoolContext = {
582
+ maxClients: number;
583
+ maxAcquireQueueDepth: number;
584
+ maxAcquireQueueWaitMs: number;
585
+ };
586
+
587
+ const serverlessAdmissionPoolContext =
588
+ new AsyncLocalStorage<ServerlessAdmissionPoolContext>();
589
+
590
+ export async function withServerlessAdmissionPoolMaxClients<T>(input: {
591
+ maxClients: number;
592
+ maxAcquireQueueDepth?: number;
593
+ maxAcquireQueueWaitMs?: number;
594
+ fn: () => Promise<T>;
595
+ }): Promise<T> {
596
+ if (
597
+ !Number.isInteger(input.maxClients) ||
598
+ input.maxClients < 1 ||
599
+ input.maxClients > PLAY_ADMISSION_MAX_PARALLELISM
600
+ ) {
601
+ throw new Error(
602
+ 'Serverless admission pool capacity is outside the supported range.',
603
+ );
604
+ }
605
+ const maxAcquireQueueDepth =
606
+ input.maxAcquireQueueDepth ?? PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES;
607
+ const maxAcquireQueueWaitMs =
608
+ input.maxAcquireQueueWaitMs ?? PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS;
609
+ if (
610
+ !Number.isSafeInteger(maxAcquireQueueDepth) ||
611
+ maxAcquireQueueDepth < 1 ||
612
+ maxAcquireQueueDepth > PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES
613
+ ) {
614
+ throw new Error(
615
+ 'Serverless admission acquire queue capacity is outside the supported range.',
616
+ );
617
+ }
618
+ if (
619
+ !Number.isSafeInteger(maxAcquireQueueWaitMs) ||
620
+ maxAcquireQueueWaitMs < 5_000 ||
621
+ maxAcquireQueueWaitMs > PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS
622
+ ) {
623
+ throw new Error(
624
+ 'Serverless admission acquire queue wait is outside the supported range.',
625
+ );
626
+ }
627
+ return await serverlessAdmissionPoolContext.run(
628
+ {
629
+ maxClients: input.maxClients,
630
+ maxAcquireQueueDepth,
631
+ maxAcquireQueueWaitMs,
632
+ },
633
+ input.fn,
634
+ );
635
+ }
636
+
637
+ /**
638
+ * The private batch adapter owns this request-local scheduler pool. Reserve
639
+ * one client for executor-token and other critical control-plane traffic; the
640
+ * rest may submit the independently idempotent batch sources in parallel.
641
+ */
642
+ export function serverlessAdmissionSubmitMaxActive(): number | null {
643
+ const context = serverlessAdmissionPoolContext.getStore();
644
+ return context === undefined ? null : Math.max(1, context.maxClients - 1);
645
+ }
646
+
551
647
  /**
552
648
  * Resolve the runtime scheduler Postgres pool options for the absurd control
553
649
  * plane. Loud failure over silent fallback: the absurd worker and scheduler
@@ -563,12 +659,24 @@ export function resolveAbsurdPoolOptions(
563
659
  'scheduler Postgres URL). Refusing to fall back to local execution.',
564
660
  );
565
661
  }
662
+ const serverless = process.env.VERCEL?.trim();
663
+ const context = serverlessAdmissionPoolContext.getStore();
566
664
  return {
567
665
  url,
568
666
  applicationName: 'deepline-absurd-runtime',
569
- maxClients: process.env.VERCEL?.trim()
570
- ? ABSURD_SERVERLESS_POOL_MAX_CLIENTS
571
- : ABSURD_RUNTIME_POOL_MAX_CLIENTS,
667
+ maxClients:
668
+ serverless || context !== undefined
669
+ ? (context?.maxClients ?? ABSURD_SERVERLESS_POOL_MAX_CLIENTS)
670
+ : ABSURD_RUNTIME_POOL_MAX_CLIENTS,
671
+ ...(serverless || context !== undefined
672
+ ? { idleTimeoutMs: ABSURD_SERVERLESS_POOL_IDLE_TIMEOUT_MS }
673
+ : {}),
674
+ ...(context
675
+ ? {
676
+ maxAcquireQueueDepth: context.maxAcquireQueueDepth,
677
+ maxAcquireQueueWaitMs: context.maxAcquireQueueWaitMs,
678
+ }
679
+ : {}),
572
680
  };
573
681
  }
574
682
 
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { recordPerfTrace } from '../perf-trace';
22
22
  import { CTX_RUN_PLAY_INLINE_ONLY } from '../child-execution-strategy';
23
+ import { PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES } from '../admission-transport-policy';
23
24
  import {
24
25
  defaultRuntimeSandboxReconciliation,
25
26
  runtimeSandboxDeleteSucceeded,
@@ -58,6 +59,7 @@ import {
58
59
  ensureAbsurdRuntimeReady,
59
60
  normalizeAbsurdSchedulerOptions,
60
61
  resolveAbsurdPoolOptions,
62
+ serverlessAdmissionSubmitMaxActive,
61
63
  type AbsurdDaytonaSandboxCleanupTaskParams,
62
64
  type AbsurdSchedulerOptions,
63
65
  } from './absurd-shared';
@@ -325,9 +327,23 @@ export function createAbsurdSchedulerBackend(
325
327
  // Returning from submit therefore means the durable work is runnable;
326
328
  // there is no outbox, dispatcher, or second admission boundary.
327
329
  let submissionQueue = queue;
330
+ const serverlessSubmitMaxActive = serverlessAdmissionSubmitMaxActive();
328
331
  const admissionParams = {
329
332
  schedulerUrl: poolOptions.url,
330
333
  schedulerSchema: normalizedSchedulerOptions.schema,
334
+ ...(serverlessSubmitMaxActive === null
335
+ ? {}
336
+ : {
337
+ maxActive: serverlessSubmitMaxActive,
338
+ // The private batch may prepare 256 sources while 31 atomic
339
+ // Runtime submissions are active. Its process-local wait room
340
+ // must cover that already-bounded difference; otherwise the
341
+ // generic 128-launch buffer rejects healthy burst members
342
+ // before PgBouncer has a chance to drain them.
343
+ maxQueued:
344
+ PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES -
345
+ serverlessSubmitMaxActive,
346
+ }),
331
347
  };
332
348
  let spawn = await withRuntimeSchedulerSubmitAdmission(
333
349
  admissionParams,
@@ -28,6 +28,10 @@ import {
28
28
  RuntimeSchedulerSubmissionAdmissionError,
29
29
  RuntimeSchedulerSaturatedError,
30
30
  } from './scheduler-capacity';
31
+ import {
32
+ PLAY_ADMISSION_LARGE_BATCH_SIZE,
33
+ PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS,
34
+ } from '../admission-transport-policy';
31
35
  import { RUNTIME_RELIABILITY_POLICY } from '../runtime-reliability-policy';
32
36
  export {
33
37
  isRuntimeSchedulerCapacityError,
@@ -163,6 +167,23 @@ export type RuntimeSchedulerPoolOptions = {
163
167
  url: string;
164
168
  applicationName?: string;
165
169
  maxClients?: number;
170
+ /**
171
+ * Serverless launchers must not pin a scarce PgBouncer client between
172
+ * requests. Long-lived workers retain the default; Vercel callers opt into
173
+ * a short idle lifetime through the shared Absurd pool resolver.
174
+ */
175
+ idleTimeoutMs?: number;
176
+ /**
177
+ * Optional request-local acquire queue capacity. The dense webhook batch
178
+ * owns a finite 256-source wave, so it may reserve enough scheduler waiters
179
+ * for that wave without widening ordinary worker/request callers.
180
+ */
181
+ maxAcquireQueueDepth?: number;
182
+ /**
183
+ * Optional request-local waiter lifetime. A bounded batch may spend longer
184
+ * than the ordinary single-launch acquire timeout draining its finite wave.
185
+ */
186
+ maxAcquireQueueWaitMs?: number;
166
187
  };
167
188
 
168
189
  export type RuntimeSchedulerQueryClient = {
@@ -328,6 +349,7 @@ export class RuntimeSchedulerPoolAdmission {
328
349
  type PoolEntry = {
329
350
  pool: Pool;
330
351
  maxClients: number;
352
+ maxAcquireQueueDepth: number;
331
353
  admission: RuntimeSchedulerPoolAdmission;
332
354
  breaker: BreakerState;
333
355
  stats: {
@@ -489,6 +511,14 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
489
511
  assertRuntimeSchedulerHotPathUrl(options.url);
490
512
  const maxClients =
491
513
  options.maxClients ?? RUNTIME_SCHEDULER_POOL_DEFAULTS.maxClients;
514
+ const idleTimeoutMs =
515
+ options.idleTimeoutMs ?? RUNTIME_SCHEDULER_POOL_DEFAULTS.idleTimeoutMs;
516
+ const maxAcquireQueueDepth =
517
+ options.maxAcquireQueueDepth ??
518
+ RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth;
519
+ const maxAcquireQueueWaitMs =
520
+ options.maxAcquireQueueWaitMs ??
521
+ RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs;
492
522
  if (maxClients > RUNTIME_SCHEDULER_POOL_DEFAULTS.maxClientsHardCap) {
493
523
  throw new Error(
494
524
  `Runtime scheduler pool maxClients=${maxClients} exceeds the hard cap of ` +
@@ -496,16 +526,46 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
496
526
  'instead of widening a single process pool.',
497
527
  );
498
528
  }
529
+ if (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs < 0) {
530
+ throw new Error(
531
+ `Runtime scheduler pool idleTimeoutMs=${idleTimeoutMs} must be a non-negative integer.`,
532
+ );
533
+ }
534
+ if (
535
+ !Number.isSafeInteger(maxAcquireQueueDepth) ||
536
+ maxAcquireQueueDepth < 1 ||
537
+ maxAcquireQueueDepth > PLAY_ADMISSION_LARGE_BATCH_SIZE
538
+ ) {
539
+ throw new Error(
540
+ `Runtime scheduler pool maxAcquireQueueDepth=${maxAcquireQueueDepth} ` +
541
+ `must be an integer between 1 and ${PLAY_ADMISSION_LARGE_BATCH_SIZE}.`,
542
+ );
543
+ }
544
+ if (
545
+ !Number.isSafeInteger(maxAcquireQueueWaitMs) ||
546
+ maxAcquireQueueWaitMs < RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs ||
547
+ maxAcquireQueueWaitMs > PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS
548
+ ) {
549
+ throw new Error(
550
+ `Runtime scheduler pool maxAcquireQueueWaitMs=${maxAcquireQueueWaitMs} ` +
551
+ `must be between ${RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs} and ` +
552
+ `${PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS}.`,
553
+ );
554
+ }
499
555
  const registry = getPoolRegistry();
500
556
  const connectionString = normalizePostgresConnectionStringForPg(options.url);
501
- const key = `${connectionString}::${maxClients}`;
557
+ // The connection lifetime is part of a Pool's semantics. Do not let a
558
+ // serverless short-idle caller accidentally reuse a long-lived worker pool
559
+ // merely because both target the same runtime database.
560
+ const key = `${connectionString}::${maxClients}::${idleTimeoutMs}::${maxAcquireQueueDepth}::${maxAcquireQueueWaitMs}`;
502
561
  const existing = registry.get(key);
503
562
  if (existing) {
504
563
  // Next dev can retain the process-global pool across a module hot reload.
505
564
  // Upgrade that live entry in place when this admission layer first lands.
506
565
  existing.admission ??= new RuntimeSchedulerPoolAdmission(
507
566
  maxClients,
508
- RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth,
567
+ maxAcquireQueueDepth,
568
+ maxAcquireQueueWaitMs,
509
569
  );
510
570
  return existing;
511
571
  }
@@ -515,7 +575,7 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
515
575
  application_name:
516
576
  options.applicationName ?? 'deepline-runtime-scheduler-pool',
517
577
  max: maxClients,
518
- idleTimeoutMillis: RUNTIME_SCHEDULER_POOL_DEFAULTS.idleTimeoutMs,
578
+ idleTimeoutMillis: idleTimeoutMs,
519
579
  connectionTimeoutMillis: RUNTIME_SCHEDULER_POOL_DEFAULTS.acquireTimeoutMs,
520
580
  query_timeout: RUNTIME_SCHEDULER_POOL_DEFAULTS.queryTimeoutMs,
521
581
  keepAlive: true,
@@ -523,9 +583,11 @@ function getPoolEntry(options: RuntimeSchedulerPoolOptions): PoolEntry {
523
583
  const entry: PoolEntry = {
524
584
  pool,
525
585
  maxClients,
586
+ maxAcquireQueueDepth,
526
587
  admission: new RuntimeSchedulerPoolAdmission(
527
588
  maxClients,
528
- RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth,
589
+ maxAcquireQueueDepth,
590
+ maxAcquireQueueWaitMs,
529
591
  ),
530
592
  breaker: {
531
593
  consecutiveFailures: 0,
@@ -653,10 +715,7 @@ export async function withRuntimeSchedulerClient<T>(
653
715
  ) {
654
716
  throwIfSchedulerAborted(signal);
655
717
  breakerGate(entry);
656
- if (
657
- entry.pool.waitingCount >=
658
- RUNTIME_SCHEDULER_POOL_DEFAULTS.maxAcquireQueueDepth
659
- ) {
718
+ if (entry.pool.waitingCount >= entry.maxAcquireQueueDepth) {
660
719
  entry.stats.saturationRejections += 1;
661
720
  throw new RuntimeSchedulerSaturatedError(entry.pool.waitingCount);
662
721
  }