deepline 0.1.207 → 0.1.209

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 (33) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +2 -12
  2. package/dist/bundling-sources/sdk/src/client.ts +10 -0
  3. package/dist/bundling-sources/sdk/src/http.ts +10 -1
  4. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +16 -31
  6. package/dist/bundling-sources/shared_libs/play-runtime/child-execution-strategy.ts +18 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +312 -207
  8. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +15 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/daytona-runtime-config.ts +0 -5
  10. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +1 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +429 -102
  12. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +27 -5
  13. package/dist/bundling-sources/shared_libs/play-runtime/governor/rate-state-backend.ts +18 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +31 -12
  15. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +103 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +105 -10
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +21 -1
  18. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +32 -86
  19. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +141 -2
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-session-execution.ts +45 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +180 -447
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-constants.ts +12 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +33 -1
  24. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
  25. package/dist/bundling-sources/shared_libs/plays/artifact-kind-guard.ts +112 -0
  26. package/dist/cli/index.js +38 -5
  27. package/dist/cli/index.mjs +38 -5
  28. package/dist/index.d.mts +10 -0
  29. package/dist/index.d.ts +10 -0
  30. package/dist/index.js +32 -5
  31. package/dist/index.mjs +32 -5
  32. package/package.json +1 -1
  33. package/dist/bundling-sources/shared_libs/play-runtime/child-run-lifecycle.ts +0 -70
