deepline 0.1.182 → 0.1.183

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 (66) hide show
  1. package/dist/bundling-sources/apps/play-runner-workers/src/child-play-await.ts +203 -6
  2. package/dist/bundling-sources/apps/play-runner-workers/src/child-play-submit.ts +8 -1
  3. package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +633 -107
  4. package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +3 -2
  5. package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +1747 -1890
  6. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +116 -0
  7. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +542 -78
  8. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +292 -11
  9. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +519 -0
  10. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +2381 -0
  11. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +18 -5
  12. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +130 -0
  13. package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +39 -0
  14. package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +2 -0
  15. package/dist/bundling-sources/sdk/src/client.ts +41 -0
  16. package/dist/bundling-sources/sdk/src/http.ts +23 -8
  17. package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +56 -3
  18. package/dist/bundling-sources/sdk/src/release.ts +2 -2
  19. package/dist/bundling-sources/sdk/src/types.ts +2 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +132 -10
  21. package/dist/bundling-sources/shared_libs/play-runtime/auth-scope-resolver.ts +92 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +4 -4
  23. package/dist/bundling-sources/shared_libs/play-runtime/batching-types.ts +8 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +101 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1605 -281
  26. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +105 -6
  27. package/dist/bundling-sources/shared_libs/play-runtime/dedup-backend.ts +0 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +1 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/durability-store.ts +4 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +116 -16
  31. package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +186 -20
  32. package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +2 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/execution-ledger-store.ts +133 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +245 -0
  35. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -5
  36. package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +14 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/lease-policy.ts +52 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +41 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +3 -2
  40. package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +124 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +6 -2
  42. package/dist/bundling-sources/shared_libs/play-runtime/row-isolation.ts +48 -2
  43. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +158 -15
  44. package/dist/bundling-sources/shared_libs/play-runtime/run-terminal-source.ts +45 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +9 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +2037 -262
  47. package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +42 -0
  48. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-attempt-state-machine.ts +183 -0
  49. package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-errors.ts +88 -0
  50. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +8 -1
  51. package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +103 -0
  52. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +19 -1
  53. package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +343 -0
  54. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +43 -0
  55. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
  56. package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +437 -0
  57. package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +83 -6
  58. package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +24 -17
  59. package/dist/cli/index.js +76 -17
  60. package/dist/cli/index.mjs +76 -17
  61. package/dist/index.d.mts +2 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.js +63 -9
  64. package/dist/index.mjs +63 -9
  65. package/dist/plays/bundle-play-file.mjs +2 -2
  66. package/package.json +1 -1
