@fabric-harness/sdk 1.14.0 → 2.0.1

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 (91) hide show
  1. package/dist/action.d.ts +69 -0
  2. package/dist/action.d.ts.map +1 -0
  3. package/dist/action.js +119 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/attachment-store-contract.d.ts +34 -0
  6. package/dist/attachment-store-contract.d.ts.map +1 -0
  7. package/dist/attachment-store-contract.js +0 -0
  8. package/dist/attachment-store-contract.js.map +1 -0
  9. package/dist/attachment-store.d.ts +107 -0
  10. package/dist/attachment-store.d.ts.map +1 -0
  11. package/dist/attachment-store.js +164 -0
  12. package/dist/attachment-store.js.map +1 -0
  13. package/dist/channel.d.ts +8 -1
  14. package/dist/channel.d.ts.map +1 -1
  15. package/dist/channel.js.map +1 -1
  16. package/dist/conversation-projection.d.ts +52 -0
  17. package/dist/conversation-projection.d.ts.map +1 -0
  18. package/dist/conversation-projection.js +207 -0
  19. package/dist/conversation-projection.js.map +1 -0
  20. package/dist/conversation-stream-contract.d.ts +37 -0
  21. package/dist/conversation-stream-contract.d.ts.map +1 -0
  22. package/dist/conversation-stream-contract.js +453 -0
  23. package/dist/conversation-stream-contract.js.map +1 -0
  24. package/dist/conversation-stream.d.ts +141 -0
  25. package/dist/conversation-stream.d.ts.map +1 -0
  26. package/dist/conversation-stream.js +159 -0
  27. package/dist/conversation-stream.js.map +1 -0
  28. package/dist/delivered-message.d.ts +90 -0
  29. package/dist/delivered-message.d.ts.map +1 -0
  30. package/dist/delivered-message.js +154 -0
  31. package/dist/delivered-message.js.map +1 -0
  32. package/dist/dispatch.d.ts +16 -3
  33. package/dist/dispatch.d.ts.map +1 -1
  34. package/dist/dispatch.js +5 -1
  35. package/dist/dispatch.js.map +1 -1
  36. package/dist/history.d.ts +27 -0
  37. package/dist/history.d.ts.map +1 -1
  38. package/dist/history.js +94 -0
  39. package/dist/history.js.map +1 -1
  40. package/dist/in-memory-submission-store.d.ts +53 -0
  41. package/dist/in-memory-submission-store.d.ts.map +1 -0
  42. package/dist/in-memory-submission-store.js +340 -0
  43. package/dist/in-memory-submission-store.js.map +1 -0
  44. package/dist/index.d.ts +24 -3
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +13 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/persistent-agent.d.ts +6 -0
  49. package/dist/persistent-agent.d.ts.map +1 -1
  50. package/dist/persistent-agent.js +6 -0
  51. package/dist/persistent-agent.js.map +1 -1
  52. package/dist/session.d.ts +13 -0
  53. package/dist/session.d.ts.map +1 -1
  54. package/dist/session.js +78 -2
  55. package/dist/session.js.map +1 -1
  56. package/dist/strict.d.ts +24 -3
  57. package/dist/strict.d.ts.map +1 -1
  58. package/dist/strict.js +13 -2
  59. package/dist/strict.js.map +1 -1
  60. package/dist/submission-context.d.ts +26 -0
  61. package/dist/submission-context.d.ts.map +1 -0
  62. package/dist/submission-context.js +20 -0
  63. package/dist/submission-context.js.map +1 -0
  64. package/dist/submission-runner.d.ts +150 -0
  65. package/dist/submission-runner.d.ts.map +1 -0
  66. package/dist/submission-runner.js +767 -0
  67. package/dist/submission-runner.js.map +1 -0
  68. package/dist/submission-state.d.ts +50 -0
  69. package/dist/submission-state.d.ts.map +1 -0
  70. package/dist/submission-state.js +82 -0
  71. package/dist/submission-state.js.map +1 -0
  72. package/dist/submission-store-contract.d.ts +31 -0
  73. package/dist/submission-store-contract.d.ts.map +1 -0
  74. package/dist/submission-store-contract.js +730 -0
  75. package/dist/submission-store-contract.js.map +1 -0
  76. package/dist/submission-store.d.ts +365 -0
  77. package/dist/submission-store.d.ts.map +1 -0
  78. package/dist/submission-store.js +191 -0
  79. package/dist/submission-store.js.map +1 -0
  80. package/dist/submission-telemetry.d.ts +56 -0
  81. package/dist/submission-telemetry.d.ts.map +1 -0
  82. package/dist/submission-telemetry.js +36 -0
  83. package/dist/submission-telemetry.js.map +1 -0
  84. package/dist/testing-contracts.d.ts +17 -0
  85. package/dist/testing-contracts.d.ts.map +1 -0
  86. package/dist/testing-contracts.js +14 -0
  87. package/dist/testing-contracts.js.map +1 -0
  88. package/dist/types.d.ts +28 -1
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/package.json +5 -1
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Durable submission runner (v2 migration, phase A5).
3
+ *
4
+ * The execution engine over an {@link AgentSubmissionStore}: a claim loop
5
+ * that takes runnable session heads with lease-fenced CAS claims, an
6
+ * executor seam that applies the input to the instance session, and a
7
+ * reconciler that classifies interrupted work after a crash and produces
8
+ * exactly-once observable outcomes — completed work settles, repairable
9
+ * work resumes, unstarted work requeues, exhausted work fails with a
10
+ * terminal advisory. Ported from flue's node agent coordinator +
11
+ * `reconcileInterruptedSubmission`, adapted to the harness session model
12
+ * (SessionEntry DAG; `session.prompt({ submission })` is idempotent by
13
+ * submission id, so re-execution after a crash never double-applies input).
14
+ */
15
+ import { LEASE_DURATION_MS, createDispatchAgentSubmissionInput, submissionStoreSessionId, } from './submission-store.js';
16
+ import { materializeMessageAttachments, messageHasDataAttachments } from './attachment-store.js';
17
+ import { submissionSettlementEntryId } from './submission-state.js';
18
+ import { runWithSubmissionContext } from './submission-context.js';
19
+ import { emitSubmissionTelemetry } from './submission-telemetry.js';
20
+ // ─── Errors ─────────────────────────────────────────────────────────────────
21
+ export class SubmissionAbortedError extends Error {
22
+ code = 'SUBMISSION_ABORTED';
23
+ constructor() {
24
+ super('The agent submission was aborted.');
25
+ this.name = 'SubmissionAbortedError';
26
+ }
27
+ }
28
+ export class SubmissionTimeoutError extends Error {
29
+ code = 'SUBMISSION_TIMEOUT';
30
+ constructor() {
31
+ super('The agent submission exceeded its execution timeout.');
32
+ this.name = 'SubmissionTimeoutError';
33
+ }
34
+ }
35
+ export class SubmissionRetryExhaustedError extends Error {
36
+ code = 'SUBMISSION_RETRY_EXHAUSTED';
37
+ interruptedTools;
38
+ constructor(options) {
39
+ super(`The agent submission exceeded its maximum recovery attempts (${options.attemptCount}/${options.maxAttempts}).`);
40
+ this.name = 'SubmissionRetryExhaustedError';
41
+ this.interruptedTools = options.interruptedTools;
42
+ }
43
+ }
44
+ export class SubmissionInterruptedError extends Error {
45
+ code = 'SUBMISSION_INTERRUPTED';
46
+ phase;
47
+ constructor(phase, detail) {
48
+ super(phase === 'retry_exhausted_before_input'
49
+ ? `The agent submission was interrupted repeatedly before its input could be applied${detail ? ` (${detail})` : ''}.`
50
+ : 'The agent submission was interrupted after its input was applied and could not be recovered.');
51
+ this.name = 'SubmissionInterruptedError';
52
+ this.phase = phase;
53
+ }
54
+ }
55
+ export function createSubmissionRunner(options) {
56
+ const { submissions, executor } = options;
57
+ const onError = options.onError ??
58
+ ((context, error) => {
59
+ console.error(`[fabric-harness:submission-runner] ${context}:`, error);
60
+ });
61
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? 10_000;
62
+ const leaseScanIntervalMs = options.leaseScanIntervalMs ?? 15_000;
63
+ /** Owner id for lease-based submission ownership. */
64
+ const ownerId = crypto.randomUUID();
65
+ const activeSubmissions = new Map();
66
+ /** Local settlement notifications for same-process waiters. */
67
+ const localSettlements = new Map();
68
+ const settlementListeners = new Map();
69
+ let heartbeatInterval = null;
70
+ let leaseScanInterval = null;
71
+ // ── Wake machinery (see flue's claim loop for the mode discussion) ──
72
+ let wakeResolve = null;
73
+ let wakePromise = null;
74
+ let claimPassRunning = false;
75
+ let wakeRequested = false;
76
+ let loopRunning = false;
77
+ let stopping = false;
78
+ function resetWakePromise() {
79
+ wakePromise = new Promise((resolve) => {
80
+ wakeResolve = resolve;
81
+ });
82
+ }
83
+ function wake() {
84
+ if (claimPassRunning) {
85
+ wakeRequested = true;
86
+ return;
87
+ }
88
+ if (wakeResolve) {
89
+ const resolve = wakeResolve;
90
+ wakeResolve = null;
91
+ resolve();
92
+ }
93
+ }
94
+ function notifySettled(submissionId, settlement, submission) {
95
+ if (submission) {
96
+ emitSubmissionTelemetry(options.telemetry, {
97
+ type: 'submission_settled',
98
+ ...telemetryBase(submission),
99
+ ...(submission.attemptId !== undefined ? { attemptId: submission.attemptId } : {}),
100
+ outcome: settlement.outcome,
101
+ ...(settlement.error !== undefined ? { error: settlement.error } : {}),
102
+ durationMs: Math.max(0, Date.now() - submission.acceptedAt),
103
+ attemptCount: submission.attemptCount,
104
+ }, telemetryError);
105
+ }
106
+ localSettlements.set(submissionId, settlement);
107
+ const listeners = settlementListeners.get(submissionId);
108
+ if (listeners) {
109
+ settlementListeners.delete(submissionId);
110
+ for (const listener of listeners) {
111
+ try {
112
+ listener(settlement);
113
+ }
114
+ catch (error) {
115
+ onError('settlement listener failed', error);
116
+ }
117
+ }
118
+ }
119
+ try {
120
+ options.onSettled?.(submissionId, settlement);
121
+ }
122
+ catch (error) {
123
+ onError('onSettled callback failed', error);
124
+ }
125
+ }
126
+ function serializeError(error) {
127
+ if (error instanceof Error)
128
+ return error.message;
129
+ return String(error);
130
+ }
131
+ function attemptRefOf(submission) {
132
+ if (!submission.attemptId)
133
+ return null;
134
+ return { submissionId: submission.submissionId, attemptId: submission.attemptId };
135
+ }
136
+ function telemetryBase(submission) {
137
+ return {
138
+ submissionId: submission.submissionId,
139
+ kind: submission.kind,
140
+ agent: submission.input.agent,
141
+ instanceId: submission.input.id,
142
+ session: submission.input.session,
143
+ at: Date.now(),
144
+ ...(submission.input.tenantId !== undefined ? { tenantId: submission.input.tenantId } : {}),
145
+ ...(submission.input.actor !== undefined ? { actor: submission.input.actor } : {}),
146
+ };
147
+ }
148
+ const telemetryError = (error) => onError('telemetry sink failed', error);
149
+ // ── Settlement ───────────────────────────────────────────────────────
150
+ /**
151
+ * Two-phase settlement for direct submissions: reserve the exact canonical
152
+ * record as an obligation (running → terminalizing CAS), append the
153
+ * canonical entry (idempotent by deterministic id), then finalize
154
+ * (terminalizing → settled, obligation deleted). A crash between the
155
+ * phases is repaired by the pending-settlement pass in reconciliation.
156
+ */
157
+ async function settleDirect(submission, attempt, outcome, result, error) {
158
+ const recordId = submissionSettlementEntryId(submission.submissionId);
159
+ const pending = (await submissions.listPendingSubmissionSettlements()).find((obligation) => obligation.submissionId === submission.submissionId);
160
+ const record = pending?.record ??
161
+ {
162
+ type: 'submission_settled',
163
+ submissionId: submission.submissionId,
164
+ outcome,
165
+ ...(outcome === 'completed' && result !== undefined
166
+ ? { resultText: typeof result === 'string' ? result : JSON.stringify(result) }
167
+ : {}),
168
+ ...(outcome !== 'completed' ? { error: serializeError(error ?? new Error('unknown error')) } : {}),
169
+ };
170
+ const obligation = pending ?? (await submissions.reserveSubmissionSettlement(attempt, { recordId, record }));
171
+ if (!obligation)
172
+ return;
173
+ await executor.appendSettlement(submission, obligation);
174
+ await submissions.finalizeSubmissionSettlement(attempt, recordId);
175
+ notifySettled(submission.submissionId, {
176
+ outcome: obligation.record.outcome,
177
+ ...(result !== undefined ? { result } : {}),
178
+ ...(obligation.record.error !== undefined ? { error: obligation.record.error } : {}),
179
+ }, submission);
180
+ }
181
+ async function settleDispatch(submission, attempt, outcome, result, error) {
182
+ if (outcome === 'completed') {
183
+ if (!(await submissions.completeSubmission(attempt)))
184
+ return;
185
+ }
186
+ else if (!(await submissions.failSubmission(attempt, error ?? new SubmissionAbortedError()))) {
187
+ return;
188
+ }
189
+ notifySettled(submission.submissionId, {
190
+ outcome,
191
+ ...(result !== undefined ? { result } : {}),
192
+ ...(outcome !== 'completed' ? { error: serializeError(error ?? new SubmissionAbortedError()) } : {}),
193
+ }, submission);
194
+ }
195
+ async function settle(submission, attempt, outcome, result, error) {
196
+ if (submission.kind === 'direct') {
197
+ await settleDirect(submission, attempt, outcome, result, error);
198
+ }
199
+ else {
200
+ await settleDispatch(submission, attempt, outcome, result, error);
201
+ }
202
+ }
203
+ /** Settle as the distinct aborted outcome, with a visible terminal advisory. */
204
+ async function settleAborted(submission, attempt) {
205
+ const error = new SubmissionAbortedError();
206
+ try {
207
+ await executor.recordTerminal(submission, {
208
+ submissionId: submission.submissionId,
209
+ kind: submission.kind,
210
+ reason: 'aborted',
211
+ message: error.message,
212
+ });
213
+ }
214
+ catch (advisoryError) {
215
+ onError(`abort advisory failed for ${submission.submissionId}`, advisoryError);
216
+ }
217
+ await settle(submission, attempt, 'aborted', undefined, error);
218
+ }
219
+ async function failInterrupted(submission, attempt, reason, createError) {
220
+ // Best-effort terminal record: a persistent save failure must not wedge
221
+ // the submission in an infinite reconciliation loop.
222
+ let interruptedTools;
223
+ try {
224
+ interruptedTools = await executor.recordTerminal(submission, {
225
+ submissionId: submission.submissionId,
226
+ kind: submission.kind,
227
+ reason,
228
+ message: createError(undefined).message,
229
+ });
230
+ }
231
+ catch (terminalError) {
232
+ onError(`terminal record failed for ${submission.submissionId}`, terminalError);
233
+ }
234
+ const error = createError(interruptedTools?.length ? interruptedTools : undefined);
235
+ await settle(submission, attempt, 'failed', undefined, error);
236
+ }
237
+ // ── Processing ───────────────────────────────────────────────────────
238
+ async function processClaimed(claimed, signal) {
239
+ const attempt = attemptRefOf(claimed);
240
+ if (!attempt)
241
+ return;
242
+ const persisted = await submissions.getSubmission(claimed.submissionId);
243
+ if (persisted?.status !== 'running' || persisted.attemptId !== attempt.attemptId)
244
+ return;
245
+ await submissions.insertAttemptMarker(attempt);
246
+ emitSubmissionTelemetry(options.telemetry, { type: 'submission_started', ...telemetryBase(claimed), attemptId: attempt.attemptId, attemptCount: persisted.attemptCount }, telemetryError);
247
+ try {
248
+ // Pre-execution abort: a queued submission that was abort-flagged is
249
+ // still claimed (attempt-based settlement is uniform); settle it as
250
+ // aborted before running any model work.
251
+ if (persisted.abortRequestedAt !== undefined) {
252
+ await settleAborted(claimed, attempt);
253
+ return;
254
+ }
255
+ let result;
256
+ try {
257
+ // Ambient correlation: tool wrappers / telemetry inside the turn read
258
+ // the owning submission via currentSubmissionContext().
259
+ result = await runWithSubmissionContext({
260
+ submissionId: claimed.submissionId,
261
+ attemptId: attempt.attemptId,
262
+ agent: claimed.input.agent,
263
+ instanceId: claimed.input.id,
264
+ session: claimed.input.session,
265
+ ...(claimed.input.tenantId !== undefined ? { tenantId: claimed.input.tenantId } : {}),
266
+ ...(claimed.input.actor !== undefined ? { actor: claimed.input.actor } : {}),
267
+ }, () => executor.execute(claimed, {
268
+ attempt,
269
+ signal,
270
+ onInputApplied: async () => {
271
+ if (!(await submissions.markSubmissionInputApplied(attempt))) {
272
+ throw new Error('[fabric-harness] Submission attempt lost ownership before input application.');
273
+ }
274
+ },
275
+ }));
276
+ }
277
+ catch (error) {
278
+ // Graceful shutdown: the submission stays running; its expired
279
+ // lease triggers reclamation on next startup.
280
+ if (stopping && isAbortError(error))
281
+ throw error;
282
+ if (signal.reason instanceof SubmissionAbortedError) {
283
+ await settleAborted(claimed, attempt);
284
+ return;
285
+ }
286
+ await settle(claimed, attempt, 'failed', undefined, error);
287
+ throw error;
288
+ }
289
+ await settle(claimed, attempt, 'completed', result);
290
+ }
291
+ finally {
292
+ await submissions.deleteAttemptMarker(attempt).catch((error) => {
293
+ onError(`attempt marker cleanup failed for ${claimed.submissionId}`, error);
294
+ });
295
+ }
296
+ }
297
+ function spawnSubmissionTask(claimed) {
298
+ const controller = new AbortController();
299
+ const task = processClaimed(claimed, controller.signal)
300
+ .catch((error) => {
301
+ if (isAbortError(error))
302
+ return;
303
+ onError(`processing failed for ${claimed.submissionId}`, error);
304
+ })
305
+ .finally(() => {
306
+ activeSubmissions.delete(claimed.submissionId);
307
+ wake();
308
+ });
309
+ activeSubmissions.set(claimed.submissionId, { task, abort: controller });
310
+ }
311
+ // ── Claim loop ───────────────────────────────────────────────────────
312
+ async function runClaimPass() {
313
+ await markUnreadyCanonical();
314
+ await periodicLeaseScan();
315
+ const runnable = await submissions.listRunnableSubmissions();
316
+ let progressed = false;
317
+ for (const submission of runnable) {
318
+ if (activeSubmissions.has(submission.submissionId))
319
+ continue;
320
+ const claimed = await submissions.claimSubmission({
321
+ submissionId: submission.submissionId,
322
+ attemptId: crypto.randomUUID(),
323
+ ownerId,
324
+ leaseExpiresAt: Date.now() + LEASE_DURATION_MS,
325
+ });
326
+ if (!claimed)
327
+ continue;
328
+ progressed = true;
329
+ spawnSubmissionTask(claimed);
330
+ }
331
+ return progressed;
332
+ }
333
+ async function claimLoop() {
334
+ while (!stopping) {
335
+ claimPassRunning = true;
336
+ try {
337
+ let progressed;
338
+ do {
339
+ wakeRequested = false;
340
+ progressed = await runClaimPass();
341
+ } while (progressed || wakeRequested);
342
+ }
343
+ catch (error) {
344
+ onError('claim pass failed, retrying', error);
345
+ await new Promise((resolve) => setTimeout(resolve, 1000));
346
+ wakeRequested = true;
347
+ }
348
+ finally {
349
+ // Reset the sleep promise BEFORE clearing claimPassRunning so a wake
350
+ // arriving in the gap resolves the new promise, not a stale one.
351
+ resetWakePromise();
352
+ claimPassRunning = false;
353
+ }
354
+ if (wakeRequested) {
355
+ wakeRequested = false;
356
+ continue;
357
+ }
358
+ await wakePromise;
359
+ }
360
+ }
361
+ function ensureClaimLoop() {
362
+ if (loopRunning)
363
+ return;
364
+ loopRunning = true;
365
+ void claimLoop().catch((error) => {
366
+ onError('fatal claim loop error', error);
367
+ loopRunning = false;
368
+ });
369
+ if (!heartbeatInterval) {
370
+ heartbeatInterval = setInterval(() => {
371
+ const ids = [...activeSubmissions.keys()];
372
+ if (ids.length === 0)
373
+ return;
374
+ submissions.renewLeases(ownerId, ids).catch((error) => {
375
+ onError('lease renewal failed', error);
376
+ });
377
+ }, heartbeatIntervalMs);
378
+ heartbeatInterval.unref?.();
379
+ }
380
+ if (!leaseScanInterval) {
381
+ leaseScanInterval = setInterval(() => wake(), leaseScanIntervalMs);
382
+ leaseScanInterval.unref?.();
383
+ }
384
+ }
385
+ // ── Reconciliation ───────────────────────────────────────────────────
386
+ let lastLeaseScanAt = 0;
387
+ let reconcilePassInFlight = null;
388
+ async function periodicLeaseScan() {
389
+ const now = Date.now();
390
+ if (now - lastLeaseScanAt < leaseScanIntervalMs)
391
+ return;
392
+ lastLeaseScanAt = now;
393
+ await reconcileRunningSubmissions();
394
+ }
395
+ /** Single-flight expired-lease reconciliation (see flue for the race this closes). */
396
+ function reconcileRunningSubmissions() {
397
+ reconcilePassInFlight ??= runReconciliationPass().finally(() => {
398
+ reconcilePassInFlight = null;
399
+ });
400
+ return reconcilePassInFlight;
401
+ }
402
+ /** Admission crash-window repair: canonical readiness for unready queued rows. */
403
+ async function markUnreadyCanonical() {
404
+ for (const submission of await submissions.listUnreadySubmissions()) {
405
+ try {
406
+ await submissions.markSubmissionCanonicalReady(submission.submissionId);
407
+ }
408
+ catch (error) {
409
+ onError(`canonical readiness failed for ${submission.submissionId}`, error);
410
+ }
411
+ }
412
+ }
413
+ async function runReconciliationPass() {
414
+ await markUnreadyCanonical();
415
+ // Finalize settlements that crashed between reserve and finalize.
416
+ for (const pending of await submissions.listPendingSubmissionSettlements()) {
417
+ const submission = await submissions.getSubmission(pending.submissionId);
418
+ if (!submission || submission.kind !== 'direct')
419
+ continue;
420
+ if (activeSubmissions.has(submission.submissionId) || submission.leaseExpiresAt > Date.now())
421
+ continue;
422
+ try {
423
+ const attempt = { submissionId: pending.submissionId, attemptId: pending.attemptId };
424
+ await executor.appendSettlement(submission, pending);
425
+ await submissions.finalizeSubmissionSettlement(attempt, pending.recordId);
426
+ notifySettled(submission.submissionId, {
427
+ outcome: pending.record.outcome,
428
+ ...(pending.record.error !== undefined ? { error: pending.record.error } : {}),
429
+ }, submission);
430
+ }
431
+ catch (error) {
432
+ onError(`pending settlement finalization failed for ${pending.submissionId}`, error);
433
+ }
434
+ }
435
+ for (const submission of await submissions.listExpiredSubmissions()) {
436
+ // Never reconcile our own live submission (transient heartbeat failure
437
+ // can expire a lease mid-flight); a second concurrent task for the same
438
+ // session is exactly the corruption leases exist to prevent.
439
+ if (activeSubmissions.has(submission.submissionId))
440
+ continue;
441
+ try {
442
+ const replacement = await reconcileInterrupted(submission);
443
+ if (replacement)
444
+ spawnSubmissionTask(replacement);
445
+ }
446
+ catch (error) {
447
+ onError(`reconciliation failed for ${submission.submissionId}`, error);
448
+ }
449
+ }
450
+ }
451
+ /**
452
+ * Shared reconciliation decision tree for an interrupted running
453
+ * submission (flue's `reconcileInterruptedSubmission`, adapted). Returns
454
+ * the replacement submission when a new attempt was claimed; `undefined`
455
+ * for every other outcome (all durable side effects already applied).
456
+ */
457
+ async function reconcileInterrupted(submission) {
458
+ const attempt = attemptRefOf(submission);
459
+ if (!attempt)
460
+ return undefined;
461
+ try {
462
+ return await reconcileInterruptedAttempt(submission, attempt);
463
+ }
464
+ finally {
465
+ // The interrupted attempt is dead on every branch (settled, requeued,
466
+ // or replaced by a fresh attempt that inserts its own marker) — drop
467
+ // its durable attempt marker so markers never leak across crashes.
468
+ await submissions.deleteAttemptMarker(attempt).catch((error) => {
469
+ onError(`stale attempt marker cleanup failed for ${submission.submissionId}`, error);
470
+ });
471
+ }
472
+ }
473
+ async function reconcileInterruptedAttempt(submission, attempt) {
474
+ // Completed canonical work settles as success unconditionally — the
475
+ // retry budget and timeout gate only the retry/requeue branches below,
476
+ // and must never discard finished provider work.
477
+ const state = await executor.inspect(submission);
478
+ if (state === 'completed') {
479
+ await settle(submission, attempt, 'completed');
480
+ return undefined;
481
+ }
482
+ // A crash-interrupted abort is never resurrected; and the budget/timeout
483
+ // cannot pre-empt the distinct aborted outcome.
484
+ if (submission.abortRequestedAt !== undefined) {
485
+ await settleAborted(submission, attempt);
486
+ return undefined;
487
+ }
488
+ if (submission.attemptCount >= submission.maxRetry) {
489
+ await failInterrupted(submission, attempt, 'exhausted_retry_budget', (interruptedTools) => submission.inputAppliedAt === undefined
490
+ ? new SubmissionInterruptedError('retry_exhausted_before_input', `${submission.attemptCount}/${submission.maxRetry} attempts`)
491
+ : new SubmissionRetryExhaustedError({
492
+ attemptCount: submission.attemptCount,
493
+ maxAttempts: submission.maxRetry,
494
+ ...(interruptedTools ? { interruptedTools } : {}),
495
+ }));
496
+ return undefined;
497
+ }
498
+ if (submission.timeoutAt > 0 && Date.now() >= submission.timeoutAt) {
499
+ await failInterrupted(submission, attempt, 'exceeded_timeout', () => new SubmissionTimeoutError());
500
+ return undefined;
501
+ }
502
+ // Canonical input exists but the input-applied marker did not land (the
503
+ // crash window between the entry write and the marker CAS). Re-acquire
504
+ // the attempt, install the marker, and resume from canonical state.
505
+ if (submission.inputAppliedAt === undefined && state !== 'absent') {
506
+ const replacement = await submissions.replaceSubmissionAttempt(attempt, crypto.randomUUID(), {
507
+ ownerId,
508
+ leaseExpiresAt: Date.now() + LEASE_DURATION_MS,
509
+ });
510
+ if (replacement?.attemptId) {
511
+ const replacementAttempt = { submissionId: replacement.submissionId, attemptId: replacement.attemptId };
512
+ if (!(await submissions.markSubmissionInputApplied(replacementAttempt)))
513
+ return undefined;
514
+ return replacement;
515
+ }
516
+ return undefined;
517
+ }
518
+ // Resumable progress ('continuable': trailing tool batch repaired at the
519
+ // next prompt) or the one accepted provider-redispatch window
520
+ // ('uncertain': nothing observable persisted). Acquire the replacement
521
+ // attempt (the fencing CAS) before any recovery side effect.
522
+ if (state === 'continuable' || state === 'uncertain') {
523
+ const replacement = await submissions.replaceSubmissionAttempt(attempt, crypto.randomUUID(), {
524
+ ownerId,
525
+ leaseExpiresAt: Date.now() + LEASE_DURATION_MS,
526
+ });
527
+ if (!replacement?.attemptId)
528
+ return undefined;
529
+ return replacement;
530
+ }
531
+ // Only 'absent' remains. No canonical input: either requeue for a clean
532
+ // first attempt (marker never landed) or fail (marker landed but the
533
+ // canonical input could not be persisted before the crash).
534
+ if (submission.inputAppliedAt === undefined) {
535
+ await submissions.requeueSubmissionBeforeInputApplied(attempt);
536
+ return undefined;
537
+ }
538
+ await failInterrupted(submission, attempt, 'interrupted_after_input_application', () => new SubmissionInterruptedError('after_input_application'));
539
+ return undefined;
540
+ }
541
+ // ── Attachment materialization (pre-admission) ───────────────────────
542
+ /**
543
+ * Replace inline base64 attachments with durable refs before the payload
544
+ * is persisted. `admissionId` seeds the deterministic attachment ids
545
+ * (`att_<admissionId>_<index>`), so an exact redelivery of the same
546
+ * dispatch/submission id materializes to an identical payload.
547
+ */
548
+ async function materializeForAdmission(input, scope, admissionId) {
549
+ const store = options.attachments?.store;
550
+ if (!store || !messageHasDataAttachments(input.message))
551
+ return input;
552
+ const message = await materializeMessageAttachments(input.message, store, {
553
+ scope,
554
+ idPrefix: `att_${admissionId}`,
555
+ });
556
+ return { ...input, message };
557
+ }
558
+ // ── Public interface ─────────────────────────────────────────────────
559
+ const runner = {
560
+ async admitDispatch(input) {
561
+ if (stopping)
562
+ throw new Error('[fabric-harness] Submission runner is shutting down.');
563
+ await options.validateInput?.(createDispatchAgentSubmissionInput(input));
564
+ let admissionInput = await materializeForAdmission(input, submissionStoreSessionId(input), input.dispatchId);
565
+ if (admissionInput !== input) {
566
+ // Also point the legacy `input` field at the materialized message so
567
+ // the persisted payload does not retain a base64 copy through it.
568
+ admissionInput = { ...admissionInput, input: admissionInput.message };
569
+ }
570
+ const admission = await submissions.admitDispatch(admissionInput);
571
+ if (admission.kind !== 'submission')
572
+ return admission;
573
+ let submission = admission.submission;
574
+ if (submission.canonicalReadyAt === null) {
575
+ submission = (await submissions.markSubmissionCanonicalReady(submission.submissionId)) ?? submission;
576
+ emitSubmissionTelemetry(options.telemetry, { type: 'submission_accepted', ...telemetryBase(submission) }, telemetryError);
577
+ }
578
+ ensureClaimLoop();
579
+ wake();
580
+ return { kind: 'submission', submission };
581
+ },
582
+ async admitDirect(input) {
583
+ if (stopping)
584
+ throw new Error('[fabric-harness] Submission runner is shutting down.');
585
+ await options.validateInput?.(input);
586
+ const admissionInput = await materializeForAdmission(input, submissionStoreSessionId(input), input.submissionId);
587
+ const admitted = await submissions.admitDirect(admissionInput);
588
+ if (admitted.canonicalReadyAt === null) {
589
+ await submissions.markSubmissionCanonicalReady(admitted.submissionId);
590
+ emitSubmissionTelemetry(options.telemetry, { type: 'submission_accepted', ...telemetryBase(admitted) }, telemetryError);
591
+ }
592
+ ensureClaimLoop();
593
+ wake();
594
+ return admitted;
595
+ },
596
+ async requestAbort(storeSessionId) {
597
+ const affected = await submissions.requestSessionAbort(storeSessionId);
598
+ if (affected.length === 0)
599
+ return affected;
600
+ let hasInactive = false;
601
+ for (const submissionId of affected) {
602
+ const active = activeSubmissions.get(submissionId);
603
+ if (active)
604
+ active.abort.abort(new SubmissionAbortedError());
605
+ else
606
+ hasInactive = true;
607
+ }
608
+ ensureClaimLoop();
609
+ wake();
610
+ if (hasInactive) {
611
+ void reconcileRunningSubmissions().catch((error) => {
612
+ onError('reconcile after abort failed', error);
613
+ });
614
+ }
615
+ return affected;
616
+ },
617
+ async reconcile() {
618
+ if (!(await submissions.hasUnsettledSubmissions()))
619
+ return;
620
+ ensureClaimLoop();
621
+ await reconcileRunningSubmissions();
622
+ await runner.waitForIdle();
623
+ },
624
+ async waitForSettlement(submissionId, waitOptions = {}) {
625
+ const local = localSettlements.get(submissionId);
626
+ if (local)
627
+ return local;
628
+ const pollIntervalMs = waitOptions.pollIntervalMs ?? 250;
629
+ return new Promise((resolve, reject) => {
630
+ let done = false;
631
+ let timer = null;
632
+ const finish = (settlement) => {
633
+ if (done)
634
+ return;
635
+ done = true;
636
+ if (timer)
637
+ clearTimeout(timer);
638
+ waitOptions.signal?.removeEventListener('abort', onAbort);
639
+ resolve(settlement);
640
+ };
641
+ const onAbort = () => {
642
+ if (done)
643
+ return;
644
+ done = true;
645
+ if (timer)
646
+ clearTimeout(timer);
647
+ listeners?.delete(finish);
648
+ reject(waitOptions.signal?.reason instanceof Error ? waitOptions.signal.reason : new Error('waitForSettlement aborted'));
649
+ };
650
+ let listeners = settlementListeners.get(submissionId);
651
+ if (!listeners) {
652
+ listeners = new Set();
653
+ settlementListeners.set(submissionId, listeners);
654
+ }
655
+ listeners.add(finish);
656
+ waitOptions.signal?.addEventListener('abort', onAbort, { once: true });
657
+ // Cross-process fallback: poll the store; outcome precision comes
658
+ // from the canonical settlement record when one exists.
659
+ const poll = async () => {
660
+ if (done)
661
+ return;
662
+ try {
663
+ const submission = await submissions.getSubmission(submissionId);
664
+ if (submission?.status === 'settled') {
665
+ const canonical = await executor.getSettlement(submission).catch(() => null);
666
+ finish(canonical
667
+ ? {
668
+ outcome: canonical.outcome,
669
+ ...(canonical.resultText !== undefined ? { result: canonical.resultText } : {}),
670
+ ...(canonical.error !== undefined ? { error: canonical.error } : {}),
671
+ }
672
+ : submission.error !== undefined
673
+ ? { outcome: 'failed', error: submission.error }
674
+ : { outcome: 'completed' });
675
+ return;
676
+ }
677
+ }
678
+ catch (error) {
679
+ onError(`settlement poll failed for ${submissionId}`, error);
680
+ }
681
+ if (!done)
682
+ timer = setTimeout(poll, pollIntervalMs);
683
+ if (timer && typeof timer === 'object' && 'unref' in timer)
684
+ timer.unref();
685
+ };
686
+ void poll();
687
+ });
688
+ },
689
+ async waitForIdle() {
690
+ while (true) {
691
+ if (stopping)
692
+ return;
693
+ if (activeSubmissions.size > 0) {
694
+ await Promise.allSettled([...activeSubmissions.values()].map((entry) => entry.task));
695
+ }
696
+ if (stopping)
697
+ return;
698
+ // Let the claim loop's wake → claim pass cycle pick up newly
699
+ // runnable work from session-head advancement.
700
+ await new Promise((resolve) => setTimeout(resolve, 10));
701
+ if (activeSubmissions.size === 0) {
702
+ const runnable = await submissions.listRunnableSubmissions();
703
+ if (runnable.length === 0)
704
+ break;
705
+ if (stopping)
706
+ return;
707
+ wake();
708
+ }
709
+ }
710
+ },
711
+ async shutdown(timeoutMs = 30_000) {
712
+ if (stopping)
713
+ return;
714
+ stopping = true;
715
+ wake();
716
+ for (const { abort } of activeSubmissions.values()) {
717
+ abort.abort(new DOMException('Submission runner shutting down.', 'AbortError'));
718
+ }
719
+ if (activeSubmissions.size > 0) {
720
+ const settlement = Promise.allSettled([...activeSubmissions.values()].map((entry) => entry.task));
721
+ const timeout = new Promise((resolve) => {
722
+ const timer = setTimeout(resolve, timeoutMs);
723
+ void settlement.finally(() => clearTimeout(timer));
724
+ });
725
+ await Promise.race([settlement, timeout]);
726
+ }
727
+ if (heartbeatInterval) {
728
+ clearInterval(heartbeatInterval);
729
+ heartbeatInterval = null;
730
+ }
731
+ if (leaseScanInterval) {
732
+ clearInterval(leaseScanInterval);
733
+ leaseScanInterval = null;
734
+ }
735
+ if (activeSubmissions.size > 0) {
736
+ onError('shutdown timeout', new Error(`${activeSubmissions.size} submission(s) did not settle within ${timeoutMs}ms; leases will be reclaimed on next startup.`));
737
+ }
738
+ },
739
+ wake,
740
+ createDispatchQueue() {
741
+ return {
742
+ enqueue: async (input) => {
743
+ const admission = await runner.admitDispatch(input);
744
+ if (admission.kind === 'retained_receipt') {
745
+ return {
746
+ dispatchId: admission.receipt.submissionId,
747
+ acceptedAt: new Date(admission.receipt.acceptedAt).toISOString(),
748
+ };
749
+ }
750
+ if (admission.kind === 'conflict') {
751
+ throw new Error(`[fabric-harness] dispatch target agent "${input.agent}" rejected a conflicting dispatch replay.`);
752
+ }
753
+ return { dispatchId: admission.submission.submissionId, acceptedAt: input.acceptedAt };
754
+ },
755
+ };
756
+ },
757
+ };
758
+ return runner;
759
+ }
760
+ /** Store-session FIFO key of a submission (re-exported convenience). */
761
+ export function submissionSessionKey(input) {
762
+ return submissionStoreSessionId(input);
763
+ }
764
+ function isAbortError(error) {
765
+ return error instanceof DOMException && error.name === 'AbortError';
766
+ }
767
+ //# sourceMappingURL=submission-runner.js.map