deepline 0.1.179 → 0.1.181

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.
@@ -78,6 +78,13 @@ import {
78
78
  type DurableReceiptExecutionStore,
79
79
  } from './durable-receipt-execution';
80
80
  import { isRowIsolationExemptError } from './row-isolation';
81
+ import {
82
+ createRuntimePersistenceLatch,
83
+ PROVIDER_DISPATCH_WAVE_SIZE,
84
+ RuntimePersistenceCircuitOpenError,
85
+ tripRuntimePersistenceLatch,
86
+ type RuntimePersistenceLatch,
87
+ } from './persistence-latch';
81
88
  import { createRuntimeDatasetId } from './dataset-id';
82
89
  import { dedupeExplicitMapKeyRows } from './map-row-identity';
83
90
  import {
@@ -712,6 +719,16 @@ export class PlayContextImpl {
712
719
  createSecretRedactionContext();
713
720
  private mapInvocationIndex = 0;
714
721
  private readonly stepCallIndexByKey = new Map<string, number>();
722
+ /**
723
+ * Runtime persistence-failure circuit breaker (postgres_fast parity with the
724
+ * Workers runtime). The first persistence failure — a receipt-completion
725
+ * failure after a successful tool call, a receipt-failure write failure, or a
726
+ * map-row sheet flush failure — trips this latch; the dispatch loops then stop
727
+ * dispatching NEW provider calls so a dead tenant DB cannot keep billing calls
728
+ * that have nowhere durable to land.
729
+ */
730
+ private readonly persistenceLatch: RuntimePersistenceLatch =
731
+ createRuntimePersistenceLatch();
715
732
  /**
716
733
  * The single source of every concurrency, budget, and pacing decision for this
717
734
  * run-attempt. Tool/play/row slots are blocking semaphores; budgets are charged
@@ -1722,8 +1739,10 @@ export class PlayContextImpl {
1722
1739
  receiptKey,
1723
1740
  }),
1724
1741
  });
1725
- const completed = receiptKey
1726
- ? (
1742
+ let completed: RuntimeStepReceipt | null | undefined = null;
1743
+ if (receiptKey) {
1744
+ try {
1745
+ completed = (
1727
1746
  await this.completeRuntimeStepReceipts([
1728
1747
  {
1729
1748
  key: receiptKey,
@@ -1731,8 +1750,14 @@ export class PlayContextImpl {
1731
1750
  output: serializeToolExecuteResult(wrapped),
1732
1751
  },
1733
1752
  ])
1734
- ).get(receiptKey)
1735
- : null;
1753
+ ).get(receiptKey);
1754
+ } catch (receiptError) {
1755
+ // Completion failed AFTER a successful (billed) tool call: trip the
1756
+ // breaker so remaining rows stop dispatching new provider calls.
1757
+ tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
1758
+ throw receiptError;
1759
+ }
1760
+ }
1736
1761
  return await this.finalizeResolvedToolCall(
1737
1762
  toolId,
1738
1763
  request,
@@ -1781,10 +1806,19 @@ export class PlayContextImpl {
1781
1806
  ]
1782
1807
  : [];
1783
1808
  });
1784
- const completedByKey =
1785
- receiptInputs.length > 0
1786
- ? await this.completeRuntimeStepReceipts(receiptInputs)
1787
- : new Map<string, RuntimeStepReceipt>();
1809
+ let completedByKey: Map<string, RuntimeStepReceipt>;
1810
+ if (receiptInputs.length > 0) {
1811
+ try {
1812
+ completedByKey = await this.completeRuntimeStepReceipts(receiptInputs);
1813
+ } catch (receiptError) {
1814
+ // Completion failed AFTER successful (billed) tool calls: trip the
1815
+ // breaker so remaining rows stop dispatching new provider calls.
1816
+ tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
1817
+ throw receiptError;
1818
+ }
1819
+ } else {
1820
+ completedByKey = new Map<string, RuntimeStepReceipt>();
1821
+ }
1788
1822
  const results: unknown[] = [];
1789
1823
  for (const entry of wrappedEntries) {
1790
1824
  const receiptKey = entry.request.receiptKey?.trim() || null;
@@ -1875,6 +1909,7 @@ export class PlayContextImpl {
1875
1909
  },
1876
1910
  ]);
1877
1911
  } catch (receiptError) {
1912
+ tripRuntimePersistenceLatch(this.persistenceLatch, receiptError);
1878
1913
  rejectionError = new AggregateError(
1879
1914
  [error, receiptError],
1880
1915
  'Tool call failed and durable receipt could not be marked failed',
@@ -2285,17 +2320,24 @@ export class PlayContextImpl {
2285
2320
  let chunkBytes = 0;
2286
2321
  const flushChunk = async () => {
2287
2322
  if (chunk.length === 0) return;
2288
- await this.#options.onMapRowsCompleted!({
2289
- playName: this.#options.playName,
2290
- playId: this.#options.playId,
2291
- runId: this.#options.runId,
2292
- tableNamespace: resolvedTableNamespace,
2293
- rows: chunk,
2294
- outputFields: datasetColumnNames.filter((field) =>
2295
- shouldPersistMapCellField(field),
2296
- ),
2297
- staticPipeline: this.#options.staticPipeline ?? null,
2298
- });
2323
+ try {
2324
+ await this.#options.onMapRowsCompleted!({
2325
+ playName: this.#options.playName,
2326
+ playId: this.#options.playId,
2327
+ runId: this.#options.runId,
2328
+ tableNamespace: resolvedTableNamespace,
2329
+ rows: chunk,
2330
+ outputFields: datasetColumnNames.filter((field) =>
2331
+ shouldPersistMapCellField(field),
2332
+ ),
2333
+ staticPipeline: this.#options.staticPipeline ?? null,
2334
+ });
2335
+ } catch (error) {
2336
+ // Output-sheet flush failed: trip the breaker so the dispatch loops
2337
+ // stop dispatching new provider calls for the rest of this map.
2338
+ tripRuntimePersistenceLatch(this.persistenceLatch, error);
2339
+ throw error;
2340
+ }
2299
2341
  chunk = [];
2300
2342
  chunkBytes = 0;
2301
2343
  };
@@ -4742,8 +4784,23 @@ export class PlayContextImpl {
4742
4784
  await executeChunkedRequests({
4743
4785
  requests: compiledBatches,
4744
4786
  batchSize,
4745
- execute: async (batch) =>
4746
- await this.callToolAPI(
4787
+ // Bounded dispatch wave (postgres_fast parity with workers_edge):
4788
+ // cap the provider calls (counted by batch member size) a single
4789
+ // chunk dispatches before the latch is read again, so a
4790
+ // persistence failure prevents the rest of a large batch group.
4791
+ maxChunkWeight: PROVIDER_DISPATCH_WAVE_SIZE,
4792
+ weightOf: (batch) => batch.memberRequests.length,
4793
+ execute: async (batch) => {
4794
+ // Circuit breaker: skip dispatching this batch's provider call
4795
+ // once a persistence failure has occurred in this run.
4796
+ if (this.persistenceLatch.tripped) {
4797
+ this.persistenceLatch.preventedCallCount +=
4798
+ batch.memberRequests.length;
4799
+ throw new RuntimePersistenceCircuitOpenError(
4800
+ this.persistenceLatch,
4801
+ );
4802
+ }
4803
+ return await this.callToolAPI(
4747
4804
  batch.batchOperation,
4748
4805
  batch.batchPayload,
4749
4806
  {
@@ -4751,7 +4808,8 @@ export class PlayContextImpl {
4751
4808
  batch.memberRequests,
4752
4809
  ),
4753
4810
  },
4754
- ),
4811
+ );
4812
+ },
4755
4813
  onChunkComplete: async (chunkResults) => {
4756
4814
  for (const entry of chunkResults) {
4757
4815
  if (entry.error !== undefined) {
@@ -4790,9 +4848,13 @@ export class PlayContextImpl {
4790
4848
  },
4791
4849
  });
4792
4850
  } else {
4793
- const batchSize = await this.governor.suggestedParallelism(
4794
- toolId,
4795
- 50,
4851
+ // Bounded dispatch wave (postgres_fast parity with workers_edge):
4852
+ // an unbatched tool group can carry a whole chunk's rows, so cap the
4853
+ // provider calls dispatched before the latch is read again to the
4854
+ // wave size. Respect a lower provider-pacing suggestion when present.
4855
+ const batchSize = Math.min(
4856
+ await this.governor.suggestedParallelism(toolId, 50),
4857
+ PROVIDER_DISPATCH_WAVE_SIZE,
4796
4858
  );
4797
4859
  for (
4798
4860
  let start = 0;
@@ -4802,6 +4864,18 @@ export class PlayContextImpl {
4802
4864
  const chunk = pendingRequests.slice(start, start + batchSize);
4803
4865
  await Promise.allSettled(
4804
4866
  chunk.map(async (request) => {
4867
+ // Circuit breaker: do not dispatch this provider call once a
4868
+ // persistence failure has occurred in this run.
4869
+ if (this.persistenceLatch.tripped) {
4870
+ this.persistenceLatch.preventedCallCount += 1;
4871
+ await rejectWithLiveFollowers(
4872
+ request,
4873
+ new RuntimePersistenceCircuitOpenError(
4874
+ this.persistenceLatch,
4875
+ ),
4876
+ );
4877
+ return;
4878
+ }
4805
4879
  try {
4806
4880
  const execution = await this.callToolExecutionAPI(
4807
4881
  toolId,
@@ -3,8 +3,13 @@ import type {
3
3
  PostgresUrlEncryptionRequest,
4
4
  } from './db-session-crypto';
5
5
 
6
- export const DB_SESSION_DEFAULT_TTL_SECONDS = 10 * 60;
7
- export const DB_SESSION_MAX_TTL_SECONDS = 30 * 60;
6
+ // Workflow DB sessions must not expire before the workflow run/retry state they
7
+ // pair with. Run/retry state lives 60 min (WORKFLOW_RUN_STATE_TTL_MS in the
8
+ // coordinator), so these are aligned to 60 min. Raising the default WITHOUT
9
+ // raising the max would leave the coordinator DO clamp / request validator
10
+ // silently capping the session at 30 min, so both move together.
11
+ export const DB_SESSION_DEFAULT_TTL_SECONDS = 60 * 60;
12
+ export const DB_SESSION_MAX_TTL_SECONDS = 60 * 60;
8
13
 
9
14
  export const DB_SESSION_OPERATIONS = [
10
15
  'rows.read',
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Runtime persistence-failure circuit breaker.
3
+ *
4
+ * When a runtime persistence write fails mid-map (a work-receipt claim/complete
5
+ * or an output-sheet flush against the tenant Postgres), continuing to dispatch
6
+ * NEW provider calls only widens the "billed but not durable" gap: every extra
7
+ * call bills server-side yet has nowhere to land. The latch trips on the first
8
+ * such failure and is checked by the worker dispatch loops before they dispatch
9
+ * any further provider call, converting "all billed, half persisted" into
10
+ * "billed ≈ persisted + small in-flight window".
11
+ *
12
+ * The latch is a plain mutable object shared across all row workers of a map
13
+ * (single isolate, cooperative scheduling — no locking needed).
14
+ */
15
+ export type RuntimePersistenceLatch = {
16
+ /** True once the first runtime persistence failure has been observed. */
17
+ tripped: boolean;
18
+ /** Formatted first-failure message (first trip wins). */
19
+ cause: string | null;
20
+ /** Provider calls NOT dispatched because the latch was already tripped. */
21
+ preventedCallCount: number;
22
+ /**
23
+ * True once a full completeReceipt retry ladder has been exhausted in this
24
+ * map. Bookkeeping only: the shorten decision now keys on `tripped` (see
25
+ * `shouldShortenCompleteReceiptLadder`) so fresh entrants collapse as soon as
26
+ * a persistence failure is confirmed rather than only after the first ladder
27
+ * fully drains.
28
+ */
29
+ latchRetryBudgetExhausted: boolean;
30
+ };
31
+
32
+ export function createRuntimePersistenceLatch(): RuntimePersistenceLatch {
33
+ return {
34
+ tripped: false,
35
+ cause: null,
36
+ preventedCallCount: 0,
37
+ latchRetryBudgetExhausted: false,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Bounded provider-dispatch wave size.
43
+ *
44
+ * A single map chunk can hold hundreds of rows. The persistence latch only
45
+ * trips AFTER a receipt completes (i.e. after the provider call already billed),
46
+ * so if every row in a big chunk dispatches its provider call in one synchronous
47
+ * wave, the latch cannot gate anything within that chunk — all calls bill before
48
+ * the first failure is even observed. That is the 250/250 incident (run
49
+ * play/x-lima-shard-03/run/20260703t231850-473-44f3d408: a customer billed
50
+ * 250/250 with preventedCallCount=0, because enrich batches compile to one
51
+ * ~245-row chunk with effectively unbounded row concurrency).
52
+ *
53
+ * Dispatching provider calls in waves of this size, with a single latch read
54
+ * between waves, bounds billed exposure for ANY chunk shape to ~one wave plus
55
+ * the calls already in flight when the latch trips. Constant on purpose (house
56
+ * preference: searchable, no env var).
57
+ */
58
+ export const PROVIDER_DISPATCH_WAVE_SIZE = 25;
59
+
60
+ /**
61
+ * Partition dispatch units into bounded waves. Each unit contributes `sizeOf`
62
+ * toward the wave budget: 1 for a single provider call, the batch member count
63
+ * for a batched group. A batch is atomic and is never split across waves — an
64
+ * over-budget batch forms its own wave and still counts its full member size
65
+ * toward the budget. Pure + synchronous so the wave arithmetic is unit-testable
66
+ * apart from the async dispatch loops that consume it.
67
+ */
68
+ export function partitionDispatchWaves<T>(
69
+ units: readonly T[],
70
+ sizeOf: (unit: T) => number,
71
+ waveSize: number = PROVIDER_DISPATCH_WAVE_SIZE,
72
+ ): T[][] {
73
+ const budget = Math.max(1, Math.floor(waveSize));
74
+ const waves: T[][] = [];
75
+ let current: T[] = [];
76
+ let weight = 0;
77
+ for (const unit of units) {
78
+ if (current.length > 0 && weight >= budget) {
79
+ waves.push(current);
80
+ current = [];
81
+ weight = 0;
82
+ }
83
+ current.push(unit);
84
+ weight += Math.max(1, Math.floor(sizeOf(unit)));
85
+ }
86
+ if (current.length > 0) waves.push(current);
87
+ return waves;
88
+ }
89
+
90
+ function formatLatchCause(error: unknown): string {
91
+ const raw =
92
+ error instanceof Error
93
+ ? error.message
94
+ : typeof error === 'string'
95
+ ? error
96
+ : (() => {
97
+ try {
98
+ return JSON.stringify(error);
99
+ } catch {
100
+ return String(error);
101
+ }
102
+ })();
103
+ const message = (raw ?? '').replace(/\s+/g, ' ').trim();
104
+ if (!message) return 'Runtime persistence failed.';
105
+ return message.length > 1_000 ? `${message.slice(0, 1_000)}…` : message;
106
+ }
107
+
108
+ /**
109
+ * Trip the latch on the first persistence failure. Idempotent: later trips are
110
+ * no-ops so `cause` keeps the first, root failure.
111
+ */
112
+ export function tripRuntimePersistenceLatch(
113
+ latch: RuntimePersistenceLatch,
114
+ error: unknown,
115
+ ): void {
116
+ if (latch.tripped) return;
117
+ latch.tripped = true;
118
+ latch.cause = formatLatchCause(error);
119
+ }
120
+
121
+ /**
122
+ * Thrown in place of dispatching a provider call once the latch is tripped. The
123
+ * message deliberately contains the substring `persistence failure` so that a
124
+ * `failed` receipt carrying this error is reclaimable on resume (see
125
+ * `canReclaimFailedWorkerToolReceipt`).
126
+ */
127
+ export class RuntimePersistenceCircuitOpenError extends Error {
128
+ constructor(latch: RuntimePersistenceLatch) {
129
+ super(
130
+ `Runtime persistence failure circuit breaker: skipped dispatching this provider call ` +
131
+ `because a runtime persistence failure already occurred in this map. ` +
132
+ `First persistence error: ${latch.cause}`,
133
+ );
134
+ this.name = 'RuntimePersistenceCircuitOpenError';
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Decide whether a completeReceipt retry ladder entrant should collapse to a
140
+ * single attempt instead of running the full backoff ladder.
141
+ *
142
+ * Keyed on `latch.tripped` (NOT `latchRetryBudgetExhausted`): the latch only
143
+ * trips once a persistence failure is CONFIRMED — either a full completeReceipt
144
+ * ladder already gave up, or an output-sheet flush failed. A transient blip that
145
+ * a ladder rides out never trips the latch, so the first ladder always keeps its
146
+ * full budget and blip tolerance is preserved. Once the latch is tripped the
147
+ * endpoint is known-dead, so every FRESH entrant collapses to one attempt rather
148
+ * than piling another full ladder of nested connect attempts onto a saturated
149
+ * database. A ladder that already committed to retrying (`committedToFullLadder`)
150
+ * is protected: it keeps its full budget so an in-progress leader is never
151
+ * truncated mid-flight by a peer that tripped the latch a moment later.
152
+ */
153
+ export function shouldShortenCompleteReceiptLadder(
154
+ latch: RuntimePersistenceLatch,
155
+ committedToFullLadder: boolean,
156
+ ): boolean {
157
+ return latch.tripped && !committedToFullLadder;
158
+ }
159
+
160
+ export function isRuntimePersistenceCircuitOpenError(error: unknown): boolean {
161
+ if (!error || typeof error !== 'object') return false;
162
+ if (error instanceof RuntimePersistenceCircuitOpenError) return true;
163
+ return (
164
+ error instanceof Error &&
165
+ error.name === 'RuntimePersistenceCircuitOpenError'
166
+ );
167
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Salvage of orphaned billed tool results.
3
+ *
4
+ * When a provider call has billed server-side but its durable receipt cannot be
5
+ * marked completed (tenant Postgres unwritable) even after the retry ladder,
6
+ * the serialized tool output would otherwise live only in isolate memory and be
7
+ * lost when the run fails. This module collects those outputs and builds a
8
+ * size-capped salvage record that rides on the run.failed ledger event's
9
+ * `result` field — a different durable system (Convex run ledger) than the dead
10
+ * tenant Postgres.
11
+ *
12
+ * The salvage is a LOUD marker on a FAILED run, never a fallback that lets the
13
+ * run succeed. Entries carry exactly the bytes a receipt would have stored
14
+ * (customer-owned tool output); no provider-spend / cost metadata is added.
15
+ */
16
+
17
+ /** Total salvage payload cap. Well under Convex's ~1MB document limit once the
18
+ * rest of the failure result + run snapshot are accounted for. */
19
+ export const SALVAGE_MAX_TOTAL_BYTES = 262_144;
20
+ /** Per-entry output cap: an oversized single output is dropped (metadata kept). */
21
+ export const SALVAGE_MAX_ENTRY_BYTES = 32_768;
22
+
23
+ export type ReceiptSalvageEntry = {
24
+ receiptKey: string;
25
+ toolId: string;
26
+ cacheKey?: string;
27
+ /** Serialized tool output (what completeReceipt would have persisted).
28
+ * Omitted when the output was dropped for size. */
29
+ output?: unknown;
30
+ /** UTF-8 byte length of the serialized output (0 when there was none). */
31
+ outputBytes: number;
32
+ /** True when this entry's output was dropped for size. */
33
+ truncated: boolean;
34
+ /** completeReceipt attempts made before giving up. */
35
+ completeAttempts: number;
36
+ /** epoch ms of the first completion failure for this entry. */
37
+ firstErrorAt: number;
38
+ };
39
+
40
+ export type ReceiptSalvage = {
41
+ entries: ReceiptSalvageEntry[];
42
+ /** Total orphaned results collected, including any dropped entirely. */
43
+ totalEntries: number;
44
+ /** Entries dropped entirely (not even metadata retained) for total-size cap. */
45
+ droppedEntries: number;
46
+ /** True when any output was dropped or any entry was dropped entirely. */
47
+ truncated: boolean;
48
+ /** Loud human-readable marker (see note strings below). */
49
+ note: string;
50
+ };
51
+
52
+ export type ReceiptSalvageInput = {
53
+ receiptKey: string;
54
+ toolId: string;
55
+ cacheKey?: string;
56
+ /** Serialized durable output (already JSON-safe, as receipts store it). */
57
+ output: unknown;
58
+ completeAttempts: number;
59
+ firstErrorAt: number;
60
+ };
61
+
62
+ function jsonByteLength(value: unknown): number {
63
+ let json: string | undefined;
64
+ try {
65
+ json = JSON.stringify(value);
66
+ } catch {
67
+ return 0;
68
+ }
69
+ if (!json) return 0;
70
+ return new TextEncoder().encode(json).length;
71
+ }
72
+
73
+ /**
74
+ * Cap-enforcing, pure builder. Keeps metadata-only entries until even metadata
75
+ * would exceed the total cap, then drops remaining entries entirely and counts
76
+ * them in `droppedEntries`.
77
+ */
78
+ export function buildReceiptSalvage(
79
+ inputs: readonly ReceiptSalvageInput[],
80
+ ): ReceiptSalvage | null {
81
+ if (inputs.length === 0) return null;
82
+ const totalEntries = inputs.length;
83
+ const entries: ReceiptSalvageEntry[] = [];
84
+ // Reserve the enclosing `[` and `]`. Every appended entry also reserves one
85
+ // byte for the joining comma, so `usedBytes` stays a conservative upper bound
86
+ // on the serialized `entries` array length.
87
+ let usedBytes = 2;
88
+ let droppedEntries = 0;
89
+ let truncatedOutputs = 0;
90
+ for (const input of inputs) {
91
+ const outputBytes = jsonByteLength(input.output);
92
+ const metaOnly: ReceiptSalvageEntry = {
93
+ receiptKey: input.receiptKey,
94
+ toolId: input.toolId,
95
+ ...(input.cacheKey ? { cacheKey: input.cacheKey } : {}),
96
+ outputBytes,
97
+ truncated: outputBytes > 0,
98
+ completeAttempts: input.completeAttempts,
99
+ firstErrorAt: input.firstErrorAt,
100
+ };
101
+ const metaBytes = jsonByteLength(metaOnly) + 1;
102
+ // Even the metadata-only footprint would blow the total cap: drop entirely.
103
+ if (usedBytes + metaBytes > SALVAGE_MAX_TOTAL_BYTES) {
104
+ droppedEntries += 1;
105
+ continue;
106
+ }
107
+ const withOutput: ReceiptSalvageEntry = {
108
+ ...metaOnly,
109
+ truncated: false,
110
+ output: input.output,
111
+ };
112
+ const fullBytes = jsonByteLength(withOutput) + 1;
113
+ const canIncludeOutput =
114
+ outputBytes > 0 &&
115
+ outputBytes <= SALVAGE_MAX_ENTRY_BYTES &&
116
+ usedBytes + fullBytes <= SALVAGE_MAX_TOTAL_BYTES;
117
+ if (canIncludeOutput) {
118
+ entries.push(withOutput);
119
+ usedBytes += fullBytes;
120
+ } else {
121
+ entries.push(metaOnly);
122
+ if (outputBytes > 0) truncatedOutputs += 1;
123
+ usedBytes += metaBytes;
124
+ }
125
+ }
126
+ const truncated = droppedEntries > 0 || truncatedOutputs > 0;
127
+ const note = truncated
128
+ ? `SALVAGE TRUNCATED: ${truncatedOutputs + droppedEntries} of ${totalEntries} billed-but-unpersisted tool outputs exceeded the salvage size cap and were dropped. All listed receiptKeys were billed; re-running will re-execute unpersisted calls.`
129
+ : `${totalEntries} billed tool result(s) could not be durably persisted; serialized outputs are attached for manual export.`;
130
+ return { entries, totalEntries, droppedEntries, truncated, note };
131
+ }
132
+
133
+ /** Loud one-line suffix appended to the run.failed error message. */
134
+ export function receiptSalvageFailureSuffix(salvage: ReceiptSalvage): string {
135
+ return `${salvage.totalEntries} provider result(s) were billed but could not be persisted; salvage attached to the run record.`;
136
+ }
137
+
138
+ /**
139
+ * Run-scoped collection buffer. Thin wrapper over an array so a single
140
+ * reference can be threaded to every scheduler in the run; `build()` runs the
141
+ * pure cap-enforcing builder at terminal time.
142
+ */
143
+ export type ReceiptSalvageBuffer = {
144
+ push(input: ReceiptSalvageInput): void;
145
+ size(): number;
146
+ build(): ReceiptSalvage | null;
147
+ };
148
+
149
+ export function createReceiptSalvageBuffer(): ReceiptSalvageBuffer {
150
+ const inputs: ReceiptSalvageInput[] = [];
151
+ return {
152
+ push: (input) => {
153
+ inputs.push(input);
154
+ },
155
+ size: () => inputs.length,
156
+ build: () => buildReceiptSalvage(inputs),
157
+ };
158
+ }
@@ -7,6 +7,37 @@ export const PLATFORM_DEPLOY_INTERRUPTED_MESSAGE =
7
7
  export const INTERNAL_RUNTIME_STORAGE_ERROR_MESSAGE =
8
8
  'Internal play runtime storage failed. Please retry the run; if this keeps happening, contact Deepline support with the run ID.';
9
9
 
10
+ export const WORKSPACE_STORAGE_NOT_READY_CODE = 'WORKSPACE_STORAGE_NOT_READY';
11
+
12
+ // User-facing message. Contains NO SQL, role names, database names, or hosts by
13
+ // construction, so it is safe past redactRuntimeInternalError (which only strips
14
+ // postgres-URI passwords). The raw pg error goes in the Error `cause` for server
15
+ // logs only, never into this message or the normalized public failure.
16
+ export const WORKSPACE_STORAGE_NOT_READY_MESSAGE =
17
+ 'Workspace storage for this organization is not ready, so the run was stopped before any provider calls were made (nothing was billed). ' +
18
+ 'Ask an organization admin to run `deepline db repair` (POST /api/v2/ingestion/repair), then re-run. ' +
19
+ 'If this keeps happening, contact Deepline support with the run ID.';
20
+
21
+ export class WorkspaceStorageNotReadyError extends Error {
22
+ readonly code = WORKSPACE_STORAGE_NOT_READY_CODE;
23
+ constructor(options?: { cause?: unknown }) {
24
+ // Prefix the code token so `String(error)` and any stored error string
25
+ // carry it. The run doc persists only the message string, and the read
26
+ // path re-normalizes that string (extractPublicRunErrors), so the token
27
+ // is how `errorCode` survives the serialization boundary back to the CLI.
28
+ super(
29
+ `${WORKSPACE_STORAGE_NOT_READY_CODE}: ${WORKSPACE_STORAGE_NOT_READY_MESSAGE}`,
30
+ );
31
+ this.name = 'WorkspaceStorageNotReadyError';
32
+ // Attach `cause` manually instead of via the 2-arg Error constructor:
33
+ // ErrorOptions is ES2022, but the Convex tsconfig typechecks this file
34
+ // transitively under `lib: ES2021`, where the 2-arg form is a type error.
35
+ if (options?.cause !== undefined) {
36
+ (this as { cause?: unknown }).cause = options.cause;
37
+ }
38
+ }
39
+ }
40
+
10
41
  export type PlayRunFailureDetails = {
11
42
  code: string;
12
43
  phase: string;
@@ -45,6 +76,19 @@ export function normalizePlayRunFailure(error: unknown): PlayRunFailureDetails {
45
76
  cause,
46
77
  };
47
78
  }
79
+ if (
80
+ error instanceof WorkspaceStorageNotReadyError ||
81
+ /\bWORKSPACE_STORAGE_NOT_READY\b/.test(cause)
82
+ ) {
83
+ return {
84
+ code: WORKSPACE_STORAGE_NOT_READY_CODE,
85
+ phase: 'storage',
86
+ // Deliberately return the clean constant, NOT `cause`: the raw string may
87
+ // carry the code token prefix, and we never surface tenant DB internals.
88
+ message: WORKSPACE_STORAGE_NOT_READY_MESSAGE,
89
+ retryable: false,
90
+ };
91
+ }
48
92
  const lowerCause = cause.toLowerCase();
49
93
  if (
50
94
  lowerCause.includes('neondberror') ||
@@ -112,6 +112,12 @@ const runtimeSheetEnsureCache = new Map<
112
112
  const DIRECT_POSTGRES_BATCH_SIZE = 10_000;
113
113
  const RUNTIME_DB_SESSION_ROW_LIMIT_FLOOR = DIRECT_POSTGRES_BATCH_SIZE;
114
114
  const APPEND_KEY_SUFFIX_LENGTH = 12;
115
+ // On-demand (create_db_session) mint TTL. Deliberately NOT aligned with
116
+ // DB_SESSION_DEFAULT_TTL_SECONDS (60 min): on-demand sessions are isolate-
117
+ // cached and re-minted through sessionHasRenewalWindow, never stored in the
118
+ // dedup DO, so they are not subject to the 410 expired-retention race the
119
+ // 60-min alignment fixed. A shorter TTL only causes periodic re-mints (cheap;
120
+ // the tenant runtime role is long-lived), not run failures.
115
121
  const RUNTIME_DB_SESSION_TTL_SECONDS = 10 * 60;
116
122
  const RUNTIME_SHEET_ENSURE_CACHE_TTL_MS = 10 * 60_000;
117
123
  const RUNTIME_SHEET_READY_AFTER_ENSURE_RETRY_DELAYS_MS = [