deepline 0.3.43 → 0.3.45

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 (25) hide show
  1. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  2. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +1 -0
  3. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1540 -265
  4. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +11 -0
  5. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-session-execution.ts +10 -0
  6. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +22 -4
  7. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/modal.ts +16 -4
  8. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/types.ts +56 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/contract.ts +418 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/index.ts +452 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/runner-backend-adapter.ts +304 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/sandbox-runs/testkit.ts +83 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/always-fresh-adapter.ts +50 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/contract.ts +135 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/index.ts +291 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +335 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/receipt-cohort.ts +904 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/runtime-step-receipts-adapter.ts +522 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/testkit.ts +165 -0
  20. package/dist/cli/index.js +1 -1
  21. package/dist/cli/index.mjs +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.mjs +1 -1
  24. package/dist/install-integrity.json +11 -0
  25. package/package.json +1 -1
@@ -0,0 +1,522 @@
1
+ import {
2
+ COMPLETED_RECEIPT_CACHE_INSERT_LEASE_ID,
3
+ RuntimeReceiptWaitTimeoutError,
4
+ type DurableReceiptExecutionStore,
5
+ } from '../durable-receipt-execution';
6
+ import {
7
+ deserializeToolExecuteResult,
8
+ isSerializedToolExecuteResult,
9
+ isToolExecuteResult,
10
+ serializeToolExecuteResult,
11
+ type ToolExecuteResult,
12
+ } from '../tool-result';
13
+ import type { RuntimeStepReceipt } from '../ctx-types';
14
+ import type {
15
+ ToolCallReceiptLease,
16
+ ToolCallReceiptRecoverySource,
17
+ ToolResultReceipts,
18
+ } from './contract';
19
+ import { ToolCallReceiptLeaseLostError } from './contract';
20
+
21
+ const DEFAULT_LOCK_TTL_MS = 300_000;
22
+ const DEFAULT_LOCK_WAIT_MS = 15_000;
23
+ const DEFAULT_LOCK_POLL_MS = 250;
24
+
25
+ /**
26
+ * The already-provisioned RuntimeStepReceipt storage contract, bound to one
27
+ * Play Run by this adapter. It intentionally reuses the existing durable
28
+ * store instead of giving Tool Call another receipt table or lifecycle.
29
+ */
30
+ export type RuntimeStepToolResultReceiptStore = Pick<
31
+ DurableReceiptExecutionStore,
32
+ | 'enabled'
33
+ | 'get'
34
+ | 'complete'
35
+ | 'acquireExecutionLock'
36
+ | 'releaseExecutionLock'
37
+ >;
38
+
39
+ export type RuntimeStepToolResultReceiptsInput = {
40
+ store: RuntimeStepToolResultReceiptStore;
41
+ owner: {
42
+ runId: string;
43
+ };
44
+ /**
45
+ * A completed-cache-only result has no in-flight receipt row. Non-idempotent
46
+ * provider operations therefore retain the historical, separate execution
47
+ * lock while dispatch is in progress.
48
+ */
49
+ executionLock?: {
50
+ required?: boolean;
51
+ ttlMs?: number;
52
+ waitMs?: number;
53
+ pollMs?: number;
54
+ };
55
+ /**
56
+ * Some runtime gateways require receipt reads/writes to share a hydration
57
+ * turn. Keep that transport concern at the Adapter boundary; ToolCall stays
58
+ * unaware of gateway sessions.
59
+ */
60
+ withCompletedReceiptHydration?: <Result>(
61
+ hydrate: () => Promise<Result>,
62
+ ) => Promise<Result>;
63
+ /**
64
+ * A caller that already performed the first hydrated cache read may pass it
65
+ * here. It is consumed once, preserving one read/turn for the normal cache
66
+ * path while still allowing a compatibility reader to inspect old rows.
67
+ */
68
+ initialReceipt?: {
69
+ key: string;
70
+ receipt: RuntimeStepReceipt | null;
71
+ };
72
+ /**
73
+ * The portable Tool Call job deals in a complete result. The runner Adapter
74
+ * alone knows how its public result metadata distinguishes a cache replay
75
+ * from an in-flight follower. Keep that presentation concern at this seam.
76
+ */
77
+ recover?: (input: {
78
+ result: ToolExecuteResult;
79
+ resultReceiptKey: string;
80
+ source: ToolCallReceiptRecoverySource | 'owner';
81
+ }) => ToolExecuteResult;
82
+ /** Injectable only for deterministic adapter conformance tests. */
83
+ sleep?: (milliseconds: number) => Promise<void>;
84
+ };
85
+
86
+ type OwnedReceipt = {
87
+ resultReceiptKey: string;
88
+ ownerRunId: string;
89
+ force: boolean;
90
+ executionLockOwnerId: string | null;
91
+ lockLost: boolean;
92
+ completedByAnotherOwner: ToolExecuteResult | null;
93
+ heartbeatTimer: ReturnType<typeof setTimeout> | null;
94
+ heartbeatInFlight: Promise<void> | null;
95
+ };
96
+
97
+ /**
98
+ * Adapts the durable RuntimeStepReceipt store to the Tool Result Receipt port.
99
+ *
100
+ * Tool results deliberately use the store's immutable completed-cache mode:
101
+ * a cache hit is reused, execution does not publish a `running` row, and the
102
+ * winner inserts one serialized ToolExecuteResult. That is what lets a V2
103
+ * receipt key be distinct from a legacy one without creating another provider
104
+ * invocation fence. The provider fence remains the dispatcher's Operation.
105
+ */
106
+ export function createRuntimeStepToolResultReceipts(
107
+ input: RuntimeStepToolResultReceiptsInput,
108
+ ): ToolResultReceipts {
109
+ const ownerRunId = required(input.owner.runId, 'owner run id');
110
+ const sleep = input.sleep ?? defaultSleep;
111
+ const lock = normalizeExecutionLock(input.executionLock);
112
+ const owned = new Map<string, OwnedReceipt>();
113
+ let initialReceipt = input.initialReceipt;
114
+ const hydrate = async <Result>(
115
+ read: () => Promise<Result>,
116
+ ): Promise<Result> =>
117
+ input.withCompletedReceiptHydration
118
+ ? await input.withCompletedReceiptHydration(read)
119
+ : await read();
120
+
121
+ const assertOwner = (candidate: {
122
+ resultReceiptKey: string;
123
+ ownerRunId?: string;
124
+ lease?: ToolCallReceiptLease;
125
+ }): OwnedReceipt => {
126
+ if (candidate.ownerRunId && candidate.ownerRunId !== ownerRunId) {
127
+ throw new ToolCallReceiptLeaseLostError(candidate.resultReceiptKey);
128
+ }
129
+ const lease = candidate.lease;
130
+ if (!lease || lease.ownerRunId !== ownerRunId) {
131
+ throw new ToolCallReceiptLeaseLostError(candidate.resultReceiptKey);
132
+ }
133
+ const record = owned.get(lease.leaseId);
134
+ if (
135
+ !record ||
136
+ record.resultReceiptKey !== candidate.resultReceiptKey ||
137
+ record.ownerRunId !== ownerRunId
138
+ ) {
139
+ throw new ToolCallReceiptLeaseLostError(candidate.resultReceiptKey);
140
+ }
141
+ return record;
142
+ };
143
+
144
+ const readCompleted = async (
145
+ resultReceiptKey: string,
146
+ source: ToolCallReceiptRecoverySource | 'owner' = 'cache',
147
+ ): Promise<ToolExecuteResult | null> => {
148
+ const receipt =
149
+ initialReceipt?.key === resultReceiptKey
150
+ ? (() => {
151
+ const value = initialReceipt!.receipt;
152
+ initialReceipt = undefined;
153
+ return value;
154
+ })()
155
+ : await hydrate(() => input.store.get(resultReceiptKey));
156
+ const result = completedResult(receipt, resultReceiptKey);
157
+ return result
158
+ ? (input.recover?.({ result, resultReceiptKey, source }) ?? result)
159
+ : null;
160
+ };
161
+
162
+ const releaseExecutionLock = async (record: OwnedReceipt): Promise<void> => {
163
+ if (!record.executionLockOwnerId || !input.store.releaseExecutionLock) {
164
+ return;
165
+ }
166
+ await input.store.releaseExecutionLock({
167
+ receiptKey: record.resultReceiptKey,
168
+ ownerExecutionId: record.executionLockOwnerId,
169
+ });
170
+ record.executionLockOwnerId = null;
171
+ };
172
+
173
+ const renewExecutionLock = async (
174
+ record: OwnedReceipt,
175
+ ): Promise<'active' | 'completed' | 'lost'> => {
176
+ if (!record.executionLockOwnerId || !input.store.acquireExecutionLock) {
177
+ return 'active';
178
+ }
179
+ const renewed = await input.store.acquireExecutionLock({
180
+ receiptKey: record.resultReceiptKey,
181
+ ownerExecutionId: record.executionLockOwnerId,
182
+ ttlMs: lock.ttlMs,
183
+ });
184
+ if (renewed?.ownerExecutionId === record.executionLockOwnerId) {
185
+ return 'active';
186
+ }
187
+ if (!record.force && input.store.enabled) {
188
+ const completed = await readCompleted(
189
+ record.resultReceiptKey,
190
+ 'in_flight',
191
+ );
192
+ if (completed) {
193
+ record.completedByAnotherOwner = completed;
194
+ return 'completed';
195
+ }
196
+ }
197
+ record.lockLost = true;
198
+ return 'lost';
199
+ };
200
+
201
+ const startHeartbeat = (record: OwnedReceipt): void => {
202
+ if (!record.executionLockOwnerId || record.heartbeatTimer) return;
203
+ // Keep renewal comfortably inside the ownership TTL. The store permits
204
+ // short test/development TTLs too, so a one-second floor would silently
205
+ // let such a lease expire before its first renewal.
206
+ const intervalMs = Math.max(1, Math.floor(lock.ttlMs / 3));
207
+ const schedule = (): void => {
208
+ record.heartbeatTimer = setTimeout(() => {
209
+ record.heartbeatTimer = null;
210
+ record.heartbeatInFlight = renewExecutionLock(record)
211
+ .catch(() => {
212
+ record.lockLost = true;
213
+ })
214
+ .then(() => undefined)
215
+ .finally(() => {
216
+ record.heartbeatInFlight = null;
217
+ if (
218
+ !record.lockLost &&
219
+ !record.completedByAnotherOwner &&
220
+ record.executionLockOwnerId
221
+ ) {
222
+ schedule();
223
+ }
224
+ });
225
+ }, intervalMs);
226
+ };
227
+ schedule();
228
+ };
229
+
230
+ const stopHeartbeat = async (record: OwnedReceipt): Promise<void> => {
231
+ if (record.heartbeatTimer) {
232
+ clearTimeout(record.heartbeatTimer);
233
+ record.heartbeatTimer = null;
234
+ }
235
+ await record.heartbeatInFlight;
236
+ };
237
+
238
+ const acquireExecutionLock = async (
239
+ resultReceiptKey: string,
240
+ force: boolean,
241
+ ): Promise<string | null> => {
242
+ if (!lock.required || !input.store.enabled) return null;
243
+ if (
244
+ !input.store.acquireExecutionLock ||
245
+ !input.store.releaseExecutionLock
246
+ ) {
247
+ throw new Error(
248
+ 'Tool Result Receipt requires the RuntimeStepReceipt execution-lock backend.',
249
+ );
250
+ }
251
+
252
+ const ownerExecutionId = `tool-result:${crypto.randomUUID()}`;
253
+ const deadline = Date.now() + lock.waitMs;
254
+ while (true) {
255
+ const acquired = await input.store.acquireExecutionLock({
256
+ receiptKey: resultReceiptKey,
257
+ ownerExecutionId,
258
+ ttlMs: lock.ttlMs,
259
+ });
260
+ if (acquired?.ownerExecutionId === ownerExecutionId) {
261
+ // Recheck after acquiring: a prior holder may have completed between
262
+ // our cache read and lock acquisition. A forced call intentionally
263
+ // bypasses that immutable completed fact, matching the legacy path.
264
+ if (!force) {
265
+ const cached = await readCompleted(resultReceiptKey, 'in_flight');
266
+ if (cached) {
267
+ await input.store.releaseExecutionLock({
268
+ receiptKey: resultReceiptKey,
269
+ ownerExecutionId,
270
+ });
271
+ return null;
272
+ }
273
+ }
274
+ return ownerExecutionId;
275
+ }
276
+
277
+ if (!force) {
278
+ const cached = await readCompleted(resultReceiptKey, 'in_flight');
279
+ if (cached) return null;
280
+ }
281
+ if (Date.now() >= deadline) {
282
+ throw new RuntimeReceiptWaitTimeoutError(resultReceiptKey);
283
+ }
284
+ await sleep(lock.pollMs);
285
+ }
286
+ };
287
+
288
+ return {
289
+ async claim(candidate) {
290
+ const resultReceiptKey = required(
291
+ candidate.resultReceiptKey,
292
+ 'result receipt key',
293
+ );
294
+ if (candidate.ownerRunId !== ownerRunId) {
295
+ throw new ToolCallReceiptLeaseLostError(resultReceiptKey);
296
+ }
297
+ if (input.store.enabled && !candidate.force) {
298
+ const cached = await readCompleted(resultReceiptKey, 'cache');
299
+ if (cached) return { kind: 'completed', result: cached };
300
+ }
301
+
302
+ const executionLockOwnerId = await acquireExecutionLock(
303
+ resultReceiptKey,
304
+ candidate.force,
305
+ );
306
+ if (input.store.enabled && !candidate.force && !executionLockOwnerId) {
307
+ const cached = await readCompleted(resultReceiptKey, 'cache');
308
+ if (cached) return { kind: 'completed', result: cached };
309
+ // The only non-lock reason to get here is a store without a lock
310
+ // requirement. In that case this caller is still the cache publisher.
311
+ }
312
+
313
+ const leaseId =
314
+ executionLockOwnerId ?? `tool-result:${crypto.randomUUID()}`;
315
+ owned.set(leaseId, {
316
+ resultReceiptKey,
317
+ ownerRunId,
318
+ force: candidate.force,
319
+ executionLockOwnerId,
320
+ lockLost: false,
321
+ completedByAnotherOwner: null,
322
+ heartbeatTimer: null,
323
+ heartbeatInFlight: null,
324
+ });
325
+ return {
326
+ kind: 'owned',
327
+ lease: { leaseId, ownerRunId },
328
+ };
329
+ },
330
+
331
+ async recover(candidate) {
332
+ const resultReceiptKey = required(
333
+ candidate.resultReceiptKey,
334
+ 'result receipt key',
335
+ );
336
+ if (candidate.ownerRunId !== ownerRunId) {
337
+ throw new ToolCallReceiptLeaseLostError(resultReceiptKey);
338
+ }
339
+ const maxWaitMs = candidate.source === 'in_flight' ? lock.waitMs : 0;
340
+ const deadline = Date.now() + maxWaitMs;
341
+ while (true) {
342
+ const receipt = await hydrate(() => input.store.get(resultReceiptKey));
343
+ const completed = completedResult(receipt, resultReceiptKey);
344
+ if (completed) {
345
+ return (
346
+ input.recover?.({
347
+ result: completed,
348
+ resultReceiptKey,
349
+ source: candidate.source,
350
+ }) ?? completed
351
+ );
352
+ }
353
+ if (receipt?.status === 'failed') {
354
+ throw new Error(
355
+ `Tool Result Receipt ${resultReceiptKey} failed: ${receipt.error ?? 'unknown error'}.`,
356
+ );
357
+ }
358
+ if (Date.now() >= deadline) {
359
+ throw new RuntimeReceiptWaitTimeoutError(resultReceiptKey);
360
+ }
361
+ await sleep(lock.pollMs);
362
+ }
363
+ },
364
+
365
+ async heartbeat(candidate) {
366
+ const record = assertOwner({
367
+ resultReceiptKey: candidate.resultReceiptKey,
368
+ lease: candidate.lease,
369
+ });
370
+ if (!record.force && input.store.enabled) {
371
+ const cached = await readCompleted(
372
+ record.resultReceiptKey,
373
+ 'in_flight',
374
+ );
375
+ if (cached) return 'completed';
376
+ }
377
+ if (!record.executionLockOwnerId) return 'active';
378
+ if (record.lockLost) return 'lost';
379
+ const renewal = await renewExecutionLock(record);
380
+ if (renewal === 'active') {
381
+ // ToolCall has one synchronous heartbeat seam before dispatch. Keep
382
+ // the existing lease policy alive for a long provider invocation by
383
+ // renewing in the Adapter until its terminal call stops the loop.
384
+ startHeartbeat(record);
385
+ return 'active';
386
+ }
387
+ if (renewal === 'completed') return 'completed';
388
+ if (!record.force && input.store.enabled) {
389
+ const cached = await readCompleted(record.resultReceiptKey);
390
+ if (cached) return 'completed';
391
+ }
392
+ return 'lost';
393
+ },
394
+
395
+ async complete(candidate) {
396
+ const record = assertOwner({
397
+ resultReceiptKey: candidate.resultReceiptKey,
398
+ lease: candidate.lease,
399
+ });
400
+ try {
401
+ await stopHeartbeat(record);
402
+ if (record.completedByAnotherOwner) {
403
+ return { kind: 'existing', result: record.completedByAnotherOwner };
404
+ }
405
+ if (record.lockLost) {
406
+ throw new ToolCallReceiptLeaseLostError(record.resultReceiptKey);
407
+ }
408
+ // A refresh executes under the provider fence but cannot replace the
409
+ // immutable receipt. Its fresh result is returned to this caller only.
410
+ if (record.force || !input.store.enabled) {
411
+ return { kind: 'stored', result: candidate.result };
412
+ }
413
+ const persisted = await hydrate(
414
+ async () =>
415
+ await input.store.complete(
416
+ record.resultReceiptKey,
417
+ ownerRunId,
418
+ serializeToolExecuteResult(candidate.result),
419
+ COMPLETED_RECEIPT_CACHE_INSERT_LEASE_ID,
420
+ ),
421
+ );
422
+ // Completion transports may acknowledge only status/ownership. The
423
+ // durable row already has the full serialized result, so read it back
424
+ // below instead of mistaking an intentionally compact reply for a
425
+ // malformed completed receipt.
426
+ const result =
427
+ persisted?.output === undefined
428
+ ? null
429
+ : completedResult(persisted, record.resultReceiptKey);
430
+ if (result) {
431
+ return {
432
+ // The legacy storage operation returns the converged completed row
433
+ // for both an insert and a concurrent winner. The exact distinction
434
+ // is intentionally unobservable; the public invariant is the
435
+ // converged immutable ToolExecuteResult.
436
+ kind: 'stored',
437
+ result,
438
+ };
439
+ }
440
+ const converged = await readCompleted(record.resultReceiptKey, 'owner');
441
+ if (converged) return { kind: 'existing', result: converged };
442
+ throw new Error(
443
+ `Tool Result Receipt ${record.resultReceiptKey} could not publish a completed result.`,
444
+ );
445
+ } finally {
446
+ await stopHeartbeat(record);
447
+ await releaseExecutionLock(record);
448
+ owned.delete(candidate.lease.leaseId);
449
+ }
450
+ },
451
+
452
+ async fail(candidate) {
453
+ const record = assertOwner({
454
+ resultReceiptKey: candidate.resultReceiptKey,
455
+ lease: candidate.lease,
456
+ });
457
+ try {
458
+ // Completed-cache-only mode has no owned in-flight row to mark failed.
459
+ // Recording a terminal failure would let an old owner overwrite a
460
+ // concurrent successful publisher. Releasing just our lock is safe;
461
+ // the original provider error remains the caller-visible fact.
462
+ } finally {
463
+ await stopHeartbeat(record);
464
+ await releaseExecutionLock(record);
465
+ owned.delete(candidate.lease.leaseId);
466
+ }
467
+ },
468
+ };
469
+ }
470
+
471
+ function completedResult(
472
+ receipt: RuntimeStepReceipt | null,
473
+ resultReceiptKey: string,
474
+ ): ToolExecuteResult | null {
475
+ if (receipt?.status !== 'completed' && receipt?.status !== 'skipped') {
476
+ return null;
477
+ }
478
+ if (isSerializedToolExecuteResult(receipt.output)) {
479
+ return deserializeToolExecuteResult(receipt.output);
480
+ }
481
+ if (isToolExecuteResult(receipt.output)) {
482
+ return receipt.output;
483
+ }
484
+ throw new Error(
485
+ `Completed Tool Result Receipt ${resultReceiptKey} has no valid serialized ToolExecuteResult.`,
486
+ );
487
+ }
488
+
489
+ function normalizeExecutionLock(
490
+ input: RuntimeStepToolResultReceiptsInput['executionLock'],
491
+ ): Required<NonNullable<RuntimeStepToolResultReceiptsInput['executionLock']>> {
492
+ const ttlMs = input?.ttlMs ?? DEFAULT_LOCK_TTL_MS;
493
+ const waitMs = input?.waitMs ?? DEFAULT_LOCK_WAIT_MS;
494
+ const pollMs = input?.pollMs ?? DEFAULT_LOCK_POLL_MS;
495
+ for (const [name, value] of [
496
+ ['ttlMs', ttlMs],
497
+ ['waitMs', waitMs],
498
+ ['pollMs', pollMs],
499
+ ] as const) {
500
+ if (!Number.isFinite(value) || value <= 0) {
501
+ throw new Error(
502
+ `Tool Result Receipt execution lock ${name} must be positive.`,
503
+ );
504
+ }
505
+ }
506
+ return {
507
+ required: input?.required === true,
508
+ ttlMs: Math.floor(ttlMs),
509
+ waitMs: Math.floor(waitMs),
510
+ pollMs: Math.floor(pollMs),
511
+ };
512
+ }
513
+
514
+ function required(value: string, name: string): string {
515
+ const normalized = value?.trim();
516
+ if (!normalized) throw new Error(`Tool Result Receipt requires a ${name}.`);
517
+ return normalized;
518
+ }
519
+
520
+ async function defaultSleep(milliseconds: number): Promise<void> {
521
+ await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
522
+ }
@@ -0,0 +1,165 @@
1
+ import type { ToolExecuteResult } from '../tool-result';
2
+ import type {
3
+ ToolCallDispatcher,
4
+ ToolCallReceiptLease,
5
+ ToolResultReceipts,
6
+ } from './contract';
7
+
8
+ type ReceiptState =
9
+ | {
10
+ kind: 'running';
11
+ lease: ToolCallReceiptLease;
12
+ settled: Promise<ToolExecuteResult>;
13
+ resolve(result: ToolExecuteResult): void;
14
+ reject(error: unknown): void;
15
+ }
16
+ | { kind: 'completed'; result: ToolExecuteResult }
17
+ | { kind: 'failed'; error: unknown };
18
+
19
+ /** Test-only adapters that model the public lifecycle seams. */
20
+ export function createMemoryToolResultReceipts(): ToolResultReceipts & {
21
+ entries(): ReadonlyMap<string, ToolExecuteResult>;
22
+ heartbeats(): readonly { resultReceiptKey: string; leaseId: string }[];
23
+ failures(): readonly { resultReceiptKey: string; error: unknown }[];
24
+ } {
25
+ const states = new Map<string, ReceiptState>();
26
+ const completed = new Map<string, ToolExecuteResult>();
27
+ const heartbeats: Array<{ resultReceiptKey: string; leaseId: string }> = [];
28
+ const failures: Array<{ resultReceiptKey: string; error: unknown }> = [];
29
+ let nextLease = 0;
30
+
31
+ return {
32
+ async claim({ resultReceiptKey, ownerRunId, force }) {
33
+ const existing = states.get(resultReceiptKey);
34
+ if (!force && existing?.kind === 'completed') {
35
+ return { kind: 'completed', result: existing.result };
36
+ }
37
+ if (existing?.kind === 'running') return { kind: 'following' };
38
+ let resolve!: (result: ToolExecuteResult) => void;
39
+ let reject!: (error: unknown) => void;
40
+ const settled = new Promise<ToolExecuteResult>(
41
+ (resolvePromise, rejectPromise) => {
42
+ resolve = resolvePromise;
43
+ reject = rejectPromise;
44
+ },
45
+ );
46
+ // A receipt may fail before a follower attaches. Keep that durable
47
+ // failure from becoming an unhandled process rejection; recover() still
48
+ // returns the same rejected promise to a real follower.
49
+ void settled.catch(() => undefined);
50
+ const lease = { leaseId: `lease-${++nextLease}`, ownerRunId };
51
+ states.set(resultReceiptKey, {
52
+ kind: 'running',
53
+ lease,
54
+ settled,
55
+ resolve,
56
+ reject,
57
+ });
58
+ return { kind: 'owned', lease };
59
+ },
60
+ async recover({ resultReceiptKey }) {
61
+ const state = states.get(resultReceiptKey);
62
+ if (state?.kind === 'completed') return state.result;
63
+ if (state?.kind === 'failed') throw state.error;
64
+ if (state?.kind === 'running') return await state.settled;
65
+ throw new Error(`No Tool Result Receipt exists for ${resultReceiptKey}.`);
66
+ },
67
+ async heartbeat({ resultReceiptKey, lease }) {
68
+ heartbeats.push({ resultReceiptKey, leaseId: lease.leaseId });
69
+ const state = states.get(resultReceiptKey);
70
+ if (state?.kind === 'completed') return 'completed';
71
+ if (
72
+ state?.kind !== 'running' ||
73
+ state.lease.leaseId !== lease.leaseId ||
74
+ state.lease.ownerRunId !== lease.ownerRunId
75
+ ) {
76
+ return 'lost';
77
+ }
78
+ return 'active';
79
+ },
80
+ async complete({ resultReceiptKey, lease, result }) {
81
+ const state = states.get(resultReceiptKey);
82
+ if (state?.kind === 'completed') {
83
+ return { kind: 'existing', result: state.result };
84
+ }
85
+ if (
86
+ state?.kind !== 'running' ||
87
+ state.lease.leaseId !== lease.leaseId ||
88
+ state.lease.ownerRunId !== lease.ownerRunId
89
+ ) {
90
+ throw new Error(`Cannot complete unowned receipt ${resultReceiptKey}.`);
91
+ }
92
+ states.set(resultReceiptKey, { kind: 'completed', result });
93
+ completed.set(resultReceiptKey, result);
94
+ state.resolve(result);
95
+ return { kind: 'stored', result };
96
+ },
97
+ async fail({ resultReceiptKey, lease, error }) {
98
+ const state = states.get(resultReceiptKey);
99
+ if (
100
+ state?.kind === 'running' &&
101
+ state.lease.leaseId === lease.leaseId &&
102
+ state.lease.ownerRunId === lease.ownerRunId
103
+ ) {
104
+ states.set(resultReceiptKey, { kind: 'failed', error });
105
+ state.reject(error);
106
+ }
107
+ failures.push({ resultReceiptKey, error });
108
+ },
109
+ entries() {
110
+ return completed;
111
+ },
112
+ heartbeats() {
113
+ return heartbeats;
114
+ },
115
+ failures() {
116
+ return failures;
117
+ },
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Models the server-side operation fence: multiple receipt representations
123
+ * may share one provider operation key while keeping separate result receipts.
124
+ */
125
+ export function createSingleFlightToolCallDispatcher(input: {
126
+ dispatch(
127
+ input: Parameters<ToolCallDispatcher['dispatch']>[0],
128
+ ): Promise<ToolExecuteResult>;
129
+ }): ToolCallDispatcher & {
130
+ dispatches(): readonly Parameters<ToolCallDispatcher['dispatch']>[0][];
131
+ } {
132
+ const completed = new Map<string, ToolExecuteResult>();
133
+ const inFlight = new Map<string, Promise<ToolExecuteResult>>();
134
+ const dispatches: Parameters<ToolCallDispatcher['dispatch']>[0][] = [];
135
+ return {
136
+ async dispatch(call) {
137
+ // Receiptless calls are intentionally not single-flighted: they make no
138
+ // provider-operation claim and every invocation reaches the adapter.
139
+ if (!call.providerOperationKey) {
140
+ dispatches.push(call);
141
+ return await input.dispatch(call);
142
+ }
143
+ const providerOperationKey = call.providerOperationKey;
144
+ const cached = completed.get(providerOperationKey);
145
+ if (cached) return cached;
146
+ const owned = inFlight.get(providerOperationKey);
147
+ if (owned) return await owned;
148
+ dispatches.push(call);
149
+ const work = input
150
+ .dispatch(call)
151
+ .then((result) => {
152
+ completed.set(providerOperationKey, result);
153
+ return result;
154
+ })
155
+ .finally(() => {
156
+ inFlight.delete(providerOperationKey);
157
+ });
158
+ inFlight.set(providerOperationKey, work);
159
+ return await work;
160
+ },
161
+ dispatches() {
162
+ return dispatches;
163
+ },
164
+ };
165
+ }
package/dist/cli/index.js CHANGED
@@ -1043,7 +1043,7 @@ var SDK_RELEASE = {
1043
1043
  // available at toolResponse.rawV2 while toolResponse.raw and all declared
1044
1044
  // getters keep their established compatibility behavior.
1045
1045
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
1046
- version: "0.3.43",
1046
+ version: "0.3.45",
1047
1047
  updateSummary: "Automatic CLI updates are now enabled by default. To opt out, run `deepline settings autoupdate off`; use `deepline settings autoupdate on` to re-enable updates or `deepline settings autoupdate pin <version>` to hold an exact release. This release also adds raw-v2 tool responses at toolResponse.rawV2 while preserving existing toolResponse.raw and declared getters.",
1048
1048
  packageCapabilities: {
1049
1049
  updatePreferences: 1
@@ -1029,7 +1029,7 @@ var SDK_RELEASE = {
1029
1029
  // available at toolResponse.rawV2 while toolResponse.raw and all declared
1030
1030
  // getters keep their established compatibility behavior.
1031
1031
  // 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
1032
- version: "0.3.43",
1032
+ version: "0.3.45",
1033
1033
  updateSummary: "Automatic CLI updates are now enabled by default. To opt out, run `deepline settings autoupdate off`; use `deepline settings autoupdate on` to re-enable updates or `deepline settings autoupdate pin <version>` to hold an exact release. This release also adds raw-v2 tool responses at toolResponse.rawV2 while preserving existing toolResponse.raw and declared getters.",
1034
1034
  packageCapabilities: {
1035
1035
  updatePreferences: 1