@@ -0,0 +1,245 @@
1
+ import {
2
+ acquireRateStateViaAppRuntime,
3
+ penalizeRateStateViaAppRuntime,
4
+ releaseRateStateViaAppRuntime,
5
+ type WorkerRuntimeApiContext,
6
+ } from '../app-runtime-api';
7
+ import {
8
+ noopPacingPermit,
9
+ type PacingPermit,
10
+ type PacingRule,
11
+ type RateStateBackend,
12
+ } from './rate-state-backend';
13
+
14
+ /**
15
+ * Permits leased per app-runtime round trip for the Hatchet/Node substrate.
16
+ *
17
+ * Eight keeps DB/API round trips amortized for batch-heavy maps while limiting
18
+ * stale-block over-issue to 8 x live runner processes per bucket/window.
19
+ */
20
+ export const APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE = 8;
21
+ const LEASE_BLOCK_TTL_MS = 250;
22
+ const MAX_ACQUIRE_SLEEP_MS = 5_000;
23
+ const STORE_FAILURE_RETRY_DELAYS_MS = [100, 250] as const;
24
+
25
+ interface LeasedBlock {
26
+ remaining: number;
27
+ expiresAt: number;
28
+ rulesKey: string;
29
+ rules: PacingRule[];
30
+ }
31
+
32
+ interface Options {
33
+ schedulerSchema?: string | null;
34
+ now?: () => number;
35
+ sleep?: (ms: number) => Promise<void>;
36
+ onStoreFailure?: (info: { bucketId: string; error: string }) => void;
37
+ }
38
+
39
+ export class AppRuntimeRateStateBackend implements RateStateBackend {
40
+ private readonly context: WorkerRuntimeApiContext;
41
+ private readonly schedulerSchema: string | null;
42
+ private readonly now: () => number;
43
+ private readonly sleep: (ms: number) => Promise<void>;
44
+ private readonly onStoreFailure: (info: {
45
+ bucketId: string;
46
+ error: string;
47
+ }) => void;
48
+ private readonly blocks = new Map<string, LeasedBlock>();
49
+ private pendingReleaseFailure: Error | null = null;
50
+
51
+ constructor(context: WorkerRuntimeApiContext, options: Options = {}) {
52
+ this.context = context;
53
+ this.schedulerSchema = options.schedulerSchema?.trim() || null;
54
+ this.now = options.now ?? (() => Date.now());
55
+ this.sleep =
56
+ options.sleep ??
57
+ ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
58
+ this.onStoreFailure =
59
+ options.onStoreFailure ??
60
+ ((info) => {
61
+ console.error('[app-runtime-rate-state] store failure', info);
62
+ });
63
+ }
64
+
65
+ async acquire(input: {
66
+ bucketId: string;
67
+ rules: readonly PacingRule[];
68
+ signal?: AbortSignal;
69
+ }): Promise<PacingPermit> {
70
+ const { bucketId, rules, signal } = input;
71
+ if (rules.length === 0) return noopPacingPermit();
72
+ this.throwPendingReleaseFailure();
73
+
74
+ const ordered = [...rules].sort((a, b) => a.ruleId.localeCompare(b.ruleId));
75
+ const rulesKey = rulesSignature(ordered);
76
+ const localPermit = this.drawFromBlock(bucketId, rulesKey);
77
+ if (localPermit) return localPermit;
78
+
79
+ let consecutiveFailures = 0;
80
+ while (true) {
81
+ if (signal?.aborted) {
82
+ throw signal.reason instanceof Error
83
+ ? signal.reason
84
+ : new Error('Rate-state acquire aborted.');
85
+ }
86
+ try {
87
+ const response = await acquireRateStateViaAppRuntime(this.context, {
88
+ bucketId,
89
+ rules: ordered,
90
+ requested: APP_RUNTIME_RATE_STATE_LEASE_BLOCK_SIZE,
91
+ schedulerSchema: this.schedulerSchema,
92
+ });
93
+ consecutiveFailures = 0;
94
+ if (response.granted > 0) {
95
+ const remaining = response.granted - 1;
96
+ if (remaining > 0)
97
+ this.mergeBlock(bucketId, remaining, rulesKey, ordered);
98
+ return this.permitFor(bucketId, ordered);
99
+ }
100
+ await this.sleep(
101
+ Math.max(1, Math.min(response.waitMs, MAX_ACQUIRE_SLEEP_MS)),
102
+ );
103
+ } catch (error) {
104
+ // Fail closed. A degraded rate-state store must not grant unlimited
105
+ // provider calls; after this bounded retry ladder the run fails loudly.
106
+ consecutiveFailures += 1;
107
+ const normalized = normalizeError(error);
108
+ this.onStoreFailure({ bucketId, error: normalized.message });
109
+ if (consecutiveFailures > STORE_FAILURE_RETRY_DELAYS_MS.length) {
110
+ throw new Error(
111
+ `Rate-state store unavailable for ${bucketId}; failing closed after ${consecutiveFailures} acquire attempts: ${normalized.message}`,
112
+ );
113
+ }
114
+ await this.sleep(
115
+ STORE_FAILURE_RETRY_DELAYS_MS[consecutiveFailures - 1] ?? 250,
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ penalize(input: { bucketId: string; cooldownMs: number }): void {
122
+ if (input.cooldownMs <= 0) return;
123
+ this.drainBlock(input.bucketId);
124
+ void penalizeRateStateViaAppRuntime(this.context, {
125
+ bucketId: input.bucketId,
126
+ cooldownMs: input.cooldownMs,
127
+ schedulerSchema: this.schedulerSchema,
128
+ }).catch((error) => {
129
+ const normalized = normalizeError(error);
130
+ this.pendingReleaseFailure = new Error(
131
+ `Rate-state penalize failed for ${input.bucketId}: ${normalized.message}`,
132
+ );
133
+ this.onStoreFailure({
134
+ bucketId: input.bucketId,
135
+ error: normalized.message,
136
+ });
137
+ });
138
+ }
139
+
140
+ private permitFor(bucketId: string, rules: PacingRule[]): PacingPermit {
141
+ if (!rules.some((rule) => rule.maxConcurrency != null)) {
142
+ return noopPacingPermit();
143
+ }
144
+ let released = false;
145
+ return {
146
+ release: () => {
147
+ if (released) return;
148
+ released = true;
149
+ this.releaseReserved(bucketId, rules, 1);
150
+ },
151
+ };
152
+ }
153
+
154
+ private mergeBlock(
155
+ bucketId: string,
156
+ remaining: number,
157
+ rulesKey: string,
158
+ rules: PacingRule[],
159
+ ): void {
160
+ const freshExpiresAt = this.now() + LEASE_BLOCK_TTL_MS;
161
+ const existing = this.blocks.get(bucketId);
162
+ if (
163
+ existing &&
164
+ existing.rulesKey === rulesKey &&
165
+ existing.expiresAt > this.now()
166
+ ) {
167
+ existing.remaining += remaining;
168
+ existing.expiresAt = Math.min(existing.expiresAt, freshExpiresAt);
169
+ return;
170
+ }
171
+ if (existing)
172
+ this.releaseReserved(bucketId, existing.rules, existing.remaining);
173
+ this.blocks.set(bucketId, {
174
+ remaining,
175
+ expiresAt: freshExpiresAt,
176
+ rulesKey,
177
+ rules,
178
+ });
179
+ }
180
+
181
+ private drawFromBlock(
182
+ bucketId: string,
183
+ rulesKey: string,
184
+ ): PacingPermit | null {
185
+ const block = this.blocks.get(bucketId);
186
+ if (!block) return null;
187
+ if (block.rulesKey !== rulesKey || block.expiresAt <= this.now()) {
188
+ this.blocks.delete(bucketId);
189
+ this.releaseReserved(bucketId, block.rules, block.remaining);
190
+ return null;
191
+ }
192
+ block.remaining -= 1;
193
+ if (block.remaining <= 0) this.blocks.delete(bucketId);
194
+ return this.permitFor(bucketId, block.rules);
195
+ }
196
+
197
+ private drainBlock(bucketId: string): void {
198
+ const block = this.blocks.get(bucketId);
199
+ if (!block) return;
200
+ this.blocks.delete(bucketId);
201
+ this.releaseReserved(bucketId, block.rules, block.remaining);
202
+ }
203
+
204
+ private releaseReserved(
205
+ bucketId: string,
206
+ rules: readonly PacingRule[],
207
+ count: number,
208
+ ): void {
209
+ if (count <= 0 || !rules.some((rule) => rule.maxConcurrency != null))
210
+ return;
211
+ void releaseRateStateViaAppRuntime(this.context, {
212
+ bucketId,
213
+ rules: [...rules],
214
+ count,
215
+ schedulerSchema: this.schedulerSchema,
216
+ }).catch((error) => {
217
+ const normalized = normalizeError(error);
218
+ this.pendingReleaseFailure = new Error(
219
+ `Rate-state release failed for ${bucketId}: ${normalized.message}`,
220
+ );
221
+ this.onStoreFailure({ bucketId, error: normalized.message });
222
+ });
223
+ }
224
+
225
+ private throwPendingReleaseFailure(): void {
226
+ if (!this.pendingReleaseFailure) return;
227
+ const error = this.pendingReleaseFailure;
228
+ this.pendingReleaseFailure = null;
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ function rulesSignature(rules: readonly PacingRule[]): string {
234
+ return [...rules]
235
+ .map(
236
+ (rule) =>
237
+ `${rule.ruleId}:${rule.requestsPerWindow}:${rule.windowMs}:${rule.maxConcurrency ?? ''}`,
238
+ )
239
+ .sort()
240
+ .join('|');
241
+ }
242
+
243
+ function normalizeError(error: unknown): Error {
244
+ return error instanceof Error ? error : new Error(String(error));
245
+ }
@@ -8,7 +8,7 @@
8
8
  * "may I, and how many at once" policy. See ADR 0007 + CONTEXT.md.
9
9
  *
10
10
  * Surface (small, by design):
11
- * - acquireRowSlot / acquireChildPlaySlot / acquireToolSlot → blocking leases
11
+ * - acquireRowSlot / acquireChildSubmitSlot / acquireToolSlot → blocking leases
12
12
  * - chargeBudget → throws on breach
13
13
  * - forkChild → child lineage snapshot
14
14
  * - resolveRowConcurrency / reportProviderBackpressure / snapshot
@@ -80,8 +80,8 @@ export interface PlayExecutionGovernor {
80
80
 
81
81
  /** Block until a map-row slot is free. */
82
82
  acquireRowSlot(opts?: { signal?: AbortSignal }): Promise<WorkLease>;
83
- /** Block until a child-play slot is free. */
84
- acquireChildPlaySlot(opts?: { signal?: AbortSignal }): Promise<WorkLease>;
83
+ /** Block until a child-play submit slot is free. Released after submit, not terminal. */
84
+ acquireChildSubmitSlot(opts?: { signal?: AbortSignal }): Promise<WorkLease>;
85
85
  /**
86
86
  * Block until a global tool-concurrency slot AND the per-(org,provider) pacer
87
87
  * permit are free, then charge the tool-call budget and return a lease. Order:
@@ -111,7 +111,7 @@ export interface PlayExecutionGovernor {
111
111
  *
112
112
  * Unlike {@link acquireToolSlot} (which charges last so an aborted acquire
113
113
  * never consumes budget), child-lineage counters are charged here at fork
114
- * time, BEFORE the caller acquires a child-play slot. A slot acquire that then
114
+ * time, BEFORE the caller acquires a child-submit slot. A slot acquire that then
115
115
  * fails (e.g. abort) does NOT refund these counters. This is intentional and
116
116
  * safe given the 100k child caps: the charge reserves lineage-global capacity
117
117
  * for a launch the caller has committed to, and forkChild must return the
@@ -264,7 +264,7 @@ export function createPlayExecutionGovernor(
264
264
  policy,
265
265
 
266
266
  acquireRowSlot: (opts) => rowSlots.acquire(opts?.signal),
267
- acquireChildPlaySlot: (opts) => childPlaySlots.acquire(opts?.signal),
267
+ acquireChildSubmitSlot: (opts) => childPlaySlots.acquire(opts?.signal),
268
268
 
269
269
  async acquireToolSlot(toolId, opts) {
270
270
  // 1. global tool-concurrency slot.
@@ -72,6 +72,10 @@ export interface ExecutionPacingPolicy {
72
72
  readonly defaultProviderRequestsPerSecond: number;
73
73
  /** Parallelism the scheduler suggests before a provider's own hints tighten it. */
74
74
  readonly suggestedMaxParallelism: number;
75
+ /** Elapsed isolate work window before the worker dispatcher yields. */
76
+ readonly workerYieldElapsedMs: number;
77
+ /** Fallback batch parallelism when a tool declares no provider hints. */
78
+ readonly workerToolBatchDefaultParallelism: number;
75
79
  }
76
80
 
77
81
  export interface ResolvedExecutionPolicy {
@@ -118,6 +122,16 @@ export const SHARED_EXECUTION_POLICY: ResolvedExecutionPolicy = {
118
122
  // Undeclared providers; declared providers (rate-limit-definitions.ts) win.
119
123
  defaultProviderRequestsPerSecond: 10,
120
124
  suggestedMaxParallelism: 50,
125
+ // Worker isolate pacing knobs. The budget module owns platform accounting;
126
+ // the Governor owns the policy values that decide when work is chunked.
127
+ workerYieldElapsedMs: 45_000,
128
+ // Fallback wave width for batched tool chunks that carry no provider pacing
129
+ // hint. This is only a floor for the undeclared case: it stays bounded by
130
+ // suggestedMaxParallelism (50) above, and declared providers still get
131
+ // tightened by their per-provider pacing (rate-limit-definitions.ts). Raised
132
+ // 4 -> 16 so full batches fan out wide enough to close the map-throughput gap
133
+ // without over-running providers that actually declare limits.
134
+ workerToolBatchDefaultParallelism: 16,
121
135
  },
122
136
  };
123
137
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Lease timing policy for runtime receipts and Runtime Sheet attempts.
3
+ *
4
+ * Expiry is a liveness signal, not a logical ordering token. B2 moves write
5
+ * ordering to coordinator run-attempt epochs; these values only decide how
6
+ * long an owner may hold a lease without renewal.
7
+ */
8
+
9
+ const DEFAULT_PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS = 10 * 60_000;
10
+ const DEFAULT_PLAY_RUNTIME_SHEET_ATTEMPT_LEASE_TTL_MS = 10 * 60_000;
11
+
12
+ function readRuntimeLeaseEnv(name: string): string | undefined {
13
+ if (typeof process === 'undefined') return undefined;
14
+ return process.env?.[name];
15
+ }
16
+
17
+ function resolveRuntimeLeaseTtlMs(
18
+ envName: string,
19
+ defaultValueMs: number,
20
+ ): number {
21
+ const raw = readRuntimeLeaseEnv(envName);
22
+ if (raw === undefined || raw.trim() === '') return defaultValueMs;
23
+ if (!/^\d+$/.test(raw.trim())) {
24
+ throw new Error(
25
+ `${envName} must be a positive integer number of milliseconds.`,
26
+ );
27
+ }
28
+ const value = Number(raw.trim());
29
+ if (!Number.isSafeInteger(value) || value <= 0) {
30
+ throw new Error(
31
+ `${envName} must be a positive integer number of milliseconds.`,
32
+ );
33
+ }
34
+ return value;
35
+ }
36
+
37
+ export const PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS = resolveRuntimeLeaseTtlMs(
38
+ 'DEEPLINE_WORK_RECEIPT_LEASE_TTL_MS',
39
+ DEFAULT_PLAY_RUNTIME_WORK_RECEIPT_LEASE_TTL_MS,
40
+ );
41
+ export const PLAY_RUNTIME_SHEET_ATTEMPT_LEASE_TTL_MS = resolveRuntimeLeaseTtlMs(
42
+ 'DEEPLINE_SHEET_ATTEMPT_LEASE_TTL_MS',
43
+ DEFAULT_PLAY_RUNTIME_SHEET_ATTEMPT_LEASE_TTL_MS,
44
+ );
45
+ export const PLAY_RUNTIME_LEASE_HEARTBEAT_DIVISOR = 3;
46
+
47
+ export function runtimeLeaseHeartbeatIntervalMs(ttlMs: number): number {
48
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
49
+ throw new Error('Runtime lease heartbeat interval needs a positive TTL.');
50
+ }
51
+ return Math.max(1, Math.floor(ttlMs / PLAY_RUNTIME_LEASE_HEARTBEAT_DIVISOR));
52
+ }
@@ -9,6 +9,42 @@ import type { PlayExecutionSuspension } from './suspension';
9
9
  import type { PlayStaticPipeline } from '../plays/static-pipeline';
10
10
  import type { PlayLiveEventSource } from './live-events';
11
11
  import type { PreloadedRuntimeDbSession } from './db-session';
12
+ import type { PacingRule } from './governor/rate-state-backend';
13
+ import type { GovernanceSnapshot } from './governor/governor';
14
+
15
+ export type PlayRunnerRateStateBackendConfig =
16
+ | {
17
+ kind: 'in_memory';
18
+ }
19
+ | {
20
+ kind: 'app_runtime_postgres';
21
+ schedulerSchema?: string | null;
22
+ };
23
+
24
+ export type PlayRunnerRateStateAcquireInput = {
25
+ bucketId: string;
26
+ rules: PacingRule[];
27
+ requested: number;
28
+ schedulerSchema?: string | null;
29
+ };
30
+
31
+ export type PlayRunnerRateStateAcquireResult = {
32
+ granted: number;
33
+ waitMs: number;
34
+ };
35
+
36
+ export type PlayRunnerRateStateReleaseInput = {
37
+ bucketId: string;
38
+ rules: PacingRule[];
39
+ count: number;
40
+ schedulerSchema?: string | null;
41
+ };
42
+
43
+ export type PlayRunnerRateStatePenalizeInput = {
44
+ bucketId: string;
45
+ cooldownMs: number;
46
+ schedulerSchema?: string | null;
47
+ };
12
48
 
13
49
  export interface PlayRunnerContextConfig {
14
50
  executorToken?: string;
@@ -19,10 +55,12 @@ export interface PlayRunnerContextConfig {
19
55
  workflowId?: string;
20
56
  playId?: string;
21
57
  runId?: string;
58
+ runAttempt?: number | null;
22
59
  playName?: string;
23
60
  userEmail?: string;
24
61
  convexUrl?: string;
25
62
  staticPipeline?: PlayStaticPipeline | null;
63
+ force?: boolean | null;
26
64
  forceToolRefresh?: boolean | null;
27
65
  /**
28
66
  * Controls how scoped Postgres sessions enter the runner. `preloaded` means
@@ -38,6 +76,9 @@ export interface PlayRunnerContextConfig {
38
76
  * exact-enough match for the requested play/table/operation scope.
39
77
  */
40
78
  preloadedDbSessions?: PreloadedRuntimeDbSession[];
79
+ rateStateBackend?: PlayRunnerRateStateBackendConfig;
80
+ runtimeSchedulerSchema?: string | null;
81
+ governance?: GovernanceSnapshot | null;
41
82
  }
42
83
 
43
84
  export interface PlayRunnerExecutionConfig {
@@ -44,9 +44,10 @@ export const PLAY_RUNTIME_PROVIDERS: Record<
44
44
  id: PLAY_RUNTIME_PROVIDER_IDS.hatchet,
45
45
  scheduler: PLAY_SCHEDULER_BACKENDS.hatchet,
46
46
  runner: PLAY_RUNTIME_BACKENDS.daytona,
47
- dedup: PLAY_DEDUP_BACKENDS.durableObject,
47
+ dedup: PLAY_DEDUP_BACKENDS.inMemory,
48
48
  artifactKind: PLAY_ARTIFACT_KINDS.cjsNode20,
49
- label: 'Hatchet scheduler + one-shot Daytona runner + DO dedup',
49
+ label:
50
+ 'Hatchet scheduler + one-shot Daytona runner + receipt-backed in-process dedup',
50
51
  },
51
52
  local: {
52
53
  id: PLAY_RUNTIME_PROVIDER_IDS.local,
@@ -0,0 +1,124 @@
1
+ import { RECEIPT_STATUS_CODE } from './receipt-status';
2
+ import { workReceiptFailureKindCode } from './work-receipts';
3
+
4
+ export function workReceiptClaimableStatusCodes(input: {
5
+ forceRefresh?: boolean;
6
+ forceFailedRefresh?: boolean;
7
+ reclaimRunning?: boolean;
8
+ }): number[] {
9
+ if (input.forceRefresh === true) {
10
+ return [
11
+ RECEIPT_STATUS_CODE.pending,
12
+ RECEIPT_STATUS_CODE.failed,
13
+ ...(input.reclaimRunning === true ? [RECEIPT_STATUS_CODE.running] : []),
14
+ RECEIPT_STATUS_CODE.completed,
15
+ RECEIPT_STATUS_CODE.skipped,
16
+ ];
17
+ }
18
+ if (input.reclaimRunning === true) {
19
+ return [
20
+ RECEIPT_STATUS_CODE.pending,
21
+ RECEIPT_STATUS_CODE.running,
22
+ RECEIPT_STATUS_CODE.failed,
23
+ ];
24
+ }
25
+ return [RECEIPT_STATUS_CODE.pending, RECEIPT_STATUS_CODE.failed];
26
+ }
27
+
28
+ export function workReceiptClaimConflictPredicateSql(input: {
29
+ receiptTable: string;
30
+ claimableStatusesSql: string;
31
+ claimantRunIdSql: string;
32
+ claimantRunAttemptSql?: string;
33
+ forceRefreshSql: string;
34
+ forceFailedRefreshSql?: string;
35
+ }): string {
36
+ const table = input.receiptTable;
37
+ const claimantRunAttemptSql = input.claimantRunAttemptSql ?? '0';
38
+ const forceFailedRefreshSql = input.forceFailedRefreshSql ?? 'false';
39
+ return `${table}.status = ANY(${input.claimableStatusesSql}::smallint[])
40
+ AND (
41
+ ${input.forceRefreshSql}::boolean
42
+ OR (
43
+ ${forceFailedRefreshSql}::boolean
44
+ AND ${table}.status = ${RECEIPT_STATUS_CODE.failed}::smallint
45
+ )
46
+ OR ${table}.status <> ${RECEIPT_STATUS_CODE.failed}::smallint
47
+ OR (
48
+ ${table}.failure_kind = ${workReceiptFailureKindCode('repairable')}::smallint
49
+ AND (
50
+ ${table}.run_id IS DISTINCT FROM ${input.claimantRunIdSql}
51
+ OR COALESCE(${table}.lease_owner_attempt, 0) < ${claimantRunAttemptSql}::integer
52
+ )
53
+ )
54
+ )
55
+ AND (
56
+ ${table}.status = ${RECEIPT_STATUS_CODE.failed}::smallint
57
+ OR (${table}.lease_id IS NULL AND ${table}.lease_expires_at IS NULL)
58
+ OR ${table}.lease_expires_at <= now()
59
+ OR (
60
+ /* owner: runtime; remove lease-owner fallback compat after ledger cutover M1 */
61
+ ${table}.lease_id IS NOT NULL
62
+ AND ${table}.lease_expires_at IS NOT NULL
63
+ AND
64
+ COALESCE(${table}.lease_owner_run_id, ${table}.run_id) = ${input.claimantRunIdSql}
65
+ AND COALESCE(${table}.lease_owner_attempt, 0) < ${claimantRunAttemptSql}::integer
66
+ )
67
+ )`;
68
+ }
69
+
70
+ export function workReceiptRunningOwnerPredicateSql(input: {
71
+ receiptTable: string;
72
+ ownerRunIdSql: string;
73
+ ownerRunAttemptSql?: string;
74
+ leaseIdSql: string;
75
+ }): string {
76
+ const table = input.receiptTable;
77
+ const ownerRunAttemptSql = input.ownerRunAttemptSql ?? '0';
78
+ return `${table}.status = ${RECEIPT_STATUS_CODE.running}::smallint
79
+ /* owner: runtime; remove lease-owner fallback compat after ledger cutover M1 */
80
+ AND COALESCE(${table}.lease_owner_run_id, ${table}.run_id) = ${input.ownerRunIdSql}
81
+ AND COALESCE(${table}.lease_owner_attempt, 0) = ${ownerRunAttemptSql}::integer
82
+ AND (
83
+ ${table}.lease_id IS NULL
84
+ OR (
85
+ ${input.leaseIdSql} IS NOT NULL
86
+ AND ${table}.lease_id = ${input.leaseIdSql}
87
+ )
88
+ )`;
89
+ }
90
+
91
+ /**
92
+ * Fence for a controlled work-receipt lease RELEASE on run-fatal teardown.
93
+ * Matches running receipts still leased to the releasing run+attempt, regardless
94
+ * of the specific lease id (teardown does not thread per-receipt lease ids). It
95
+ * never matches a receipt owned by a different run+attempt, so releasing back to
96
+ * `pending` can never steal a live competitor's claim.
97
+ */
98
+ export function workReceiptReleaseOwnerPredicateSql(input: {
99
+ receiptTable: string;
100
+ ownerRunIdSql: string;
101
+ ownerRunAttemptSql?: string;
102
+ }): string {
103
+ const table = input.receiptTable;
104
+ const ownerRunAttemptSql = input.ownerRunAttemptSql ?? '0';
105
+ return `${table}.status = ${RECEIPT_STATUS_CODE.running}::smallint
106
+ /* owner: runtime; remove lease-owner fallback compat after ledger cutover M1 */
107
+ AND COALESCE(${table}.lease_owner_run_id, ${table}.run_id) = ${input.ownerRunIdSql}
108
+ AND COALESCE(${table}.lease_owner_attempt, 0) = ${ownerRunAttemptSql}::integer`;
109
+ }
110
+
111
+ export function workReceiptHeartbeatPredicateSql(input: {
112
+ receiptTable: string;
113
+ ownerRunIdSql: string;
114
+ ownerRunAttemptSql?: string;
115
+ leaseIdSql: string;
116
+ }): string {
117
+ const table = input.receiptTable;
118
+ const ownerRunAttemptSql = input.ownerRunAttemptSql ?? '0';
119
+ return `${table}.status = ${RECEIPT_STATUS_CODE.running}::smallint
120
+ /* owner: runtime; remove lease-owner fallback compat after ledger cutover M1 */
121
+ AND COALESCE(${table}.lease_owner_run_id, ${table}.run_id) = ${input.ownerRunIdSql}
122
+ AND COALESCE(${table}.lease_owner_attempt, 0) = ${ownerRunAttemptSql}::integer
123
+ AND ${table}.lease_id = ${input.leaseIdSql}`;
124
+ }
@@ -12,7 +12,10 @@ export type ReceiptStatusCode =
12
12
  (typeof RECEIPT_STATUS_CODE)[keyof typeof RECEIPT_STATUS_CODE];
13
13
 
14
14
  export function receiptStatusFromCode(value: unknown): WorkReceiptStatus {
15
- switch (Number(value ?? RECEIPT_STATUS_CODE.pending)) {
15
+ if (value == null) {
16
+ throw new Error(`Unknown runtime work receipt status code: ${value}`);
17
+ }
18
+ switch (Number(value)) {
16
19
  case RECEIPT_STATUS_CODE.running:
17
20
  return 'running';
18
21
  case RECEIPT_STATUS_CODE.completed:
@@ -22,8 +25,9 @@ export function receiptStatusFromCode(value: unknown): WorkReceiptStatus {
22
25
  case RECEIPT_STATUS_CODE.skipped:
23
26
  return 'skipped';
24
27
  case RECEIPT_STATUS_CODE.pending:
25
- default:
26
28
  return 'pending';
29
+ default:
30
+ throw new Error(`Unknown runtime work receipt status code: ${value}`);
27
31
  }
28
32
  }
29
33
 
@@ -1,4 +1,5 @@
1
1
  import { isHardBillingToolHttpError } from './tool-http-errors';
2
+ import { isRuntimePersistenceCircuitOpenError } from './persistence-latch';
2
3
 
3
4
  /**
4
5
  * Thrown by runner Adapters when a Play Run is externally cancelled. Row
@@ -27,13 +28,58 @@ export function isAbortLikeError(error: unknown): boolean {
27
28
  * Errors that must stay run-fatal even under default map row failure isolation.
28
29
  *
29
30
  * Provider/tool HTTP failures, including exhausted 429/5xx retries, are row
30
- * outcomes after the tool-call Adapter spends its local retry budget. Abort,
31
- * Governor budget exhaustion, and hard billing failures escape row isolation.
31
+ * outcomes after the tool-call Adapter spends its local retry budget. Runtime
32
+ * persistence failures are not row data failures: once receipts or sheet writes
33
+ * are unreliable, completing the run would hide paid/side-effecting work that
34
+ * no durable ledger can prove.
32
35
  */
33
36
  export function isRowIsolationExemptError(error: unknown): boolean {
34
37
  if (isAbortLikeError(error)) return true;
35
38
  if (error instanceof Error && error.name === 'GovernorBudgetError') {
36
39
  return true;
37
40
  }
41
+ if (isRuntimeReceiptPersistenceError(error)) return true;
42
+ if (isRuntimeStoragePersistenceError(error)) return true;
43
+ if (isRuntimePersistenceCircuitOpenError(error)) return true;
38
44
  return isHardBillingToolHttpError(error);
39
45
  }
46
+
47
+ function isRuntimeReceiptPersistenceError(error: unknown): boolean {
48
+ if (!error || typeof error !== 'object') return false;
49
+ if (
50
+ error instanceof Error &&
51
+ error.name === 'RuntimeReceiptPersistenceError'
52
+ ) {
53
+ return true;
54
+ }
55
+ const nestedErrors = (error as { errors?: unknown }).errors;
56
+ return (
57
+ Array.isArray(nestedErrors) &&
58
+ nestedErrors.some(isRuntimeReceiptPersistenceError)
59
+ );
60
+ }
61
+
62
+ function isRuntimeStoragePersistenceError(error: unknown): boolean {
63
+ if (!error || typeof error !== 'object') return false;
64
+ const nestedErrors = (error as { errors?: unknown }).errors;
65
+ if (
66
+ Array.isArray(nestedErrors) &&
67
+ nestedErrors.some(isRuntimeStoragePersistenceError)
68
+ ) {
69
+ return true;
70
+ }
71
+ const message =
72
+ error instanceof Error ? error.message : String((error as object) ?? '');
73
+ return (
74
+ /Runtime Postgres connection timed out/i.test(message) ||
75
+ /\bNeonDbError\b/i.test(message) ||
76
+ /timeout exceeded when trying to connect/i.test(message) ||
77
+ /connection terminated unexpectedly/i.test(message) ||
78
+ /permission denied (?:for|to grant) (?:schema|table|relation|database|sequence|role)\b/i.test(
79
+ message,
80
+ ) ||
81
+ /must be owner of (?:table|relation|sequence|schema|database|function|procedure|routine|type)\b/i.test(
82
+ message,
83
+ )
84
+ );
85
+ }