deepline 0.3.257 → 0.3.259

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 (68) hide show
  1. package/dist/bundling-sources/sdk/src/api-error.ts +45 -0
  2. package/dist/bundling-sources/sdk/src/client.ts +98 -459
  3. package/dist/bundling-sources/sdk/src/http.ts +1 -34
  4. package/dist/bundling-sources/sdk/src/index.ts +8 -0
  5. package/dist/bundling-sources/sdk/src/plays/csv-data-input.ts +139 -0
  6. package/dist/bundling-sources/sdk/src/plays/run-request.ts +855 -0
  7. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  8. package/dist/bundling-sources/sdk/src/runs/overview.ts +45 -2
  9. package/dist/bundling-sources/sdk/src/types.ts +5 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +8 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +42 -10
  12. package/dist/bundling-sources/shared_libs/play-runtime/launch-identity.ts +3 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/launch-source-policy.ts +23 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +3 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +23 -5
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/projection/gateway-adapter.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-execution-state.ts +845 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +106 -6
  19. package/dist/bundling-sources/shared_libs/play-runtime/run-observation.ts +440 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +35 -2
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +66 -7
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +0 -29
  23. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-transition.ts +47 -2
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +6 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +475 -36
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +78 -11
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +550 -42
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +181 -8
  29. package/dist/bundling-sources/shared_libs/play-runtime/step-lifecycle-tracker.ts +0 -18
  30. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +80 -7
  31. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +37 -2
  32. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +148 -18
  33. package/dist/bundling-sources/shared_libs/plays/cascade-falloff.ts +86 -0
  34. package/dist/bundling-sources/shared_libs/plays/docflow-unreached-reason.ts +611 -0
  35. package/dist/bundling-sources/shared_libs/plays/docflow.ts +21 -0
  36. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +28 -5
  37. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +40 -2
  38. package/dist/bundling-sources/shared_libs/plays/settings-contract.ts +92 -10
  39. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +5 -0
  40. package/dist/bundling-sources/shared_libs/plays/trigger-config-contract.ts +222 -0
  41. package/dist/bundling-sources/shared_libs/plays/trigger-config-route.ts +15 -0
  42. package/dist/cli/index.js +4957 -771
  43. package/dist/cli/index.mjs +4957 -771
  44. package/dist/client-B5XbKrrs.d.mts +6564 -0
  45. package/dist/client-C9Y49yya.d.ts +6564 -0
  46. package/dist/{compiler-manifest-C4mdRJOP.d.mts → compiler-manifest-CUuh8C3b.d.mts} +14 -1
  47. package/dist/{compiler-manifest-C4mdRJOP.d.ts → compiler-manifest-CUuh8C3b.d.ts} +14 -1
  48. package/dist/index.d.mts +7 -6275
  49. package/dist/index.d.ts +7 -6275
  50. package/dist/index.js +360 -326
  51. package/dist/index.mjs +360 -326
  52. package/dist/install-integrity.json +21 -2
  53. package/dist/plays/bundle-play-file.d.mts +2 -2
  54. package/dist/plays/bundle-play-file.d.ts +2 -2
  55. package/dist/plays/bundle-play-file.mjs +117 -18
  56. package/dist/plays/csv-data-input.d.mts +37 -0
  57. package/dist/plays/csv-data-input.d.ts +37 -0
  58. package/dist/plays/csv-data-input.js +3135 -0
  59. package/dist/plays/csv-data-input.mjs +3108 -0
  60. package/dist/plays/run-request.d.mts +6 -0
  61. package/dist/plays/run-request.d.ts +6 -0
  62. package/dist/plays/run-request.js +664 -0
  63. package/dist/plays/run-request.mjs +614 -0
  64. package/dist/release.d.mts +1 -1
  65. package/dist/release.d.ts +1 -1
  66. package/dist/release.js +1 -1
  67. package/dist/release.mjs +1 -1
  68. package/package.json +12 -1
