@fabric-harness/sdk 1.14.0 → 2.0.0
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/action.d.ts +69 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +119 -0
- package/dist/action.js.map +1 -0
- package/dist/attachment-store-contract.d.ts +34 -0
- package/dist/attachment-store-contract.d.ts.map +1 -0
- package/dist/attachment-store-contract.js +0 -0
- package/dist/attachment-store-contract.js.map +1 -0
- package/dist/attachment-store.d.ts +107 -0
- package/dist/attachment-store.d.ts.map +1 -0
- package/dist/attachment-store.js +164 -0
- package/dist/attachment-store.js.map +1 -0
- package/dist/channel.d.ts +8 -1
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js.map +1 -1
- package/dist/conversation-projection.d.ts +52 -0
- package/dist/conversation-projection.d.ts.map +1 -0
- package/dist/conversation-projection.js +207 -0
- package/dist/conversation-projection.js.map +1 -0
- package/dist/conversation-stream-contract.d.ts +37 -0
- package/dist/conversation-stream-contract.d.ts.map +1 -0
- package/dist/conversation-stream-contract.js +453 -0
- package/dist/conversation-stream-contract.js.map +1 -0
- package/dist/conversation-stream.d.ts +141 -0
- package/dist/conversation-stream.d.ts.map +1 -0
- package/dist/conversation-stream.js +159 -0
- package/dist/conversation-stream.js.map +1 -0
- package/dist/delivered-message.d.ts +90 -0
- package/dist/delivered-message.d.ts.map +1 -0
- package/dist/delivered-message.js +154 -0
- package/dist/delivered-message.js.map +1 -0
- package/dist/dispatch.d.ts +16 -3
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +5 -1
- package/dist/dispatch.js.map +1 -1
- package/dist/history.d.ts +27 -0
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +94 -0
- package/dist/history.js.map +1 -1
- package/dist/in-memory-submission-store.d.ts +53 -0
- package/dist/in-memory-submission-store.d.ts.map +1 -0
- package/dist/in-memory-submission-store.js +340 -0
- package/dist/in-memory-submission-store.js.map +1 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/persistent-agent.d.ts +6 -0
- package/dist/persistent-agent.d.ts.map +1 -1
- package/dist/persistent-agent.js +6 -0
- package/dist/persistent-agent.js.map +1 -1
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +78 -2
- package/dist/session.js.map +1 -1
- package/dist/strict.d.ts +24 -3
- package/dist/strict.d.ts.map +1 -1
- package/dist/strict.js +13 -2
- package/dist/strict.js.map +1 -1
- package/dist/submission-context.d.ts +26 -0
- package/dist/submission-context.d.ts.map +1 -0
- package/dist/submission-context.js +20 -0
- package/dist/submission-context.js.map +1 -0
- package/dist/submission-runner.d.ts +150 -0
- package/dist/submission-runner.d.ts.map +1 -0
- package/dist/submission-runner.js +767 -0
- package/dist/submission-runner.js.map +1 -0
- package/dist/submission-state.d.ts +50 -0
- package/dist/submission-state.d.ts.map +1 -0
- package/dist/submission-state.js +82 -0
- package/dist/submission-state.js.map +1 -0
- package/dist/submission-store-contract.d.ts +31 -0
- package/dist/submission-store-contract.d.ts.map +1 -0
- package/dist/submission-store-contract.js +730 -0
- package/dist/submission-store-contract.js.map +1 -0
- package/dist/submission-store.d.ts +365 -0
- package/dist/submission-store.d.ts.map +1 -0
- package/dist/submission-store.js +191 -0
- package/dist/submission-store.js.map +1 -0
- package/dist/submission-telemetry.d.ts +56 -0
- package/dist/submission-telemetry.d.ts.map +1 -0
- package/dist/submission-telemetry.js +36 -0
- package/dist/submission-telemetry.js.map +1 -0
- package/dist/testing-contracts.d.ts +17 -0
- package/dist/testing-contracts.d.ts.map +1 -0
- package/dist/testing-contracts.js +14 -0
- package/dist/testing-contracts.js.map +1 -0
- package/dist/types.d.ts +28 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|