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.
- package/dist/bundling-sources/apps/play-runner-workers/src/child-play-await.ts +203 -6
- package/dist/bundling-sources/apps/play-runner-workers/src/child-play-submit.ts +8 -1
- package/dist/bundling-sources/apps/play-runner-workers/src/coordinator-entry.ts +633 -107
- package/dist/bundling-sources/apps/play-runner-workers/src/dedup-do.ts +3 -2
- package/dist/bundling-sources/apps/play-runner-workers/src/entry.ts +1747 -1890
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/harness-receipt-store.ts +116 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/map-chunk-plan.ts +542 -78
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/receipts.ts +292 -11
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/run-work-dispatcher.ts +519 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-dispatch.ts +2381 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/tool-receipts.ts +18 -5
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/work-budget.ts +130 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/runtime/worker-platform-budget.ts +39 -0
- package/dist/bundling-sources/apps/play-runner-workers/src/workflow-retry-state.ts +2 -0
- package/dist/bundling-sources/sdk/src/client.ts +41 -0
- package/dist/bundling-sources/sdk/src/http.ts +23 -8
- package/dist/bundling-sources/sdk/src/plays/harness-stub.ts +56 -3
- package/dist/bundling-sources/sdk/src/release.ts +2 -2
- package/dist/bundling-sources/sdk/src/types.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +132 -10
- package/dist/bundling-sources/shared_libs/play-runtime/auth-scope-resolver.ts +92 -0
- package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +4 -4
- package/dist/bundling-sources/shared_libs/play-runtime/batching-types.ts +8 -0
- package/dist/bundling-sources/shared_libs/play-runtime/child-run-id.ts +101 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1605 -281
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +105 -6
- package/dist/bundling-sources/shared_libs/play-runtime/dedup-backend.ts +0 -0
- package/dist/bundling-sources/shared_libs/play-runtime/default-batch-strategies.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durability-store.ts +4 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +116 -16
- package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +186 -20
- package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/execution-ledger-store.ts +133 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +245 -0
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -5
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +14 -0
- package/dist/bundling-sources/shared_libs/play-runtime/lease-policy.ts +52 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +41 -0
- package/dist/bundling-sources/shared_libs/play-runtime/providers.ts +3 -2
- package/dist/bundling-sources/shared_libs/play-runtime/receipt-sql.ts +124 -0
- package/dist/bundling-sources/shared_libs/play-runtime/receipt-status.ts +6 -2
- package/dist/bundling-sources/shared_libs/play-runtime/row-isolation.ts +48 -2
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +158 -15
- package/dist/bundling-sources/shared_libs/play-runtime/run-terminal-source.ts +45 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +9 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +2037 -262
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +42 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-attempt-state-machine.ts +183 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-errors.ts +88 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +8 -1
- package/dist/bundling-sources/shared_libs/play-runtime/sheet-attempt-sql.ts +103 -0
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +19 -1
- package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +343 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +43 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +11 -0
- package/dist/bundling-sources/shared_libs/play-runtime/work-receipt-state-machine.ts +437 -0
- package/dist/bundling-sources/shared_libs/play-runtime/work-receipts.ts +83 -6
- package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +24 -17
- package/dist/cli/index.js +76 -17
- package/dist/cli/index.mjs +76 -17
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -9
- package/dist/index.mjs +63 -9
- package/dist/plays/bundle-play-file.mjs +2 -2
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts
ADDED
|
@@ -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 /
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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.
|
|
47
|
+
dedup: PLAY_DEDUP_BACKENDS.inMemory,
|
|
48
48
|
artifactKind: PLAY_ARTIFACT_KINDS.cjsNode20,
|
|
49
|
-
label:
|
|
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
|
-
|
|
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.
|
|
31
|
-
*
|
|
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
|
+
}
|