@@ -134,7 +134,10 @@ import {
134
134
  cleanupPostgresSchedulerMaterializedFiles,
135
135
  completePostgresSchedulerComputeBillingJob,
136
136
  completePostgresSchedulerComputeBillingSessionBootstrap,
137
+ claimPostgresSchedulerWaitingDetachedAttemptForTerminal,
137
138
  deferPostgresSchedulerAttemptForCapacity,
139
+ deferPostgresSchedulerAttemptForExecutorTokenOutage,
140
+ detachedRunnerCeilingDeadlineMsFromSuspension,
138
141
  enqueuePostgresSchedulerSandboxCleanupForAttempt,
139
142
  enqueuePostgresSchedulerSandboxCleanupForRun,
140
143
  hasPostgresSchedulerRecoverableDetachedRunner,
@@ -225,9 +228,11 @@ import {
225
228
  } from './postgres-pool';
226
229
  import { runWithAbsurdSchedulerQuerySignal } from './absurd-pool';
227
230
  import {
231
+ buildFailedTerminalSettledProgressEvents,
228
232
  createPostgresSchedulerProgressReporter,
229
233
  type PostgresSchedulerProgressReporter,
230
234
  } from './postgres-progress';
235
+ import type { PlayRunLedgerEvent } from '../run-ledger';
231
236
  import {
232
237
  ABSURD_PLAY_RUN_MAX_ATTEMPTS,
233
238
  ABSURD_PLAY_RUN_RETRY_STRATEGY,
@@ -310,6 +315,43 @@ class AttemptExecutorTokenCapacityError extends RuntimeSchedulerCapacityError {
310
315
  }
311
316
  }
312
317
 
318
+ /**
319
+ * The executor-token route was unreachable or answered with a transient
320
+ * front-door/server failure (transport error, timeout, 5xx, 408/429, or a
321
+ * non-JSON 2xx gateway page). The mint precedes any Play code, so the handler
322
+ * defers the durable task — bounded by wall-clock — instead of spending an
323
+ * engine attempt. Deterministic 4xx rejections (auth, lease fence) stay
324
+ * ordinary failures.
325
+ */
326
+ export class AttemptExecutorTokenUnavailableError extends Error {
327
+ constructor(
328
+ message: string,
329
+ readonly status: number | null,
330
+ ) {
331
+ super(message);
332
+ this.name = 'AttemptExecutorTokenUnavailableError';
333
+ }
334
+ }
335
+
336
+ /** Wall-clock budget for one executor-token request (headers and body). */
337
+ const ATTEMPT_EXECUTOR_TOKEN_MINT_TIMEOUT_MS = 30_000;
338
+ /**
339
+ * Longest consecutive executor-token outage a fresh attempt may defer
340
+ * through. A detached-runner wake is bounded by its own persisted hard
341
+ * ceiling instead. Past either bound the run is terminalized loudly.
342
+ */
343
+ export const ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS = 10 * 60_000;
344
+ const ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS = 5;
345
+
346
+ function isTransientExecutorTokenStatus(
347
+ status: number,
348
+ jsonBody: boolean,
349
+ ): boolean {
350
+ if (status >= 500 || status === 408 || status === 429) return true;
351
+ // A 2xx that is not JSON is a front-door interstitial, not the route.
352
+ return status >= 200 && status < 300 && !jsonBody;
353
+ }
354
+
313
355
  // No `installPgRuntimePoolDriver()` here: the runtime Postgres driver belongs to
314
356
  // the process that owns this module's boot — `apps/absurd-runtime-worker` and
315
357
  // `apps/absurd-release-scaler` install it at their entrypoints. `apps/deepline-api`
@@ -976,26 +1018,48 @@ export async function requestAttemptExecutorToken(input: {
976
1018
  attempt: input.attempt,
977
1019
  traceId,
978
1020
  });
979
- const response = await fetch(url, {
980
- method: 'POST',
981
- headers: {
982
- 'content-type': 'application/json',
983
- 'x-deepline-internal-token': internalToken,
984
- 'x-deepline-runtime-trace-id': traceId,
985
- ...vercelProtectionHeaders,
986
- },
987
- body: JSON.stringify({
1021
+ const requestSignal = AbortSignal.timeout(
1022
+ ATTEMPT_EXECUTOR_TOKEN_MINT_TIMEOUT_MS,
1023
+ );
1024
+ let response: Response;
1025
+ try {
1026
+ response = await fetch(url, {
1027
+ signal: requestSignal,
1028
+ method: 'POST',
1029
+ headers: {
1030
+ 'content-type': 'application/json',
1031
+ 'x-deepline-internal-token': internalToken,
1032
+ 'x-deepline-runtime-trace-id': traceId,
1033
+ ...vercelProtectionHeaders,
1034
+ },
1035
+ body: JSON.stringify({
1036
+ runId: input.launch.runId,
1037
+ attempt: input.attempt,
1038
+ leaseToken: input.leaseToken,
1039
+ schedulerSchema: input.schedulerSchema ?? null,
1040
+ // Expand-before-enable rollout fence. An old worker never sends this,
1041
+ // so a newly deployed app cannot activate direct terminal reduction
1042
+ // before the owning Fly lane has the cleanup controller.
1043
+ terminalReducerContract: DIRECT_COMPLETED_TERMINAL_CONTRACT,
1044
+ }),
1045
+ redirect: 'manual',
1046
+ });
1047
+ } catch (error) {
1048
+ const message = error instanceof Error ? error.message : String(error);
1049
+ console.warn('[runtime-attempt-token.request_failed]', {
988
1050
  runId: input.launch.runId,
989
1051
  attempt: input.attempt,
990
- leaseToken: input.leaseToken,
991
- schedulerSchema: input.schedulerSchema ?? null,
992
- // Expand-before-enable rollout fence. An old worker never sends this,
993
- // so a newly deployed app cannot activate direct terminal reduction
994
- // before the owning Fly lane has the cleanup controller.
995
- terminalReducerContract: DIRECT_COMPLETED_TERMINAL_CONTRACT,
996
- }),
997
- redirect: 'manual',
998
- });
1052
+ traceId,
1053
+ durationMs: Math.round(performance.now() - startedAt),
1054
+ status: null,
1055
+ code: 'TRANSPORT',
1056
+ error: message,
1057
+ });
1058
+ throw new AttemptExecutorTokenUnavailableError(
1059
+ `Attempt executor token mint failed [TRANSPORT] at ${new URL(url).host}: ${message}`,
1060
+ null,
1061
+ );
1062
+ }
999
1063
  const responseForFingerprint = response.clone();
1000
1064
  const body = (await response.json().catch(() => null)) as {
1001
1065
  executorToken?: unknown;
@@ -1038,9 +1102,11 @@ export async function requestAttemptExecutorToken(input: {
1038
1102
  response.status,
1039
1103
  );
1040
1104
  }
1041
- throw new Error(
1042
- `Attempt executor token mint failed [${code}] (${response.status}) at ${new URL(url).host}: ${typeof body?.error === 'string' ? body.error : 'invalid response'}`,
1043
- );
1105
+ const failure = `Attempt executor token mint failed [${code}] (${response.status}) at ${new URL(url).host}: ${typeof body?.error === 'string' ? body.error : 'invalid response'}`;
1106
+ if (isTransientExecutorTokenStatus(response.status, body !== null)) {
1107
+ throw new AttemptExecutorTokenUnavailableError(failure, response.status);
1108
+ }
1109
+ throw new Error(failure);
1044
1110
  }
1045
1111
  console.info('[runtime-attempt-token.request_succeeded]', {
1046
1112
  runId: input.launch.runId,
@@ -1857,6 +1923,24 @@ export async function reconcileTerminalTruthIntoReporter(input: {
1857
1923
  });
1858
1924
  }
1859
1925
 
1926
+ const outcomes = await readTerminalSheetRowOutcomes(input);
1927
+ if (outcomes.length > 0) {
1928
+ input.reporter.applyTerminalSheetOutcomes(outcomes);
1929
+ }
1930
+ }
1931
+
1932
+ /**
1933
+ * Each runtime sheet's settled row split for this run — the data plane's
1934
+ * account of the run's rows (ADR 0016 rule 3). Best-effort per table: a table
1935
+ * whose sheet never materialized has no counts and is skipped loudly.
1936
+ */
1937
+ async function readTerminalSheetRowOutcomes(input: {
1938
+ launch: PlaySchedulerSubmitInput;
1939
+ runId: string;
1940
+ attempt: number;
1941
+ }): Promise<
1942
+ Array<{ tableNamespace: string; completed: number; failed: number; total: number }>
1943
+ > {
1860
1944
  const sheetNamespaces = [
1861
1945
  ...new Set(
1862
1946
  (input.launch.preloadedDbSessions ?? [])
@@ -1866,7 +1950,7 @@ export async function reconcileTerminalTruthIntoReporter(input: {
1866
1950
  ),
1867
1951
  ];
1868
1952
  if (sheetNamespaces.length === 0) {
1869
- return;
1953
+ return [];
1870
1954
  }
1871
1955
  const runtimeContext = {
1872
1956
  baseUrl: input.launch.baseUrl,
@@ -1907,8 +1991,35 @@ export async function reconcileTerminalTruthIntoReporter(input: {
1907
1991
  });
1908
1992
  }
1909
1993
  }
1910
- if (outcomes.length > 0) {
1911
- input.reporter.applyTerminalSheetOutcomes(outcomes);
1994
+ return outcomes;
1995
+ }
1996
+
1997
+ /**
1998
+ * A failed runner result's settled map counts, read from the runtime sheets
1999
+ * BEFORE the terminal write so they ride the `run.failed` outbox row ahead of
2000
+ * the terminal fact (`buildFailedTerminalSettledProgressEvents`). Never
2001
+ * blocks the terminal: a read that fails leaves the ledger at its last live
2002
+ * counts, as before.
2003
+ */
2004
+ async function collectFailedTerminalSettledProgressEvents(input: {
2005
+ launch: PlaySchedulerSubmitInput;
2006
+ runId: string;
2007
+ attempt: number;
2008
+ }): Promise<PlayRunLedgerEvent[]> {
2009
+ try {
2010
+ const outcomes = await readTerminalSheetRowOutcomes(input);
2011
+ return buildFailedTerminalSettledProgressEvents({
2012
+ launch: input.launch,
2013
+ outcomes,
2014
+ occurredAt: Date.now(),
2015
+ });
2016
+ } catch (error) {
2017
+ console.warn('[absurd-runtime.worker] failed_terminal_settled_counts_failed', {
2018
+ runId: input.runId,
2019
+ attempt: input.attempt,
2020
+ error: error instanceof Error ? error.message : String(error),
2021
+ });
2022
+ return [];
1912
2023
  }
1913
2024
  }
1914
2025
  type ObservedTerminalSource =
@@ -1931,11 +2042,48 @@ function requestTerminalOutboxProjection(input: {
1931
2042
  launch: PlaySchedulerSubmitInput;
1932
2043
  runId: string;
1933
2044
  options: Pick<PostgresSchedulerOptions, 'schema'>;
2045
+ /** Where the request came from, for terminal-visibility attribution. */
2046
+ trigger?: string;
2047
+ /** When this worker began post-commit projection for the terminal. */
2048
+ projectionStartedAtMs?: number;
1934
2049
  }): void {
1935
2050
  const targetEventId = `run-terminal:${input.runId}`;
2051
+ const requestedAtMs = Date.now();
2052
+ // Terminal-visibility evidence: the request itself, and how long the
2053
+ // post-commit work before it took, so a slow first claim is attributable.
2054
+ console.info('[run-ledger-outbox.immediate_dispatch_requested]', {
2055
+ runId: input.runId,
2056
+ trigger: input.trigger ?? 'unspecified',
2057
+ requestedAtMs,
2058
+ sinceProjectionStartMs:
2059
+ input.projectionStartedAtMs === undefined
2060
+ ? null
2061
+ : requestedAtMs - input.projectionStartedAtMs,
2062
+ });
1936
2063
  void input.deps
1937
2064
  .projectOutbox(input.launch, input.runId, input.options, targetEventId)
1938
2065
  .then((projection) => {
2066
+ // Terminal-visibility latency evidence (ADR-0022 budget): when this
2067
+ // worker asked for delivery and how long the delivery took.
2068
+ console.info('[run-ledger-outbox.immediate_dispatch_settled]', {
2069
+ runId: input.runId,
2070
+ requestedAtMs,
2071
+ durationMs: Date.now() - requestedAtMs,
2072
+ targetDelivered: projection?.targetDelivered ?? null,
2073
+ attempted: projection?.attempted ?? null,
2074
+ projected: projection?.projected ?? null,
2075
+ });
2076
+ if (projection && projection.attempted === 0 && !projection.targetDelivered) {
2077
+ // Nothing was claimable for this run: another dispatcher holds its
2078
+ // outbox lease or a predecessor is not yet due. Delivery now depends
2079
+ // on that holder or the projector's next wave.
2080
+ console.info('[run-ledger-outbox.immediate_dispatch_empty_claim]', {
2081
+ runId: input.runId,
2082
+ targetEventId,
2083
+ requestedAtMs,
2084
+ durationMs: Date.now() - requestedAtMs,
2085
+ });
2086
+ }
1939
2087
  // An empty drain means another concurrent projector already claimed and
1940
2088
  // delivered the terminal fact. It is not pending work and warning here
1941
2089
  // turns the normal exactly-once race into misleading operator noise.
@@ -2081,6 +2229,12 @@ async function settleObservedTerminal(input: {
2081
2229
  )
2082
2230
  )?.status;
2083
2231
  if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
2232
+ console.info('[run-ledger-outbox.immediate_dispatch_skipped]', {
2233
+ runId: input.runId,
2234
+ source: input.source,
2235
+ reason: 'run_not_terminal',
2236
+ status: status ?? null,
2237
+ });
2084
2238
  return;
2085
2239
  }
2086
2240
  const backfilledBilling = await input.deps.runWithClient((client) =>
@@ -2100,7 +2254,7 @@ async function settleObservedTerminal(input: {
2100
2254
  },
2101
2255
  );
2102
2256
  }
2103
- requestTerminalOutboxProjection(input);
2257
+ requestTerminalOutboxProjection({ ...input, trigger: input.source });
2104
2258
  }
2105
2259
 
2106
2260
  /**
@@ -2516,16 +2670,66 @@ async function executeAbsurdAttemptAdapter(
2516
2670
  cleanupAuthorized: false,
2517
2671
  }),
2518
2672
  );
2519
- throw new DeferTask(
2520
- `Detached runner ${runId}/${generation} retains execution after scheduler handoff failure.`,
2521
- 1,
2522
- );
2673
+ // A failed handoff never destroys the live runner (no cleanup is
2674
+ // authorized here) and does not terminalize a Run whose runner may still
2675
+ // finish (#6781). The wait stays bounded by wall-clock time: an
2676
+ // executor-token outage defers only until the detached runner's hard
2677
+ // ceiling; past it the Run is terminalized exactly once under its fence,
2678
+ // and runner cleanup follows only from that recorded terminal outcome.
2679
+ if (!(error instanceof AttemptExecutorTokenUnavailableError)) {
2680
+ throw new DeferTask(
2681
+ `Detached runner ${runId}/${generation} retains execution after scheduler handoff failure.`,
2682
+ 1,
2683
+ );
2684
+ }
2685
+ // Throws DeferTask while inside the wall-clock bound.
2686
+ await deferAbsurdAttemptForExecutorTokenOutage({
2687
+ deps,
2688
+ options,
2689
+ runId,
2690
+ generation,
2691
+ leaseToken,
2692
+ detachedRecovery: true,
2693
+ detachedSuspension: detached.suspension,
2694
+ error,
2695
+ });
2696
+ await salvageAbsurdFinalAttemptTerminal({
2697
+ ctx,
2698
+ deps,
2699
+ options,
2700
+ runId,
2701
+ launch: executionLaunch,
2702
+ generation,
2703
+ leaseToken,
2704
+ error,
2705
+ detachedRecovery: true,
2706
+ force: true,
2707
+ });
2708
+ throw error;
2523
2709
  }
2524
2710
  if (error instanceof CancelledTask || error instanceof FailedTask) {
2525
2711
  // Park/cancel signals belong to the engine; never treat them as leg
2526
2712
  // failures.
2527
2713
  throw error;
2528
2714
  }
2715
+ let forceTerminalSalvage = false;
2716
+ if (error instanceof AttemptExecutorTokenUnavailableError) {
2717
+ // Throws DeferTask while inside the wall-clock bound; returns only when
2718
+ // the bound is exhausted and this attempt must write the terminal.
2719
+ await deferAbsurdAttemptForExecutorTokenOutage({
2720
+ deps,
2721
+ options,
2722
+ runId,
2723
+ generation,
2724
+ leaseToken,
2725
+ detachedRecovery: isDetachedRecovery,
2726
+ detachedSuspension: isDetachedRecovery
2727
+ ? (assigned.suspension ?? null)
2728
+ : null,
2729
+ error,
2730
+ });
2731
+ forceTerminalSalvage = true;
2732
+ }
2529
2733
  // Token minting and other pre-run setup can fail after a sandbox has
2530
2734
  // already been recorded. There is no runner config available on this
2531
2735
  // outer path, so persist the controller-owned cleanup obligation here.
@@ -2577,6 +2781,8 @@ async function executeAbsurdAttemptAdapter(
2577
2781
  generation,
2578
2782
  leaseToken,
2579
2783
  error,
2784
+ detachedRecovery: isDetachedRecovery,
2785
+ force: forceTerminalSalvage,
2580
2786
  });
2581
2787
  throw error;
2582
2788
  } finally {
@@ -2781,6 +2987,16 @@ async function executeAssignedAbsurdAttempt(input: {
2781
2987
  });
2782
2988
  }
2783
2989
 
2990
+ // A failed run's settled counts must reach the ledger BEFORE its terminal
2991
+ // makes the steps immutable, so they ride the terminal outbox row.
2992
+ const failedTerminalProgressEvents =
2993
+ runnerResult.status === 'failed'
2994
+ ? await collectFailedTerminalSettledProgressEvents({
2995
+ launch,
2996
+ runId,
2997
+ attempt: generation,
2998
+ })
2999
+ : [];
2784
3000
  const result = await deps.runWithClient((client) =>
2785
3001
  executePostgresSchedulerClaim(
2786
3002
  client,
@@ -2790,6 +3006,9 @@ async function executeAssignedAbsurdAttempt(input: {
2790
3006
  ...options,
2791
3007
  terminalLogOutbox: input.fromDetachedRunner ? 'ctx_log_tail' : 'omit',
2792
3008
  sandboxCrashDiagnostic,
3009
+ ...(failedTerminalProgressEvents.length > 0
3010
+ ? { failedTerminalProgressEvents }
3011
+ : {}),
2793
3012
  },
2794
3013
  ),
2795
3014
  );
@@ -2802,6 +3021,13 @@ async function executeAssignedAbsurdAttempt(input: {
2802
3021
  `absurd runtime worker: unexpected non-terminal claim status "${result.status}" for ${runId}.`,
2803
3022
  );
2804
3023
  }
3024
+ console.info('[absurd-runtime.worker] terminal_committed', {
3025
+ runId,
3026
+ generation,
3027
+ status: result.status,
3028
+ fromDetachedRunner: input.fromDetachedRunner,
3029
+ committedAtMs: Date.now(),
3030
+ });
2805
3031
 
2806
3032
  await terminalProjection.projectCommitted({
2807
3033
  runnerResult: terminalEnvelopeFromRunnerResult(runnerResult),
@@ -4149,6 +4375,7 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
4149
4375
  );
4150
4376
  });
4151
4377
  };
4378
+ let projectionStartedAtMs: number | undefined;
4152
4379
  return createPlayRunAttemptWorkerTerminalProjection({
4153
4380
  async observeFenceLost() {
4154
4381
  // A lost fence means this attempt is dead, which a cancelled run is the
@@ -4164,6 +4391,7 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
4164
4391
  });
4165
4392
  },
4166
4393
  async reconcileTerminalTruth() {
4394
+ projectionStartedAtMs ??= Date.now();
4167
4395
  // ADR 0016 rule 3: detached Daytona runs may provide no live events, so
4168
4396
  // fold stored docflow/runtime-sheet facts into progress after truth wins.
4169
4397
  await reconcileTerminalTruthIntoReporter({
@@ -4247,6 +4475,10 @@ function createAbsurdWorkerTerminalProjectionAdapter(input: {
4247
4475
  launch: input.launch,
4248
4476
  runId: input.runId,
4249
4477
  options: input.options,
4478
+ trigger: 'committed_terminal',
4479
+ ...(projectionStartedAtMs === undefined
4480
+ ? {}
4481
+ : { projectionStartedAtMs }),
4250
4482
  });
4251
4483
  },
4252
4484
  });
@@ -4960,6 +5192,111 @@ async function releaseAbsurdAttemptRuntimeState(input: {
4960
5192
  }
4961
5193
  }
4962
5194
 
5195
+ /**
5196
+ * An executor-token outage happens before any Play code runs under this
5197
+ * attempt, so it must not spend an engine attempt: a few minutes of app
5198
+ * restart would otherwise exhaust max_attempts. Defer the durable task while
5199
+ * inside a wall-clock bound, then return so the caller terminalizes loudly.
5200
+ *
5201
+ * - Fresh attempt: retire it and requeue the run (like a capacity deferral);
5202
+ * the bound is the start of the unbroken outage recorded on attempt rows.
5203
+ * - Detached-runner wake: the attempt stays `waiting` under its original
5204
+ * fence, because a live runner may still push its terminal. Nothing is
5205
+ * written; the bound is the runner's persisted hard ceiling.
5206
+ */
5207
+ async function deferAbsurdAttemptForExecutorTokenOutage(input: {
5208
+ deps: AbsurdRuntimeWorkerDeps;
5209
+ options: Pick<PostgresSchedulerOptions, 'schema'>;
5210
+ runId: string;
5211
+ generation: number;
5212
+ leaseToken: string;
5213
+ detachedRecovery: boolean;
5214
+ detachedSuspension: unknown;
5215
+ error: AttemptExecutorTokenUnavailableError;
5216
+ }): Promise<void> {
5217
+ const nowMs = Date.now();
5218
+ if (input.detachedRecovery) {
5219
+ const deadlineMs = detachedRunnerCeilingDeadlineMsFromSuspension(
5220
+ input.detachedSuspension,
5221
+ );
5222
+ if (deadlineMs !== null && nowMs < deadlineMs) {
5223
+ const delaySeconds = Math.max(
5224
+ 1,
5225
+ Math.min(
5226
+ ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
5227
+ Math.ceil((deadlineMs - nowMs) / 1_000),
5228
+ ),
5229
+ );
5230
+ console.warn('[absurd-runtime.worker] executor_token_outage_deferred', {
5231
+ runId: input.runId,
5232
+ generation: input.generation,
5233
+ detachedRecovery: true,
5234
+ status: input.error.status,
5235
+ deadlineMs,
5236
+ delaySeconds,
5237
+ });
5238
+ throw new DeferTask(input.error.message, delaySeconds);
5239
+ }
5240
+ console.error(
5241
+ '[absurd-runtime.worker] executor_token_outage_bound_exhausted',
5242
+ {
5243
+ runId: input.runId,
5244
+ generation: input.generation,
5245
+ detachedRecovery: true,
5246
+ status: input.error.status,
5247
+ deadlineMs,
5248
+ },
5249
+ );
5250
+ return;
5251
+ }
5252
+ // The retired attempt may already own a recorded sandbox; persist its
5253
+ // cleanup obligation before the attempt leaves the running fence.
5254
+ await persistAttemptSandboxCleanupBeforeBestEffortGc({
5255
+ deps: input.deps,
5256
+ runId: input.runId,
5257
+ attempt: input.generation,
5258
+ options: input.options,
5259
+ });
5260
+ const outcome = await input.deps.runWithClient((client) =>
5261
+ deferPostgresSchedulerAttemptForExecutorTokenOutage(
5262
+ client,
5263
+ {
5264
+ runId: input.runId,
5265
+ attempt: input.generation,
5266
+ leaseToken: input.leaseToken,
5267
+ reason: input.error.message,
5268
+ maxOutageMs: ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS,
5269
+ },
5270
+ input.options,
5271
+ ),
5272
+ );
5273
+ if (outcome.deferred) {
5274
+ console.warn('[absurd-runtime.worker] executor_token_outage_deferred', {
5275
+ runId: input.runId,
5276
+ generation: input.generation,
5277
+ detachedRecovery: false,
5278
+ status: input.error.status,
5279
+ outageStartedAtMs: outcome.outageStartedAtMs,
5280
+ delaySeconds: ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
5281
+ });
5282
+ throw new DeferTask(
5283
+ input.error.message,
5284
+ ABSURD_EXECUTOR_TOKEN_OUTAGE_DEFER_SECONDS,
5285
+ );
5286
+ }
5287
+ console.error(
5288
+ '[absurd-runtime.worker] executor_token_outage_bound_exhausted',
5289
+ {
5290
+ runId: input.runId,
5291
+ generation: input.generation,
5292
+ detachedRecovery: false,
5293
+ status: input.error.status,
5294
+ outageStartedAtMs: outcome.outageStartedAtMs,
5295
+ maxOutageMs: ABSURD_EXECUTOR_TOKEN_OUTAGE_MAX_MS,
5296
+ },
5297
+ );
5298
+ }
5299
+
4963
5300
  /**
4964
5301
  * Final-attempt terminal salvage. When the LAST engine attempt of a play-run
4965
5302
  * task dies with an unexpected error (Daytona/app-runtime/DB throw — NOT a park
@@ -4984,6 +5321,12 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
4984
5321
  generation: number;
4985
5322
  leaseToken: string;
4986
5323
  error: unknown;
5324
+ /** The attempt was claimed as a parked detached-runner wake and may still
5325
+ * be `waiting` under its original fence. */
5326
+ detachedRecovery?: boolean;
5327
+ /** A wall-clock bound, not the engine attempt count, requires the terminal
5328
+ * now (executor-token outage past its bound). */
5329
+ force?: boolean;
4987
5330
  }): Promise<void> {
4988
5331
  const attempt = input.ctx.attempt;
4989
5332
  const maxAttempts = input.ctx.maxAttempts;
@@ -5001,12 +5344,15 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
5001
5344
  { cause: input.error },
5002
5345
  );
5003
5346
  }
5004
- if (attempt < maxAttempts) return;
5347
+ if (!input.force && attempt < maxAttempts) return;
5005
5348
  const message =
5006
5349
  input.error instanceof Error ? input.error.message : String(input.error);
5007
5350
  const failedResult: PlayRunnerResult = {
5008
5351
  status: 'failed',
5009
- error: `absurd play-run task failed on its final engine attempt (${attempt}/${maxAttempts}): ${message}`,
5352
+ error:
5353
+ input.force && attempt < maxAttempts
5354
+ ? `absurd play-run task exhausted its executor-token outage bound on engine attempt ${attempt}/${maxAttempts}: ${message}`
5355
+ : `absurd play-run task failed on its final engine attempt (${attempt}/${maxAttempts}): ${message}`,
5010
5356
  logs: [],
5011
5357
  stats: {},
5012
5358
  steps: [],
@@ -5028,14 +5374,33 @@ async function salvageAbsurdFinalAttemptTerminal(input: {
5028
5374
  salvageAttempt += 1
5029
5375
  ) {
5030
5376
  try {
5031
- result = await input.deps.runWithClient((client) =>
5032
- executePostgresSchedulerClaim(
5377
+ result = await input.deps.runWithClient(async (client) => {
5378
+ // A detached-runner wake keeps its attempt `waiting` until setup
5379
+ // succeeds. Take that same fence to `running` (the overdue-ceiling
5380
+ // claim's transition) so the failure terminal can be written. When
5381
+ // the attempt is no longer waiting — it already resumed, or a newer
5382
+ // fence or pushed terminal owns it — the ordinary fenced path below
5383
+ // decides.
5384
+ if (input.detachedRecovery) {
5385
+ await claimPostgresSchedulerWaitingDetachedAttemptForTerminal(
5386
+ client,
5387
+ {
5388
+ runId: input.runId,
5389
+ attempt: input.generation,
5390
+ leaseToken: input.leaseToken,
5391
+ },
5392
+ input.options,
5393
+ );
5394
+ }
5395
+ return await executePostgresSchedulerClaim(
5033
5396
  client,
5034
5397
  claim,
5035
5398
  async () => failedResult,
5036
- input.options,
5037
- ),
5038
- );
5399
+ input.detachedRecovery
5400
+ ? { ...input.options, preserveRunnerTerminal: true }
5401
+ : input.options,
5402
+ );
5403
+ });
5039
5404
  break;
5040
5405
  } catch (salvageError) {
5041
5406
  try {
@@ -5720,6 +6085,57 @@ export async function recoverAbsurdRuntimeOverdueDetachedRunnerCeilings(input: {
5720
6085
  return { recovered, skipped };
5721
6086
  }
5722
6087
 
6088
+ export const ABSURD_OVERDUE_DETACHED_RUNNER_RECOVERY_INTERVAL_MS = 15_000;
6089
+
6090
+ /**
6091
+ * In-process hard-ceiling recovery loop for a topology with no release
6092
+ * scaler (local dev). Production keeps this obligation on the fixed scaler.
6093
+ * A pass that settled anything runs again at once: each claimed run leaves the
6094
+ * `waiting` candidate set in its own statement, so the backlog drains by
6095
+ * construction; an empty pass sleeps one interval.
6096
+ */
6097
+ export async function runAbsurdOverdueDetachedRunnerCeilingController(input: {
6098
+ runOnce: () => Promise<{ recovered: number; skipped: number }>;
6099
+ shouldStop: () => boolean;
6100
+ intervalMs?: number;
6101
+ sleep?: (ms: number) => Promise<void>;
6102
+ }): Promise<void> {
6103
+ const intervalMs = Math.max(
6104
+ 1,
6105
+ Math.floor(
6106
+ input.intervalMs ?? ABSURD_OVERDUE_DETACHED_RUNNER_RECOVERY_INTERVAL_MS,
6107
+ ),
6108
+ );
6109
+ const wait = input.sleep ?? ((ms: number) => sleep(ms));
6110
+ const reportFailure = createRateLimitedRuntimeLogger({
6111
+ log: (...args) => console.error(...args),
6112
+ });
6113
+ while (!input.shouldStop()) {
6114
+ let settled = 0;
6115
+ try {
6116
+ const outcome = await input.runOnce();
6117
+ settled = outcome.recovered + outcome.skipped;
6118
+ if (settled > 0) {
6119
+ console.info(
6120
+ '[absurd-runtime.worker] overdue_detached_runner_recovery',
6121
+ outcome,
6122
+ );
6123
+ }
6124
+ } catch (error) {
6125
+ reportFailure(
6126
+ '[absurd-runtime.worker] overdue_detached_runner_recovery_failed',
6127
+ { error: error instanceof Error ? error.message : String(error) },
6128
+ );
6129
+ }
6130
+ if (settled > 0) continue;
6131
+ // Sleep in short slices so host shutdown is not held for a full interval.
6132
+ const deadline = Date.now() + intervalMs;
6133
+ while (!input.shouldStop() && Date.now() < deadline) {
6134
+ await wait(Math.min(500, Math.max(1, deadline - Date.now())));
6135
+ }
6136
+ }
6137
+ }
6138
+
5723
6139
  /**
5724
6140
  * Fixed-controller sweep across every persisted release, including lanes no
5725
6141
  * longer present in the live release registry. The run's immutable launch
@@ -7427,6 +7843,12 @@ export type RuntimeWorkerHostConfig = {
7427
7843
  /** Invoked once the lane has drained (drain mode only). The entrypoint wires
7428
7844
  * this to a clean `process.exit(0)`. */
7429
7845
  onDrained?: () => void;
7846
+ /**
7847
+ * Run the detached-runner hard-ceiling recovery in this process. Only for a
7848
+ * topology without the fixed release scaler (the local dev supervisor);
7849
+ * production leaves it to the scaler. Default off.
7850
+ */
7851
+ recoverOverdueDetachedRunnerCeilings?: boolean;
7430
7852
  };
7431
7853
 
7432
7854
  export type RuntimeWorkerHost = {
@@ -7675,6 +8097,19 @@ export async function startRuntimeWorkerHost(
7675
8097
  readSandboxCrashDiagnostic: workerDeps.readSandboxCrashDiagnostic,
7676
8098
  shouldStop: () => cleanupStopped,
7677
8099
  });
8100
+ let overdueCeilingStopped = false;
8101
+ const overdueCeilingController = options.recoverOverdueDetachedRunnerCeilings
8102
+ ? runAbsurdOverdueDetachedRunnerCeilingController({
8103
+ runOnce: () =>
8104
+ withRuntimeSchedulerClient(poolOptions, (client) =>
8105
+ recoverAbsurdRuntimeOverdueDetachedRunnerCeilingsAcrossAllLanes({
8106
+ client,
8107
+ schedulerOptions,
8108
+ }),
8109
+ ),
8110
+ shouldStop: () => overdueCeilingStopped,
8111
+ })
8112
+ : null;
7678
8113
  // Drain self-exit (old-lane retirement). Off unless the deploy system opts in.
7679
8114
  let drainStopped = false;
7680
8115
  let drainWatch: Promise<'drained' | 'stopped'> | null = null;
@@ -7828,6 +8263,8 @@ export async function startRuntimeWorkerHost(
7828
8263
  releaseId,
7829
8264
  drainExit,
7830
8265
  laneAdoption: !drainExit,
8266
+ overdueDetachedRunnerRecovery:
8267
+ options.recoverOverdueDetachedRunnerCeilings === true,
7831
8268
  workerName: workerBase,
7832
8269
  });
7833
8270
 
@@ -7841,8 +8278,10 @@ export async function startRuntimeWorkerHost(
7841
8278
  billingStopped = true;
7842
8279
  cleanupStopped = true;
7843
8280
  adoptionStopped = true;
8281
+ overdueCeilingStopped = true;
7844
8282
  },
7845
8283
  controllers: [
8284
+ overdueCeilingController,
7846
8285
  drainWatch,
7847
8286
  adoptionSupervisor,
7848
8287
  billingController,