@@ -282,6 +282,15 @@ export function createPlayExecutionGovernor(
282
282
  const budgetState = input.budgetState ?? new InMemoryBudgetStateBackend();
283
283
  const providerByTool = new Map<string, string>();
284
284
 
285
+ // When the rate-state backend owns pacing authoritatively (the Absurd/Node
286
+ // app-runtime Postgres pacer runs the whole token bucket + AIMD in the row),
287
+ // the Governor's in-memory adaptive admission becomes a passthrough: it only
288
+ // SEEDS declared/default rules and never halves/ramps them here. Backpressure
289
+ // is routed solely to `rateState.penalize` so the DB row is the single source
290
+ // of pacing truth. The coordinator and in-memory backends leave `kind`
291
+ // undefined and keep the in-memory adaptive admission (byte-identical).
292
+ const dbAuthoritativePacing = input.rateState.kind === 'app_runtime_postgres';
293
+
285
294
  const bucketId = (provider: string) => `${input.scope.orgId}:${provider}`;
286
295
 
287
296
  async function resolveAdaptivePacing(toolId: string): Promise<{
@@ -291,6 +300,14 @@ export function createPlayExecutionGovernor(
291
300
  const resolvedPacing = await input.resolvePacing(toolId);
292
301
  const declaredPacing =
293
302
  resolvedPacing && resolvedPacing.rules.length > 0 ? resolvedPacing : null;
303
+ if (dbAuthoritativePacing) {
304
+ // Passthrough: hand the DB-backed pacer the declared rules verbatim (or
305
+ // the default-pacing shape when undeclared). No in-memory admission — the
306
+ // row is authoritative.
307
+ const pacing = declaredPacing ?? defaultPacingForTool(toolId, policy);
308
+ providerByTool.set(toolId, pacing.provider);
309
+ return pacing;
310
+ }
294
311
  const pacing = adaptiveAdmission.resolvePacing({
295
312
  orgId: input.scope.orgId,
296
313
  toolId,
@@ -531,11 +548,16 @@ export function createPlayExecutionGovernor(
531
548
  resolveRowConcurrency(policy, requested),
532
549
 
533
550
  reportProviderBackpressure(bp) {
534
- adaptiveAdmission.observeProviderBackpressure({
535
- orgId: input.scope.orgId,
536
- provider: bp.provider,
537
- retryAfterMs: bp.retryAfterMs,
538
- });
551
+ // DB-authoritative pacer: route backpressure ONLY to the row via
552
+ // penalize. The in-memory adaptive admission must not also halve, or the
553
+ // provider would be throttled twice (once in the row, once in memory).
554
+ if (!dbAuthoritativePacing) {
555
+ adaptiveAdmission.observeProviderBackpressure({
556
+ orgId: input.scope.orgId,
557
+ provider: bp.provider,
558
+ retryAfterMs: bp.retryAfterMs,
559
+ });
560
+ }
539
561
  input.rateState.penalize({
540
562
  bucketId: bucketId(bp.provider),
541
563
  cooldownMs: bp.retryAfterMs,
@@ -56,7 +56,25 @@ export interface PacingPermit {
56
56
  release(): void;
57
57
  }
58
58
 
59
+ /**
60
+ * Discriminates the backend so the Governor can adapt its policy to it. Only
61
+ * `app_runtime_postgres` (the Absurd/Node substrate's DB-backed pacer) is
62
+ * db-authoritative: it runs the whole token-bucket + AIMD in Postgres, so the
63
+ * Governor's in-memory adaptive admission must become a passthrough and defer
64
+ * pacing to the row. The in-memory and coordinator backends leave `kind`
65
+ * undefined and keep the Governor's in-memory adaptive admission (byte-identical
66
+ * to today).
67
+ */
68
+ export type RateStateBackendKind = 'app_runtime_postgres';
69
+
59
70
  export interface RateStateBackend {
71
+ /**
72
+ * When present, tells the Governor this backend owns pacing authoritatively.
73
+ * Absent on the in-memory and coordinator backends — those keep the Governor's
74
+ * in-memory adaptive admission.
75
+ */
76
+ readonly kind?: RateStateBackendKind;
77
+
60
78
  /**
61
79
  * Block until one outbound call is permitted for `bucketId` under all `rules`
62
80
  * (request windows always; per-rule `maxConcurrency` only on the in-memory
@@ -34,6 +34,17 @@ export type PlayRunnerRateStateAcquireResult = {
34
34
  waitMs: number;
35
35
  /** One independently-expiring concurrency reservation per granted permit. */
36
36
  leaseIds?: string[];
37
+ /**
38
+ * Absolute epoch-ms until which the pacer's own cooldown holds (internally
39
+ * capped, e.g. 45min probe cadence). Advisory: exposes the current backoff to
40
+ * observers; the client already sleeps on `waitMs`.
41
+ */
42
+ coolUntilMs?: number;
43
+ /**
44
+ * The most recent server Retry-After stored verbatim (uncapped) as an absolute
45
+ * epoch-ms deadline. Purely observational — never gates a grant on its own.
46
+ */
47
+ claimedRetryAtMs?: number;
37
48
  };
38
49
 
39
50
  export type PlayRunnerRateStateReleaseInput = {
@@ -126,9 +137,29 @@ export interface PlayRunnerContextConfig {
126
137
  * path or on a pre-release parent.
127
138
  */
128
139
  absurdReleaseId?: string | null;
140
+ /**
141
+ * Push-execution wiring for the Daytona sandbox runner. When present the
142
+ * runner keeps the Absurd run claim alive itself (heartbeating the receipt
143
+ * gateway on a `leaseSeconds / 3` cadence) instead of the worker babysitting a
144
+ * held `executeCommand` await. Absent for the worker-babysat local-process
145
+ * backend and every non-Daytona runtime, which keep their existing behavior.
146
+ */
147
+ runnerPushExecution?: RunnerPushExecutionConfig | null;
129
148
  governance?: GovernanceSnapshot | null;
130
149
  }
131
150
 
151
+ /**
152
+ * Runner-side push-execution parameters. The receipt gateway resolves the run's
153
+ * queue server-side, so the runner only needs the lease window (to derive the
154
+ * heartbeat cadence) and the run identity it heartbeats.
155
+ */
156
+ export interface RunnerPushExecutionConfig {
157
+ /** Absurd claim lease window in seconds; heartbeat cadence is this / 3. */
158
+ leaseSeconds: number;
159
+ /** The run the runner heartbeats. Must match the executor token's run scope. */
160
+ runId: string;
161
+ }
162
+
132
163
  export interface PlayRunnerExecutionConfig {
133
164
  artifact: PlayBundleArtifact;
134
165
  artifactTransport?: {
@@ -172,15 +203,6 @@ export type PlayRunnerEvent =
172
203
  result: PlayRunnerResult;
173
204
  };
174
205
 
175
- export type PlayRunnerDeferredRuntimeTask = {
176
- kind: 'daytona_sandbox_cleanup';
177
- sandboxId: string;
178
- billingStartedAt: number;
179
- cpu: number;
180
- memoryGiB: number;
181
- diskGiB: number;
182
- };
183
-
184
206
  export type PlayRunnerRuntimeTiming = {
185
207
  backend: 'daytona';
186
208
  daytonaCreateMs?: number;
@@ -202,7 +224,6 @@ export type PlayRunnerResult =
202
224
  inserted?: number;
203
225
  skipped?: number;
204
226
  runtimeTiming?: PlayRunnerRuntimeTiming;
205
- deferredRuntimeTasks?: PlayRunnerDeferredRuntimeTask[];
206
227
  }
207
228
  | {
208
229
  status: 'suspended';
@@ -216,7 +237,6 @@ export type PlayRunnerResult =
216
237
  inserted?: number;
217
238
  skipped?: number;
218
239
  runtimeTiming?: PlayRunnerRuntimeTiming;
219
- deferredRuntimeTasks?: PlayRunnerDeferredRuntimeTask[];
220
240
  }
221
241
  | {
222
242
  status: 'failed';
@@ -230,5 +250,4 @@ export type PlayRunnerResult =
230
250
  inserted?: number;
231
251
  skipped?: number;
232
252
  runtimeTiming?: PlayRunnerRuntimeTiming;
233
- deferredRuntimeTasks?: PlayRunnerDeferredRuntimeTask[];
234
253
  };
@@ -44,6 +44,89 @@ export class WorkspaceStorageNotReadyError extends Error {
44
44
  }
45
45
  }
46
46
 
47
+ export const PROVIDER_EXHAUSTED_CODE = 'PROVIDER_EXHAUSTED';
48
+
49
+ /**
50
+ * Format an absolute epoch-ms retry deadline as a human UTC string for the
51
+ * user-facing PROVIDER_EXHAUSTED message. Contains no provider internals.
52
+ */
53
+ export function formatProviderRetryAtUtc(retryAtMs: number): string {
54
+ const millis = Number(retryAtMs);
55
+ if (!Number.isFinite(millis)) {
56
+ return 'an unknown time';
57
+ }
58
+ return new Date(millis).toUTCString();
59
+ }
60
+
61
+ /**
62
+ * Build the user-facing PROVIDER_EXHAUSTED message. Names only the provider and
63
+ * the retry time — never Deepline provider spend, SQL, or tenant internals — so
64
+ * it is safe to surface verbatim past redaction.
65
+ */
66
+ export function providerExhaustedMessage(input: {
67
+ provider: string;
68
+ retryAtMs: number;
69
+ }): string {
70
+ return (
71
+ `${input.provider} is exhausted and asked us to retry after ${formatProviderRetryAtUtc(input.retryAtMs)}. ` +
72
+ 'This call was skipped with no spend. ' +
73
+ 'For higher provider throughput guarantees, talk to us about an enterprise plan.'
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Thrown by the DB-authoritative pacer (`app_runtime_postgres` rate-state
79
+ * backend) when a provider's hold exceeds the tolerable wait
80
+ * (PROVIDER_EXHAUSTED_MAX_WAIT_MS). The call is skipped BEFORE any tool
81
+ * execution request, so nothing is dispatched and nothing is billed. Row-failure
82
+ * isolation still applies: this is a row/step outcome, not a run-fatal error.
83
+ */
84
+ export class ProviderExhaustedError extends Error {
85
+ readonly code = PROVIDER_EXHAUSTED_CODE;
86
+ readonly provider: string;
87
+ /** ISO 8601 retry deadline (from the server Retry-After / cooldown). */
88
+ readonly retryAt: string;
89
+ constructor(input: {
90
+ provider: string;
91
+ retryAtMs: number;
92
+ cause?: unknown;
93
+ }) {
94
+ // Prefix the code token so `String(error)` and any persisted error string
95
+ // carry it across the serialization boundary back to the CLI, exactly like
96
+ // WorkspaceStorageNotReadyError.
97
+ super(
98
+ `${PROVIDER_EXHAUSTED_CODE}: ${providerExhaustedMessage({
99
+ provider: input.provider,
100
+ retryAtMs: input.retryAtMs,
101
+ })}`,
102
+ );
103
+ this.name = 'ProviderExhaustedError';
104
+ this.provider = input.provider;
105
+ this.retryAt = Number.isFinite(Number(input.retryAtMs))
106
+ ? new Date(Number(input.retryAtMs)).toISOString()
107
+ : new Date(0).toISOString();
108
+ if (input.cause !== undefined) {
109
+ (this as { cause?: unknown }).cause = input.cause;
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Derive the human provider name for a PROVIDER_EXHAUSTED error from a pacing
116
+ * bucketId. Bucket ids are `${orgId}:${provider}`; the provider itself is
117
+ * `tool:${toolId}` for undeclared tools (see `defaultPacingForTool`). Strip the
118
+ * org prefix, then unwrap a `tool:` prefix down to the toolId so the message
119
+ * names something the user recognizes.
120
+ */
121
+ export function providerNameFromBucketId(bucketId: string): string {
122
+ const separator = bucketId.indexOf(':');
123
+ const provider =
124
+ separator >= 0 ? bucketId.slice(separator + 1) : bucketId;
125
+ return provider.startsWith('tool:')
126
+ ? provider.slice('tool:'.length)
127
+ : provider;
128
+ }
129
+
47
130
  export type PlayRunFailureDetails = {
48
131
  code: string;
49
132
  phase: string;
@@ -100,6 +183,26 @@ export function normalizePlayRunFailure(error: unknown): PlayRunFailureDetails {
100
183
  cause,
101
184
  };
102
185
  }
186
+ if (
187
+ error instanceof ProviderExhaustedError ||
188
+ /\bPROVIDER_EXHAUSTED\b/.test(cause)
189
+ ) {
190
+ // The human message (provider + retry time) already lives in `cause` after
191
+ // the code token prefix; surface it verbatim minus the token so the run
192
+ // terminal reads cleanly. Contains no provider spend or tenant internals by
193
+ // construction (see providerExhaustedMessage).
194
+ const stripped = cause.replace(
195
+ new RegExp(`^${PROVIDER_EXHAUSTED_CODE}:\\s*`),
196
+ '',
197
+ );
198
+ return {
199
+ code: PROVIDER_EXHAUSTED_CODE,
200
+ phase: 'runtime',
201
+ message: stripped,
202
+ retryable: false,
203
+ cause,
204
+ };
205
+ }
103
206
  if (
104
207
  error instanceof WorkspaceStorageNotReadyError ||
105
208
  /\bWORKSPACE_STORAGE_NOT_READY\b/.test(cause)
@@ -316,6 +316,58 @@ export function isTerminalPlayRunLedgerStatus(
316
316
  return TERMINAL_STATUSES.has(status);
317
317
  }
318
318
 
319
+ /**
320
+ * Aggregate, row-level outcome of a run derived purely from the settled step
321
+ * progress. This is an ADDITIVE truthfulness signal, NOT a status: a run can be
322
+ * `status: 'completed'` (all steps ran to the end) while `failedRows > 0`
323
+ * because row-failure isolation persists failed rows for retry instead of
324
+ * failing the whole run. Readers use `hasRowFailures` to render a truthful
325
+ * "completed with failures" surface without inventing a new public run status.
326
+ *
327
+ * `completedRows` here is the reconciled successes-only count summed from the
328
+ * producer's final step progress (`completed`), which is the durable/persisted
329
+ * success count — NOT a live in-flight estimate. It is what should be compared
330
+ * against persisted dataset rows; the two diverging was the 21-vs-15 symptom of
331
+ * children being lost before their rows settled.
332
+ */
333
+ export type PlayRunRowOutcomeSummary = {
334
+ completedRows: number;
335
+ failedRows: number;
336
+ totalRows: number;
337
+ hasRowFailures: boolean;
338
+ };
339
+
340
+ export function summarizeRunRowOutcomes(
341
+ snapshot: Pick<PlayRunLedgerSnapshot, 'stepsById'>,
342
+ ): PlayRunRowOutcomeSummary {
343
+ let completedRows = 0;
344
+ let failedRows = 0;
345
+ let totalRows = 0;
346
+ for (const step of Object.values(snapshot.stepsById)) {
347
+ const progress = step.progress;
348
+ if (!progress) continue;
349
+ // `completed` is successes-only; `failed` is settled-but-failed rows. Sum
350
+ // both across every map/step so a multi-map play reports the whole run's
351
+ // row outcome, not just the last step.
352
+ completedRows += Math.max(0, finiteNumber(progress.completed) ?? 0);
353
+ failedRows += Math.max(0, finiteNumber(progress.failed) ?? 0);
354
+ // Prefer the settled denominator; fall back to completed+failed when a
355
+ // producer never stamped `total` (per the terminal invariant those agree).
356
+ const stepTotal = finiteNumber(progress.total);
357
+ totalRows +=
358
+ stepTotal !== null && stepTotal >= 0
359
+ ? stepTotal
360
+ : Math.max(0, finiteNumber(progress.completed) ?? 0) +
361
+ Math.max(0, finiteNumber(progress.failed) ?? 0);
362
+ }
363
+ return {
364
+ completedRows,
365
+ failedRows,
366
+ totalRows,
367
+ hasRowFailures: failedRows > 0,
368
+ };
369
+ }
370
+
319
371
  export function createEmptyPlayRunLedgerSnapshot(input: {
320
372
  runId: string;
321
373
  playName?: string | null;
@@ -529,6 +581,32 @@ function mergeMonotonicStepProgress(input: {
529
581
  };
530
582
  }
531
583
 
584
+ /**
585
+ * Zero the in-flight row accounting on a settled step. `activeRows` (calls in
586
+ * flight) and `waitingRows` (rows queued behind rate/concurrency limits) are
587
+ * live-only counters: once a step is terminal nothing is in flight or waiting,
588
+ * so leaving the last observed values renders a contradiction like
589
+ * "status: completed · 1 in flight · waiting 29". This preserves the settled
590
+ * counts (completed/failed/total/completedRows/startedRows) and only clears the
591
+ * two live accumulators. Idempotent: dropping already-absent fields is a no-op.
592
+ */
593
+ function clearInFlightProgressOnTerminal(
594
+ progress: PlayRunLedgerStepProgress | null | undefined,
595
+ ): PlayRunLedgerStepProgress | null | undefined {
596
+ if (!progress) return progress;
597
+ if (progress.activeRows === undefined && progress.waitingRows === undefined) {
598
+ return progress;
599
+ }
600
+ const cleared = { ...progress };
601
+ delete cleared.activeRows;
602
+ delete cleared.waitingRows;
603
+ return cleared;
604
+ }
605
+
606
+ function isTerminalStepStatus(status: PlayRunLedgerStepStatus): boolean {
607
+ return status === 'completed' || status === 'failed' || status === 'skipped';
608
+ }
609
+
532
610
  function normalizeStepStatus(value: unknown): PlayRunLedgerStepStatus | null {
533
611
  const normalized = String(value ?? '')
534
612
  .trim()
@@ -838,11 +916,11 @@ function settleRunningStepsOnTerminal(
838
916
  completedAt: step.completedAt ?? occurredAt,
839
917
  updatedAt: occurredAt,
840
918
  progress: step.progress
841
- ? {
919
+ ? clearInFlightProgressOnTerminal({
842
920
  ...step.progress,
843
921
  completedAt: step.progress.completedAt ?? occurredAt,
844
922
  updatedAt: occurredAt,
845
- }
923
+ })
846
924
  : step.progress,
847
925
  },
848
926
  ];
@@ -1043,19 +1121,30 @@ export function reducePlayRunLedgerEvent(
1043
1121
  case 'step.progress': {
1044
1122
  const current = base.stepsById[event.stepId];
1045
1123
  const runTerminal = isTerminalPlayRunLedgerStatus(base.status);
1046
- const progress = {
1124
+ const mergedProgress = {
1047
1125
  ...mergeMonotonicStepProgress({
1048
1126
  current: current?.progress,
1049
1127
  next: event.progress,
1050
1128
  }),
1051
1129
  updatedAt: event.progress.updatedAt ?? occurredAt,
1052
1130
  };
1131
+ // An explicit `completedAt` on THIS event is the producer's authoritative
1132
+ // completion signal and always settles the step. Absent that, the
1133
+ // `completed >= total` heuristic is a stale-estimate trap while rows are
1134
+ // still in flight (total can shrink below the in-flight count under child
1135
+ // fan-out), so it must NOT infer completion while active rows remain.
1136
+ const eventSignalsCompletion =
1137
+ typeof event.progress.completedAt === 'number';
1138
+ const hasActiveRows =
1139
+ typeof mergedProgress.activeRows === 'number' &&
1140
+ mergedProgress.activeRows > 0;
1053
1141
  const inferredStatus =
1054
- typeof progress.completedAt === 'number' ||
1055
- (typeof progress.total === 'number' &&
1056
- typeof progress.completed === 'number' &&
1057
- progress.total > 0 &&
1058
- progress.completed >= progress.total)
1142
+ eventSignalsCompletion ||
1143
+ (!hasActiveRows &&
1144
+ typeof mergedProgress.total === 'number' &&
1145
+ typeof mergedProgress.completed === 'number' &&
1146
+ mergedProgress.total > 0 &&
1147
+ mergedProgress.completed >= mergedProgress.total)
1059
1148
  ? 'completed'
1060
1149
  : 'running';
1061
1150
  const status =
@@ -1085,6 +1174,11 @@ export function reducePlayRunLedgerEvent(
1085
1174
  (status === 'completed' || status === 'failed'
1086
1175
  ? (completedAt ?? event.progress.updatedAt ?? occurredAt)
1087
1176
  : null);
1177
+ // Once the step is settled, drop the live in-flight/waiting accumulators
1178
+ // so a terminal node never reports active or waiting rows.
1179
+ const progress = isTerminalStepStatus(status)
1180
+ ? clearInFlightProgressOnTerminal(mergedProgress)
1181
+ : mergedProgress;
1088
1182
  const nextStep: PlayRunLedgerStepSnapshot = {
1089
1183
  ...(current ?? { stepId: event.stepId, status }),
1090
1184
  stepId: event.stepId,
@@ -1092,7 +1186,7 @@ export function reducePlayRunLedgerEvent(
1092
1186
  kind: event.kind ?? current?.kind,
1093
1187
  status,
1094
1188
  artifactTableNamespace:
1095
- progress.artifactTableNamespace ??
1189
+ progress?.artifactTableNamespace ??
1096
1190
  current?.artifactTableNamespace ??
1097
1191
  null,
1098
1192
  startedAt,
@@ -1155,7 +1249,8 @@ export function reducePlayRunLedgerEvent(
1155
1249
  : null),
1156
1250
  completedAt,
1157
1251
  updatedAt: occurredAt,
1158
- progress: current?.progress ?? null,
1252
+ // Terminal step: clear live in-flight/waiting accounting.
1253
+ progress: clearInFlightProgressOnTerminal(current?.progress) ?? null,
1159
1254
  };
1160
1255
  const runningStepIds = base.orderedStepIds.filter((stepId) => {
1161
1256
  if (stepId === event.stepId) return false;
@@ -2,8 +2,10 @@ import { resolveTimingWindow } from './live-events';
2
2
  import {
3
3
  LOG_TAIL_LIMIT,
4
4
  normalizePlayRunLedgerSnapshot,
5
+ summarizeRunRowOutcomes,
5
6
  type PlayRunLedgerSnapshot,
6
7
  type PlayRunLedgerStepSnapshot,
8
+ type PlayRunRowOutcomeSummary,
7
9
  } from './run-ledger';
8
10
  import {
9
11
  isActivePlayRunLifecycleStatus,
@@ -76,6 +78,14 @@ export type PlayRunLiveSnapshot = {
76
78
  resultTableNamespace: string | null;
77
79
  nodeStates: PlayRunStreamNodeState[];
78
80
  activeNodeId: string | null;
81
+ /**
82
+ * Additive, terminal-only aggregate of row outcomes across all steps. Present
83
+ * once the run is terminal so readers can render a truthful "completed with
84
+ * failures" surface (row-failure isolation persists failed rows for retry, so
85
+ * a run legitimately reports `status: 'completed'` with `rowOutcomes.failedRows
86
+ * > 0`). Absent on non-terminal snapshots and on runs with no row/map steps.
87
+ */
88
+ rowOutcomes?: PlayRunRowOutcomeSummary;
79
89
  };
80
90
 
81
91
  export function normalizePlayRunLiveStatus(value: unknown): PlayRunLiveStatus {
@@ -179,9 +189,18 @@ function buildSnapshotFromLedger(
179
189
  completedAt: step.completedAt ?? null,
180
190
  updatedAt: step.updatedAt ?? null,
181
191
  }));
192
+ const liveStatus = normalizePlayRunLiveStatus(snapshot.status);
193
+ // Only surface the row-outcome aggregate once the run is terminal and it
194
+ // actually observed row/map steps — mid-flight `failed` counts churn, and a
195
+ // no-map play has no meaningful row outcome to report.
196
+ const rowOutcomes =
197
+ isTerminalPlayRunLiveStatus(liveStatus) &&
198
+ Object.keys(snapshot.stepsById).length > 0
199
+ ? summarizeRunRowOutcomes(snapshot)
200
+ : null;
182
201
  return {
183
202
  runId: snapshot.runId,
184
- status: normalizePlayRunLiveStatus(snapshot.status),
203
+ status: liveStatus,
185
204
  createdAt: snapshot.createdAt ?? null,
186
205
  startedAt: snapshot.startedAt ?? null,
187
206
  finishedAt: snapshot.finishedAt ?? null,
@@ -195,6 +214,7 @@ function buildSnapshotFromLedger(
195
214
  resultTableNamespace: snapshot.resultTableNamespace ?? null,
196
215
  nodeStates,
197
216
  activeNodeId: snapshot.activeStepId ?? null,
217
+ ...(rowOutcomes ? { rowOutcomes } : {}),
198
218
  };
199
219
  }
200
220
 
@@ -1,10 +1,8 @@
1
1
  import type { Daytona } from '@daytonaio/sdk';
2
2
  import type {
3
- PlayRunnerDeferredRuntimeTask,
4
3
  PlayRunnerExecutionConfig,
5
4
  PlayRunnerResult,
6
5
  } from '@shared_libs/play-runtime/protocol';
7
- import { shouldCleanupSandboxInBackground } from '@shared_libs/play-runtime/daytona-runtime-config';
8
6
  import { isIsolatedRuntimeSchedulerSchema } from '@shared_libs/play-runtime/runtime-scheduler-topology';
9
7
 
10
8
  const DAYTONA_CREATE_TIMEOUT_SECONDS = 10;
@@ -337,19 +335,19 @@ export function createOneShotDaytonaSandboxLifecycle(input: {
337
335
  };
338
336
  }
339
337
 
340
- function attachDeferredDaytonaCleanup(
341
- result: PlayRunnerResult,
342
- task: PlayRunnerDeferredRuntimeTask | null,
343
- ): PlayRunnerResult {
344
- if (!task) {
345
- return result;
346
- }
347
- return {
348
- ...result,
349
- deferredRuntimeTasks: [...(result.deferredRuntimeTasks ?? []), task],
350
- };
351
- }
352
-
338
+ /**
339
+ * Deterministic sandbox delete on ALL backend exits (FIX 1). The old shape
340
+ * attached `deferredRuntimeTasks` cleanup records that NOTHING consumed, gated
341
+ * eager deletion behind a `DAYTONA_BACKGROUND_CLEANUP` env that was unset
342
+ * everywhere — every run leaked paid idle compute until Daytona's autoStop
343
+ * fired. Now every exit path deletes directly (fire-and-forget with structured
344
+ * logs; a failed delete never throws into the result path). autoStop remains
345
+ * only the backstop for a crashed worker process.
346
+ *
347
+ * Push execution note: a `detached_runner` suspended return deliberately
348
+ * BYPASSES `withCleanup` — the runner is still executing in the sandbox; the
349
+ * worker's wake leg owns that delete.
350
+ */
353
351
  export function createDaytonaSandboxCleanupManager(): {
354
352
  activate(acquired: AcquiredDaytonaSandbox): void;
355
353
  currentSandbox(): DaytonaSandbox | null;
@@ -362,49 +360,31 @@ export function createDaytonaSandboxCleanupManager(): {
362
360
  } {
363
361
  let sandbox: DaytonaSandbox | null = null;
364
362
  let sandboxBillingStartedAt: number | null = null;
365
- const deferredCleanupTasksFromPreviousAttempts: PlayRunnerDeferredRuntimeTask[] =
366
- [];
367
- const backgroundCleanupSandboxIds = new Set<string>();
368
-
369
- const deferredCleanupTask = (): PlayRunnerDeferredRuntimeTask | null => {
370
- if (!sandbox || sandboxBillingStartedAt === null) {
371
- return null;
372
- }
373
- return {
374
- kind: 'daytona_sandbox_cleanup',
375
- sandboxId: sandbox.id,
376
- billingStartedAt: sandboxBillingStartedAt,
377
- cpu: typeof sandbox.cpu === 'number' ? sandbox.cpu : 1,
378
- memoryGiB: typeof sandbox.memory === 'number' ? sandbox.memory : 1,
379
- diskGiB: typeof sandbox.disk === 'number' ? sandbox.disk : 0,
380
- };
381
- };
363
+ const deletedSandboxIds = new Set<string>();
382
364
 
383
365
  const deleteSandboxOnce = (input: {
384
366
  reason: string;
385
- task: PlayRunnerDeferredRuntimeTask | null;
386
367
  sandboxToDelete: DaytonaSandbox | null;
387
368
  }) => {
388
- if (
389
- !input.task ||
390
- !input.sandboxToDelete ||
391
- backgroundCleanupSandboxIds.has(input.task.sandboxId)
392
- ) {
369
+ const target = input.sandboxToDelete;
370
+ if (!target || deletedSandboxIds.has(target.id)) {
393
371
  return false;
394
372
  }
395
- backgroundCleanupSandboxIds.add(input.task.sandboxId);
396
- void input.sandboxToDelete.delete(30).then(
373
+ deletedSandboxIds.add(target.id);
374
+ const billingStartedAt = sandboxBillingStartedAt;
375
+ void target.delete(30).then(
397
376
  () => {
398
377
  console.info('[play-runner.daytona.cleanup_done]', {
399
378
  reason: input.reason,
400
- sandboxId: input.task!.sandboxId,
401
- elapsedMs: Date.now() - input.task!.billingStartedAt,
379
+ sandboxId: target.id,
380
+ elapsedMs:
381
+ billingStartedAt === null ? null : Date.now() - billingStartedAt,
402
382
  });
403
383
  },
404
384
  (error: unknown) => {
405
385
  console.warn('[play-runner.daytona.cleanup_failed]', {
406
386
  reason: input.reason,
407
- sandboxId: input.task!.sandboxId,
387
+ sandboxId: target.id,
408
388
  error: error instanceof Error ? error.message : String(error),
409
389
  });
410
390
  },
@@ -412,25 +392,6 @@ export function createDaytonaSandboxCleanupManager(): {
412
392
  return true;
413
393
  };
414
394
 
415
- const scheduleBackgroundCleanup = (
416
- task: PlayRunnerDeferredRuntimeTask | null,
417
- sandboxToDelete: DaytonaSandbox | null,
418
- ) => {
419
- if (
420
- !shouldCleanupSandboxInBackground() ||
421
- !task ||
422
- !sandboxToDelete ||
423
- backgroundCleanupSandboxIds.has(task.sandboxId)
424
- ) {
425
- return false;
426
- }
427
- return deleteSandboxOnce({
428
- reason: 'background',
429
- task,
430
- sandboxToDelete,
431
- });
432
- };
433
-
434
395
  return {
435
396
  activate(acquired) {
436
397
  sandbox = acquired.sandbox;
@@ -442,39 +403,24 @@ export function createDaytonaSandboxCleanupManager(): {
442
403
  cleanupActiveSandboxForCancellation() {
443
404
  return deleteSandboxOnce({
444
405
  reason: 'cancelled',
445
- task: deferredCleanupTask(),
446
406
  sandboxToDelete: sandbox,
447
407
  });
448
408
  },
449
409
  stashActiveSandboxForRetry() {
450
- const task = deferredCleanupTask();
451
- const sandboxToDelete = sandbox;
452
- if (!scheduleBackgroundCleanup(task, sandboxToDelete) && task) {
453
- deferredCleanupTasksFromPreviousAttempts.push(task);
454
- }
410
+ // The retired sandbox is dead — a fresh one replaces it for the retry.
411
+ // Delete NOW instead of deferring to a consumer that never existed.
412
+ deleteSandboxOnce({
413
+ reason: 'retired_for_retry',
414
+ sandboxToDelete: sandbox,
415
+ });
455
416
  sandbox = null;
456
417
  sandboxBillingStartedAt = null;
457
418
  },
458
419
  withCleanup(result, input) {
459
- const resultWithPreviousCleanup =
460
- deferredCleanupTasksFromPreviousAttempts.length === 0
461
- ? result
462
- : {
463
- ...result,
464
- deferredRuntimeTasks: [
465
- ...deferredCleanupTasksFromPreviousAttempts,
466
- ...(result.deferredRuntimeTasks ?? []),
467
- ],
468
- };
469
- const task = deferredCleanupTask();
470
- const sandboxToDelete = sandbox;
471
- if (input.cancellationCleanupStarted) {
472
- return resultWithPreviousCleanup;
473
- }
474
- if (scheduleBackgroundCleanup(task, sandboxToDelete)) {
475
- return resultWithPreviousCleanup;
420
+ if (!input.cancellationCleanupStarted) {
421
+ deleteSandboxOnce({ reason: 'terminal', sandboxToDelete: sandbox });
476
422
  }
477
- return attachDeferredDaytonaCleanup(resultWithPreviousCleanup, task);
423
+ return result;
478
424
  },
479
425
  };
480
426
  }