deepline 0.1.227 → 0.1.228

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.
@@ -380,6 +380,8 @@ function applyRetryJitter(delayMs: number): number {
380
380
  return Math.round(half + Math.random() * half);
381
381
  }
382
382
  const APP_RUNTIME_API_DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
383
+ const APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG =
384
+ '[perf][worker.receipt_api.transport]';
383
385
  const RUN_STATUS_LEDGER_SNAPSHOT_CACHE_LIMIT = 1_000;
384
386
  const runStatusLedgerSnapshots = new Map<string, PlayRunLedgerSnapshot>();
385
387
  // Positional log cursor per run attempt: count of liveLogs lines already
@@ -454,7 +456,7 @@ async function appendRunEventBatchesViaAppRuntime(
454
456
  input: { playId: string; events: readonly PlayRunLedgerEvent[] },
455
457
  ): Promise<void> {
456
458
  for (const events of partitionRunEventsForAppRuntime(input.events)) {
457
- await appendRunEventsViaAppRuntime(context, {
459
+ await appendRunEventsViaAppRuntimeRaw(context, {
458
460
  playId: input.playId,
459
461
  events,
460
462
  });
@@ -583,7 +585,7 @@ function appRuntimeErrorCode(response: Response, body: string): string | null {
583
585
  }
584
586
 
585
587
  export function isTransientAppRuntimeFailureBody(body: string): boolean {
586
- return /WorkerOverloaded|"\s*code\s*"\s*:\s*"\s*InternalServerError\s*"|Your request couldn't be completed\. Try again later\.|timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|tuple concurrently updated|RUNTIME_SCHEDULER_SATURATED|Runtime scheduler DB pool is saturated|Runtime scheduler DB circuit breaker is open|OptimisticConcurrencyControlFailure|changed while this mutation was being run|Documents read from or written to the .* table changed while this mutation/i.test(
588
+ return /WorkerOverloaded|"\s*code\s*"\s*:\s*"\s*InternalServerError\s*"|Your request couldn't be completed\. Try again later\.|timeout exceeded when trying to connect|timed out|fetch failed|ECONNRESET|ECONNREFUSED|UND_ERR_CONNECT_TIMEOUT|tuple concurrently updated|RUNTIME_SCHEDULER_(?:SATURATED|UNAVAILABLE)|Runtime scheduler DB pool is saturated|Runtime scheduler DB circuit breaker is open|OptimisticConcurrencyControlFailure|changed while this mutation was being run|Documents read from or written to the .* table changed while this mutation/i.test(
587
589
  body,
588
590
  );
589
591
  }
@@ -733,14 +735,146 @@ function sleep(ms: number): Promise<void> {
733
735
  return new Promise((resolve) => setTimeout(resolve, ms));
734
736
  }
735
737
 
738
+ type AppRuntimeRetryFailureKind =
739
+ | 'connection_refused'
740
+ | 'connection_reset'
741
+ | 'connection_timeout'
742
+ | 'dns'
743
+ | 'fetch_failed'
744
+ | 'http_error'
745
+ | 'request_timeout'
746
+ | 'response_body_error'
747
+ | 'response_body_timeout'
748
+ | 'retryable_http'
749
+ | 'transport_error';
750
+
751
+ type AppRuntimeRetryFailure = {
752
+ attempt: number;
753
+ failureKind: AppRuntimeRetryFailureKind;
754
+ attemptElapsedMs: number;
755
+ retryDelayMs: number;
756
+ httpStatus?: number;
757
+ };
758
+
759
+ type AppRuntimeRetryTelemetry = {
760
+ enabled: boolean;
761
+ startedAt: number;
762
+ retryCount: number;
763
+ retrySleepMs: number;
764
+ failures: AppRuntimeRetryFailure[];
765
+ };
766
+
767
+ function appRuntimeTransportFailureKind(
768
+ error: unknown,
769
+ ): AppRuntimeRetryFailureKind {
770
+ if (isRequestTimeoutAbort(error)) return 'request_timeout';
771
+ const message = error instanceof Error ? error.message : String(error ?? '');
772
+ if (/\b(?:ENOTFOUND|EAI_AGAIN)\b|getaddrinfo|\bdns\b/i.test(message)) {
773
+ return 'dns';
774
+ }
775
+ if (/\bECONNRESET\b|connection reset/i.test(message)) {
776
+ return 'connection_reset';
777
+ }
778
+ if (/\bECONNREFUSED\b|connection refused/i.test(message)) {
779
+ return 'connection_refused';
780
+ }
781
+ if (
782
+ /\bETIMEDOUT\b|UND_ERR_CONNECT_TIMEOUT|connection (?:timeout|timed out)/i.test(
783
+ message,
784
+ )
785
+ ) {
786
+ return 'connection_timeout';
787
+ }
788
+ return /fetch failed/i.test(message) ? 'fetch_failed' : 'transport_error';
789
+ }
790
+
791
+ function recordAppRuntimeRetryFailure(input: {
792
+ telemetry: AppRuntimeRetryTelemetry;
793
+ attempt: number;
794
+ failureKind: AppRuntimeRetryFailureKind;
795
+ attemptStartedAt: number;
796
+ retryDelayMs: number;
797
+ httpStatus?: number;
798
+ }): void {
799
+ if (!input.telemetry.enabled) return;
800
+ // Receipt actions have at most eight attempts. Keep the terminal eighth
801
+ // failure instead of truncating the causally important final observation.
802
+ if (input.telemetry.failures.length < 8) {
803
+ input.telemetry.failures.push({
804
+ attempt: input.attempt,
805
+ failureKind: input.failureKind,
806
+ attemptElapsedMs: Date.now() - input.attemptStartedAt,
807
+ retryDelayMs: input.retryDelayMs,
808
+ ...(input.httpStatus === undefined
809
+ ? {}
810
+ : { httpStatus: input.httpStatus }),
811
+ });
812
+ }
813
+ if (input.retryDelayMs > 0) {
814
+ input.telemetry.retryCount += 1;
815
+ input.telemetry.retrySleepMs += input.retryDelayMs;
816
+ }
817
+ }
818
+
819
+ function emitAppRuntimeRetrySummary(input: {
820
+ telemetry: AppRuntimeRetryTelemetry;
821
+ action: RuntimeApiRequest['action'];
822
+ outcome: 'failed_after_retry' | 'recovered_after_retry';
823
+ attempts: number;
824
+ maxAttempts: number;
825
+ finalAttemptStartedAt: number;
826
+ }): void {
827
+ if (!input.telemetry.enabled || input.telemetry.retryCount === 0) return;
828
+ const payload = {
829
+ action: input.action,
830
+ outcome: input.outcome,
831
+ attempts: input.attempts,
832
+ maxAttempts: input.maxAttempts,
833
+ totalElapsedMs: Date.now() - input.telemetry.startedAt,
834
+ retrySleepMs: input.telemetry.retrySleepMs,
835
+ finalAttemptElapsedMs: Date.now() - input.finalAttemptStartedAt,
836
+ failures: input.telemetry.failures,
837
+ };
838
+ // Observability must never alter receipt execution. The payload contains
839
+ // primitives from a fixed taxonomy only: no URL, token, body, raw error,
840
+ // run/play/org id, receipt key, input, or output.
841
+ try {
842
+ const serialized = JSON.stringify(payload);
843
+ if (input.outcome === 'failed_after_retry') {
844
+ console.warn(APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG, serialized);
845
+ } else {
846
+ console.info(APP_RUNTIME_RECEIPT_RETRY_TELEMETRY_TAG, serialized);
847
+ }
848
+ } catch {}
849
+ }
850
+
736
851
  async function retryAppRuntimeBodyTimeoutOrThrow(input: {
737
852
  action: RuntimeApiRequest['action'];
738
853
  attempt: number;
739
854
  maxAttempts: number;
740
855
  error: unknown;
856
+ telemetry: AppRuntimeRetryTelemetry;
857
+ attemptStartedAt: number;
858
+ httpStatus?: number;
741
859
  retryAfterMs?: number | null;
742
860
  }): Promise<void> {
743
861
  if (!isRequestTimeoutAbort(input.error)) {
862
+ recordAppRuntimeRetryFailure({
863
+ telemetry: input.telemetry,
864
+ attempt: input.attempt,
865
+ failureKind: 'response_body_error',
866
+ attemptStartedAt: input.attemptStartedAt,
867
+ retryDelayMs: 0,
868
+ httpStatus: input.httpStatus,
869
+ });
870
+ emitAppRuntimeRetrySummary({
871
+ telemetry: input.telemetry,
872
+ action: input.action,
873
+ outcome: 'failed_after_retry',
874
+ attempts: input.attempt,
875
+ maxAttempts: input.maxAttempts,
876
+ finalAttemptStartedAt: input.attemptStartedAt,
877
+ });
744
878
  throw input.error;
745
879
  }
746
880
  if (
@@ -750,14 +884,37 @@ async function retryAppRuntimeBodyTimeoutOrThrow(input: {
750
884
  error: input.error,
751
885
  })
752
886
  ) {
753
- await sleep(
754
- Math.max(
755
- applyRetryJitter(appRuntimeRetryDelayMs(input.action, input.attempt)),
756
- input.retryAfterMs ?? 0,
757
- ),
887
+ const retryDelayMs = Math.max(
888
+ applyRetryJitter(appRuntimeRetryDelayMs(input.action, input.attempt)),
889
+ input.retryAfterMs ?? 0,
758
890
  );
891
+ recordAppRuntimeRetryFailure({
892
+ telemetry: input.telemetry,
893
+ attempt: input.attempt,
894
+ failureKind: 'response_body_timeout',
895
+ attemptStartedAt: input.attemptStartedAt,
896
+ retryDelayMs,
897
+ httpStatus: input.httpStatus,
898
+ });
899
+ await sleep(retryDelayMs);
759
900
  return;
760
901
  }
902
+ recordAppRuntimeRetryFailure({
903
+ telemetry: input.telemetry,
904
+ attempt: input.attempt,
905
+ failureKind: 'response_body_timeout',
906
+ attemptStartedAt: input.attemptStartedAt,
907
+ retryDelayMs: 0,
908
+ httpStatus: input.httpStatus,
909
+ });
910
+ emitAppRuntimeRetrySummary({
911
+ telemetry: input.telemetry,
912
+ action: input.action,
913
+ outcome: 'failed_after_retry',
914
+ attempts: input.attempt,
915
+ maxAttempts: input.maxAttempts,
916
+ finalAttemptStartedAt: input.attemptStartedAt,
917
+ });
761
918
  throw new AppRuntimeApiTransportError({
762
919
  action: input.action,
763
920
  attempts: input.attempt,
@@ -811,6 +968,13 @@ async function postAppRuntimeApi<TResponse>(
811
968
  });
812
969
 
813
970
  const maxAttempts = appRuntimeMaxAttempts(body.action);
971
+ const retryTelemetry: AppRuntimeRetryTelemetry = {
972
+ enabled: isRuntimeStepReceiptAction(body.action),
973
+ startedAt: Date.now(),
974
+ retryCount: 0,
975
+ retrySleepMs: 0,
976
+ failures: [],
977
+ };
814
978
  const requestTimeoutMs = Math.max(
815
979
  1,
816
980
  Math.floor(
@@ -819,6 +983,7 @@ async function postAppRuntimeApi<TResponse>(
819
983
  );
820
984
 
821
985
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
986
+ const attemptStartedAt = Date.now();
822
987
  let response: Response;
823
988
  try {
824
989
  response = await runtimeFetch(resolveAppRuntimeApiUrl(context), {
@@ -843,11 +1008,34 @@ async function postAppRuntimeApi<TResponse>(
843
1008
  attempt < maxAttempts &&
844
1009
  isRetryableAppRuntimeFetchError({ action: body.action, error })
845
1010
  ) {
846
- await sleep(
847
- applyRetryJitter(appRuntimeRetryDelayMs(body.action, attempt)),
1011
+ const retryDelayMs = applyRetryJitter(
1012
+ appRuntimeRetryDelayMs(body.action, attempt),
848
1013
  );
1014
+ recordAppRuntimeRetryFailure({
1015
+ telemetry: retryTelemetry,
1016
+ attempt,
1017
+ failureKind: appRuntimeTransportFailureKind(error),
1018
+ attemptStartedAt,
1019
+ retryDelayMs,
1020
+ });
1021
+ await sleep(retryDelayMs);
849
1022
  continue;
850
1023
  }
1024
+ recordAppRuntimeRetryFailure({
1025
+ telemetry: retryTelemetry,
1026
+ attempt,
1027
+ failureKind: appRuntimeTransportFailureKind(error),
1028
+ attemptStartedAt,
1029
+ retryDelayMs: 0,
1030
+ });
1031
+ emitAppRuntimeRetrySummary({
1032
+ telemetry: retryTelemetry,
1033
+ action: body.action,
1034
+ outcome: 'failed_after_retry',
1035
+ attempts: attempt,
1036
+ maxAttempts,
1037
+ finalAttemptStartedAt: attemptStartedAt,
1038
+ });
851
1039
  throw new AppRuntimeApiTransportError({
852
1040
  action: body.action,
853
1041
  attempts: attempt,
@@ -856,13 +1044,25 @@ async function postAppRuntimeApi<TResponse>(
856
1044
  }
857
1045
  if (response.ok) {
858
1046
  try {
859
- return (await response.json()) as TResponse;
1047
+ const parsed = (await response.json()) as TResponse;
1048
+ emitAppRuntimeRetrySummary({
1049
+ telemetry: retryTelemetry,
1050
+ action: body.action,
1051
+ outcome: 'recovered_after_retry',
1052
+ attempts: attempt,
1053
+ maxAttempts,
1054
+ finalAttemptStartedAt: attemptStartedAt,
1055
+ });
1056
+ return parsed;
860
1057
  } catch (error) {
861
1058
  await retryAppRuntimeBodyTimeoutOrThrow({
862
1059
  action: body.action,
863
1060
  attempt,
864
1061
  maxAttempts,
865
1062
  error,
1063
+ telemetry: retryTelemetry,
1064
+ attemptStartedAt,
1065
+ httpStatus: response.status,
866
1066
  });
867
1067
  continue;
868
1068
  }
@@ -875,6 +1075,22 @@ async function postAppRuntimeApi<TResponse>(
875
1075
  isRequestTimeoutAbort(error) &&
876
1076
  !isRetryableAppRuntimeResponseStatus(response.status)
877
1077
  ) {
1078
+ recordAppRuntimeRetryFailure({
1079
+ telemetry: retryTelemetry,
1080
+ attempt,
1081
+ failureKind: 'response_body_timeout',
1082
+ attemptStartedAt,
1083
+ retryDelayMs: 0,
1084
+ httpStatus: response.status,
1085
+ });
1086
+ emitAppRuntimeRetrySummary({
1087
+ telemetry: retryTelemetry,
1088
+ action: body.action,
1089
+ outcome: 'failed_after_retry',
1090
+ attempts: attempt,
1091
+ maxAttempts,
1092
+ finalAttemptStartedAt: attemptStartedAt,
1093
+ });
878
1094
  throw new Error(
879
1095
  `App runtime API ${body.action} failed with status ${response.status}: response body timed out`,
880
1096
  { cause: error },
@@ -885,6 +1101,9 @@ async function postAppRuntimeApi<TResponse>(
885
1101
  attempt,
886
1102
  maxAttempts,
887
1103
  error,
1104
+ telemetry: retryTelemetry,
1105
+ attemptStartedAt,
1106
+ httpStatus: response.status,
888
1107
  retryAfterMs: appRuntimeRetryAfterMs(response, ''),
889
1108
  });
890
1109
  continue;
@@ -897,14 +1116,43 @@ async function postAppRuntimeApi<TResponse>(
897
1116
  body: responseText,
898
1117
  })
899
1118
  ) {
900
- await sleep(
901
- Math.max(
902
- applyRetryJitter(appRuntimeRetryDelayMs(body.action, attempt)),
903
- appRuntimeRetryAfterMs(response, responseText) ?? 0,
904
- ),
1119
+ const retryDelayMs = Math.max(
1120
+ applyRetryJitter(appRuntimeRetryDelayMs(body.action, attempt)),
1121
+ appRuntimeRetryAfterMs(response, responseText) ?? 0,
905
1122
  );
1123
+ recordAppRuntimeRetryFailure({
1124
+ telemetry: retryTelemetry,
1125
+ attempt,
1126
+ failureKind: 'retryable_http',
1127
+ attemptStartedAt,
1128
+ retryDelayMs,
1129
+ httpStatus: response.status,
1130
+ });
1131
+ await sleep(retryDelayMs);
906
1132
  continue;
907
1133
  }
1134
+ recordAppRuntimeRetryFailure({
1135
+ telemetry: retryTelemetry,
1136
+ attempt,
1137
+ failureKind: isRetryableAppRuntimeResponse({
1138
+ action: body.action,
1139
+ status: response.status,
1140
+ body: responseText,
1141
+ })
1142
+ ? 'retryable_http'
1143
+ : 'http_error',
1144
+ attemptStartedAt,
1145
+ retryDelayMs: 0,
1146
+ httpStatus: response.status,
1147
+ });
1148
+ emitAppRuntimeRetrySummary({
1149
+ telemetry: retryTelemetry,
1150
+ action: body.action,
1151
+ outcome: 'failed_after_retry',
1152
+ attempts: attempt,
1153
+ maxAttempts,
1154
+ finalAttemptStartedAt: attemptStartedAt,
1155
+ });
908
1156
  const code = appRuntimeErrorCode(response, responseText);
909
1157
  const requestId = response.headers.get('x-deepline-request-id')?.trim();
910
1158
  throw new Error(
@@ -1239,7 +1487,7 @@ async function updateRunStatusViaAppRuntimeUnlocked(
1239
1487
  );
1240
1488
  }
1241
1489
 
1242
- export async function appendRunEventsViaAppRuntime(
1490
+ async function appendRunEventsViaAppRuntimeRaw(
1243
1491
  context: WorkerRuntimeApiContext,
1244
1492
  input: {
1245
1493
  playId: string;
@@ -1253,6 +1501,57 @@ export async function appendRunEventsViaAppRuntime(
1253
1501
  });
1254
1502
  }
1255
1503
 
1504
+ /**
1505
+ * Append Run Ledger events through the bounded transport used by every runtime
1506
+ * producer. Scheduler terminal outbox events can carry a complete log replay,
1507
+ * so this boundary must never forward an arbitrarily large event array or log
1508
+ * event directly to Convex.
1509
+ */
1510
+ export async function appendRunEventsViaAppRuntime(
1511
+ context: WorkerRuntimeApiContext,
1512
+ input: {
1513
+ playId: string;
1514
+ events: PlayRunLedgerEvent[];
1515
+ idempotencyKey?: string;
1516
+ },
1517
+ ): Promise<void> {
1518
+ const batches = partitionRunEventsForAppRuntime(input.events);
1519
+ if (batches.length <= 1) {
1520
+ await appendRunEventsViaAppRuntimeRaw(context, input);
1521
+ return;
1522
+ }
1523
+
1524
+ // Terminal idempotency keys record a completed delivery. Sending the same
1525
+ // key on an earlier log-only batch would make Convex drop the terminal batch,
1526
+ // so attach it to the batch that contains the final terminal event only.
1527
+ let terminalBatchIndex = -1;
1528
+ for (let index = batches.length - 1; index >= 0; index -= 1) {
1529
+ if (
1530
+ batches[index]!.some(
1531
+ (event) =>
1532
+ event.type === 'run.completed' ||
1533
+ event.type === 'run.failed' ||
1534
+ event.type === 'run.cancelled',
1535
+ )
1536
+ ) {
1537
+ terminalBatchIndex = index;
1538
+ break;
1539
+ }
1540
+ }
1541
+ const idempotencyBatchIndex =
1542
+ terminalBatchIndex >= 0 ? terminalBatchIndex : batches.length - 1;
1543
+
1544
+ for (let index = 0; index < batches.length; index += 1) {
1545
+ await appendRunEventsViaAppRuntimeRaw(context, {
1546
+ playId: input.playId,
1547
+ events: batches[index]!,
1548
+ ...(input.idempotencyKey && index === idempotencyBatchIndex
1549
+ ? { idempotencyKey: input.idempotencyKey }
1550
+ : {}),
1551
+ });
1552
+ }
1553
+ }
1554
+
1256
1555
  export async function startRunViaAppRuntime(
1257
1556
  context: WorkerRuntimeApiContext,
1258
1557
  input: {
@@ -108,6 +108,10 @@ import {
108
108
  ToolExecuteAuthScopeChangedError,
109
109
  } from './tool-execute-retry-policy';
110
110
  import { ToolHttpError } from './tool-http-errors';
111
+ import {
112
+ describeTransportError,
113
+ transportGatewayOriginForDiagnostic,
114
+ } from './transport-error-diagnostics';
111
115
  import {
112
116
  buildDurableCtxCallCacheKey,
113
117
  buildDurableRunPlayInvocationScope,
@@ -8725,10 +8729,26 @@ export class PlayContextImpl {
8725
8729
  const retrySafeTransientHttp =
8726
8730
  retryPolicy?.retrySafeTransientHttp === true;
8727
8731
  let transportAttempt = 0;
8728
- const retryToolTransportFailure = async (
8729
- message: string,
8730
- ): Promise<void> => {
8732
+ const retryToolTransportFailure = async (input: {
8733
+ error: unknown;
8734
+ elapsedMs: number;
8735
+ requestId: string | null;
8736
+ aborted: boolean;
8737
+ }): Promise<void> => {
8731
8738
  transportAttempt += 1;
8739
+ const diagnostic = describeTransportError(input.error);
8740
+ this.log(
8741
+ `[runtime.transport_failure] ${JSON.stringify({
8742
+ tool_id: toolId,
8743
+ gateway_origin: transportGatewayOriginForDiagnostic(url),
8744
+ attempt: transportAttempt,
8745
+ max_attempts: TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS,
8746
+ elapsed_ms: input.elapsedMs,
8747
+ request_id: input.requestId,
8748
+ aborted: input.aborted,
8749
+ error: diagnostic,
8750
+ })}`,
8751
+ );
8732
8752
  if (transportAttempt < TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS) {
8733
8753
  await this.governor.chargeBudget('retry');
8734
8754
  const retryAfterMs =
@@ -8738,14 +8758,14 @@ export class PlayContextImpl {
8738
8758
  transportAttempt,
8739
8759
  );
8740
8760
  this.log(
8741
- `Tool ${toolId} transport failed calling ${url} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}; retrying after ${retryAfterMs}ms: ${message}`,
8761
+ `Tool ${toolId} transport failed calling ${url} on attempt ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS}; retrying after ${retryAfterMs}ms: ${diagnostic.message ?? 'unknown transport error'}`,
8742
8762
  );
8743
8763
  await options?.parkProviderCall?.();
8744
8764
  await this.sleepWithCheckpointHeartbeat(retryAfterMs);
8745
8765
  return;
8746
8766
  }
8747
8767
  throw new ToolHttpError(
8748
- `Tool ${toolId} transport failed calling ${url} after ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS} attempts: ${message}`,
8768
+ `Tool ${toolId} transport failed calling ${url} after ${transportAttempt}/${TOOL_EXECUTE_TRANSPORT_MAX_ATTEMPTS} attempts: ${diagnostic.message ?? 'unknown transport error'}`,
8749
8769
  null,
8750
8770
  0,
8751
8771
  'repairable',
@@ -8767,9 +8787,12 @@ export class PlayContextImpl {
8767
8787
  : null;
8768
8788
  const providerIdempotencyKey =
8769
8789
  options?.providerIdempotencyKey?.trim() || durableCallReceiptKey;
8790
+ // Every gateway request needs a correlation ID. When the call has
8791
+ // an idempotency key, keep its stable ID across retries; otherwise
8792
+ // give this individual attempt a fresh ID for gateway pairing.
8770
8793
  const deeplineRequestId = providerIdempotencyKey
8771
8794
  ? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
8772
- : null;
8795
+ : `ctx-tool-${crypto.randomUUID()}`;
8773
8796
  // Receipt ownership is a liveness contract, not a one-time check.
8774
8797
  // Keep it alive for the whole provider HTTP request. The cadence
8775
8798
  // comes from the store-issued expiry because a remote runner may
@@ -8837,10 +8860,10 @@ export class PlayContextImpl {
8837
8860
  V2_EXECUTE_RESPONSE_CONTRACT,
8838
8861
  [EXECUTE_RESPONSE_INTENT_HEADER]: 'dataset',
8839
8862
  [EXECUTE_TOOL_METADATA_HEADER]: 'true',
8840
- ...(deeplineRequestId
8863
+ 'x-deepline-request-id': deeplineRequestId,
8864
+ ...(providerIdempotencyKey
8841
8865
  ? {
8842
- 'x-deepline-request-id': deeplineRequestId,
8843
- 'x-deepline-idempotency-key': providerIdempotencyKey!,
8866
+ 'x-deepline-idempotency-key': providerIdempotencyKey,
8844
8867
  }
8845
8868
  : {}),
8846
8869
  ...(await this.vercelProtectionHeaders()),
@@ -8918,11 +8941,15 @@ export class PlayContextImpl {
8918
8941
  `Tool ${toolId} runtime API call timed out after ${timeoutMs}ms.`,
8919
8942
  )
8920
8943
  : error;
8921
- const message =
8922
- transportError instanceof Error
8923
- ? transportError.message
8924
- : String(transportError);
8925
- await retryToolTransportFailure(message);
8944
+ await retryToolTransportFailure({
8945
+ error: transportError,
8946
+ elapsedMs:
8947
+ providerCallStartedAt === null
8948
+ ? 0
8949
+ : Date.now() - providerCallStartedAt,
8950
+ requestId: deeplineRequestId,
8951
+ aborted: abortController?.signal.aborted === true,
8952
+ });
8926
8953
  continue;
8927
8954
  } finally {
8928
8955
  if (timeoutHandle) {
@@ -8941,9 +8968,14 @@ export class PlayContextImpl {
8941
8968
  bodyText: text,
8942
8969
  })
8943
8970
  ) {
8944
- await retryToolTransportFailure(
8945
- 'the Deepline development tunnel returned a branded 502 before reaching the app origin',
8946
- );
8971
+ await retryToolTransportFailure({
8972
+ error: new Error(
8973
+ 'the Deepline development tunnel returned a branded 502 before reaching the app origin',
8974
+ ),
8975
+ elapsedMs: providerCallElapsedMs ?? 0,
8976
+ requestId: deeplineRequestId,
8977
+ aborted: false,
8978
+ });
8947
8979
  continue;
8948
8980
  }
8949
8981
  const authScopeChanged = parseToolExecuteAuthScopeChangedError({
@@ -1,12 +1,6 @@
1
- export type PlayRunLiveStatus =
2
- | 'queued'
3
- | 'running'
4
- | 'completed'
5
- | 'failed'
6
- | 'cancelled'
7
- | 'terminated'
8
- | 'timed_out'
9
- | 'unknown';
1
+ import type { PlayRunLifecycleStatus } from './run-lifecycle-policy';
2
+
3
+ export type PlayRunLiveStatus = PlayRunLifecycleStatus;
10
4
 
11
5
  export type PlayVisualNodeStatus =
12
6
  | 'idle'
@@ -166,6 +166,12 @@ export type PlayRunLedgerEvent =
166
166
  playName?: string | null;
167
167
  runtimeBackend?: string | null;
168
168
  })
169
+ | (PlayRunLedgerBaseEvent & {
170
+ type: 'run.waiting';
171
+ })
172
+ | (PlayRunLedgerBaseEvent & {
173
+ type: 'run.resumed';
174
+ })
169
175
  | (PlayRunLedgerBaseEvent & {
170
176
  type: 'run.completed';
171
177
  result?: unknown;
@@ -1025,6 +1031,16 @@ export function reducePlayRunLedgerEvent(
1025
1031
  if (event.runId !== snapshot.runId) {
1026
1032
  return snapshot;
1027
1033
  }
1034
+ // The scheduler's outbox can replay an older wait transition after its run
1035
+ // reached a terminal state. That transition is no longer meaningful and
1036
+ // must be byte-for-byte inert: even advancing `updatedAt` would re-publish
1037
+ // a stale terminal snapshot to every observer.
1038
+ if (
1039
+ (event.type === 'run.waiting' || event.type === 'run.resumed') &&
1040
+ shouldIgnoreNonTerminalAfterStickyTerminal(snapshot)
1041
+ ) {
1042
+ return snapshot;
1043
+ }
1028
1044
  const occurredAt = Math.max(0, event.occurredAt);
1029
1045
  const base: PlayRunLedgerSnapshot = {
1030
1046
  ...snapshot,
@@ -1062,6 +1078,16 @@ export function reducePlayRunLedgerEvent(
1062
1078
  : 'running',
1063
1079
  startedAt: base.startedAt ?? occurredAt,
1064
1080
  });
1081
+ case 'run.waiting':
1082
+ return withTiming({
1083
+ ...base,
1084
+ status: 'waiting',
1085
+ });
1086
+ case 'run.resumed':
1087
+ return withTiming({
1088
+ ...base,
1089
+ status: 'running',
1090
+ });
1065
1091
  case 'run.completed':
1066
1092
  return (
1067
1093
  conflictingTerminalSnapshot(base, event.type, occurredAt, null, {
@@ -1,6 +1,7 @@
1
1
  export type PlayRunLifecycleStatus =
2
2
  | 'queued'
3
3
  | 'running'
4
+ | 'waiting'
4
5
  | 'completed'
5
6
  | 'failed'
6
7
  | 'cancelled'
@@ -35,8 +36,9 @@ export function normalizePlayRunLifecycleStatus(
35
36
  return 'running';
36
37
  case 'running':
37
38
  case 'started':
38
- case 'waiting':
39
39
  return 'running';
40
+ case 'waiting':
41
+ return 'waiting';
40
42
  case 'completed':
41
43
  case 'complete':
42
44
  case 'succeeded':
@@ -62,7 +64,8 @@ export function isTerminalPlayRunLifecycleStatus(status: unknown): boolean {
62
64
  }
63
65
 
64
66
  export function isActivePlayRunLifecycleStatus(status: unknown): boolean {
65
- return normalizePlayRunLifecycleStatus(status) === 'running';
67
+ const normalized = normalizePlayRunLifecycleStatus(status);
68
+ return normalized === 'running' || normalized === 'waiting';
66
69
  }
67
70
 
68
71
  export function isRecoverableDatasetRowStatus(status: unknown): boolean {