@ricsam/r5d-worker 0.0.123 → 0.0.125

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 (64) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/command-launcher.cjs +6 -2
  3. package/dist/cjs/main.cjs +496 -238
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/project-checkout-garbage.cjs +32 -3
  6. package/dist/cjs/project-workspace-state.cjs +51 -35
  7. package/dist/cjs/project-worktrees.cjs +79 -34
  8. package/dist/cjs/recovery-journal-protocol.cjs +56 -0
  9. package/dist/cjs/recovery-journal-runtime.cjs +133 -0
  10. package/dist/cjs/recovery-journal-thread.cjs +9 -0
  11. package/dist/cjs/recovery-journal.cjs +735 -0
  12. package/dist/cjs/recovery-store.cjs +8 -0
  13. package/dist/cjs/session-file-mutations.cjs +61 -0
  14. package/dist/cjs/working-tree-mirror.cjs +1 -0
  15. package/dist/cjs/workspace-command-sync-policy.cjs +37 -8
  16. package/dist/cjs/workspace-filesystem-executor-thread.cjs +36 -0
  17. package/dist/cjs/workspace-filesystem-executor.cjs +327 -0
  18. package/dist/cjs/workspace-filesystem-job-types.cjs +134 -0
  19. package/dist/cjs/workspace-filesystem-jobs.cjs +57 -0
  20. package/dist/cjs/workspace-git-sync.cjs +275 -201
  21. package/dist/cjs/workspace-mount-hold-fence.cjs +120 -0
  22. package/dist/mjs/command-launcher.mjs +6 -2
  23. package/dist/mjs/main.mjs +499 -244
  24. package/dist/mjs/package.json +1 -1
  25. package/dist/mjs/project-checkout-garbage.mjs +30 -2
  26. package/dist/mjs/project-workspace-state.mjs +51 -35
  27. package/dist/mjs/project-worktrees.mjs +74 -34
  28. package/dist/mjs/recovery-journal-protocol.mjs +30 -0
  29. package/dist/mjs/recovery-journal-runtime.mjs +112 -0
  30. package/dist/mjs/recovery-journal-thread.mjs +8 -0
  31. package/dist/mjs/recovery-journal.mjs +687 -0
  32. package/dist/mjs/recovery-store.mjs +8 -0
  33. package/dist/mjs/session-file-mutations.mjs +37 -0
  34. package/dist/mjs/working-tree-mirror.mjs +1 -0
  35. package/dist/mjs/workspace-command-sync-policy.mjs +37 -8
  36. package/dist/mjs/workspace-filesystem-executor-thread.mjs +38 -0
  37. package/dist/mjs/workspace-filesystem-executor.mjs +287 -0
  38. package/dist/mjs/workspace-filesystem-job-types.mjs +106 -0
  39. package/dist/mjs/workspace-filesystem-jobs.mjs +51 -0
  40. package/dist/mjs/workspace-git-sync.mjs +264 -202
  41. package/dist/mjs/workspace-mount-hold-fence.mjs +95 -0
  42. package/dist/types/command-launcher.d.ts +2 -1
  43. package/dist/types/main.d.ts +30 -19
  44. package/dist/types/project-checkout-garbage.d.ts +19 -2
  45. package/dist/types/project-workspace-state.d.ts +9 -9
  46. package/dist/types/project-worktrees.d.ts +49 -5
  47. package/dist/types/recovery-journal-protocol.d.ts +35 -0
  48. package/dist/types/recovery-journal-runtime.d.ts +12 -0
  49. package/dist/types/recovery-journal-stall-fixture.d.ts +1 -0
  50. package/dist/types/recovery-journal-thread.d.ts +1 -0
  51. package/dist/types/recovery-journal.d.ts +246 -0
  52. package/dist/types/recovery-store.d.ts +6 -0
  53. package/dist/types/session-file-mutations.d.ts +22 -0
  54. package/dist/types/workspace-command-sync-policy.d.ts +22 -7
  55. package/dist/types/workspace-filesystem-executor-thread.d.ts +1 -0
  56. package/dist/types/workspace-filesystem-executor.d.ts +123 -0
  57. package/dist/types/workspace-filesystem-job-types.d.ts +246 -0
  58. package/dist/types/workspace-filesystem-jobs.d.ts +7 -0
  59. package/dist/types/workspace-git-sync.d.ts +113 -7
  60. package/dist/types/workspace-mount-hold-fence.d.ts +42 -0
  61. package/package.json +1 -1
  62. package/dist/cjs/project-snapshot-recovery-runner.cjs +0 -171
  63. package/dist/mjs/project-snapshot-recovery-runner.mjs +0 -135
  64. package/dist/types/project-snapshot-recovery-runner.d.ts +0 -10
@@ -0,0 +1,687 @@
1
+ import path from "node:path";
2
+ import { Worker } from "node:worker_threads";
3
+ const WORKER_RECOVERY_JOURNAL_MAX_BATCH_ENTRIES = 256;
4
+ const WORKER_RECOVERY_JOURNAL_MAX_PENDING_ENTRIES = 4096;
5
+ const WORKER_RECOVERY_JOURNAL_MAX_PENDING_BYTES = 16 * 1024 * 1024;
6
+ const WORKER_RECOVERY_JOURNAL_MAX_CONTROL_ENTRIES = 2048;
7
+ const WORKER_RECOVERY_JOURNAL_MAX_CONTROL_BYTES = 1024 * 1024;
8
+ const WORKER_RECOVERY_JOURNAL_MAX_ENTRY_BYTES = 8 * 1024 * 1024;
9
+ const WORKER_RECOVERY_JOURNAL_OUTPUT_CHUNK_CHARACTERS = 64 * 1024;
10
+ const WORKER_RECOVERY_JOURNAL_MAX_WAITING_ADMISSIONS = 512;
11
+ const WORKER_RECOVERY_JOURNAL_MAX_WAITING_OUTPUT = 1024;
12
+ const WORKER_RECOVERY_JOURNAL_MAX_WAITING_BYTES = 4 * 1024 * 1024;
13
+ const WORKER_RECOVERY_JOURNAL_REPLAY_PAGE_FRAMES = 32;
14
+ class WorkerRecoveryJournalError extends Error {
15
+ name = "WorkerRecoveryJournalError";
16
+ }
17
+ class WorkerRecoveryJournalBusyError extends WorkerRecoveryJournalError {
18
+ name = "WorkerRecoveryJournalBusyError";
19
+ }
20
+ class JournalBudget {
21
+ constructor(maxEntries, maxBytes) {
22
+ this.maxEntries = maxEntries;
23
+ this.maxBytes = maxBytes;
24
+ }
25
+ maxEntries;
26
+ maxBytes;
27
+ entries = 0;
28
+ bytes = 0;
29
+ fits(bytes) {
30
+ return this.entries + 1 <= this.maxEntries && this.bytes + bytes <= this.maxBytes;
31
+ }
32
+ reserve(bytes) {
33
+ if (!this.fits(bytes)) return false;
34
+ this.entries += 1;
35
+ this.bytes += bytes;
36
+ return true;
37
+ }
38
+ release(bytes) {
39
+ this.entries -= 1;
40
+ this.bytes -= bytes;
41
+ }
42
+ }
43
+ function approximateBytes(value) {
44
+ if (typeof value === "string") return value.length;
45
+ if (value && typeof value === "object") return JSON.stringify(value).length;
46
+ return 8;
47
+ }
48
+ function messageKeyed(message) {
49
+ return typeof message.requestId === "string" || typeof message.runId === "string" || typeof message.ptyId === "string";
50
+ }
51
+ function messageJournaled(message) {
52
+ return message.type === "exec_output" || message.type === "exec_exit" || message.type === "exec_error" || typeof message.requestId === "string";
53
+ }
54
+ function splitOutput(data, maxCharacters) {
55
+ if (data.length <= maxCharacters) return [data];
56
+ const chunks = [];
57
+ let start = 0;
58
+ while (start < data.length) {
59
+ let end = Math.min(data.length, start + maxCharacters);
60
+ const last = data.charCodeAt(end - 1);
61
+ if (end < data.length && last >= 55296 && last <= 56319) end -= 1;
62
+ chunks.push(data.slice(start, end));
63
+ start = end;
64
+ }
65
+ return chunks;
66
+ }
67
+ class WorkerRecoveryJournal {
68
+ ledgerId;
69
+ worker;
70
+ log;
71
+ maxBatchEntries;
72
+ maxEntryBytes;
73
+ maxWaitingAdmissions;
74
+ maxWaitingOutput;
75
+ maxWaitingBytes;
76
+ waitingBytes = 0;
77
+ retainedReplyBytes = 0;
78
+ general;
79
+ control;
80
+ nextSeq = 1;
81
+ committedSeq = 0;
82
+ pending = [];
83
+ inFlight = null;
84
+ nextBatchId = 1;
85
+ waiters = [];
86
+ outbound = [];
87
+ deliveryPaused = 0;
88
+ replayCoveredSeq = 0;
89
+ failure = null;
90
+ closed = false;
91
+ pendingAdmissions = /* @__PURE__ */ new Map();
92
+ activeOperations = /* @__PURE__ */ new Map();
93
+ cancelledRequestIds = /* @__PURE__ */ new Set();
94
+ unknownRequestIds = /* @__PURE__ */ new Set();
95
+ unknownRunIds = /* @__PURE__ */ new Set();
96
+ coalescedControl = /* @__PURE__ */ new Map();
97
+ flushWaiters = [];
98
+ /** Delivers one committed message in order; returns false when the transport declined it (the pumps re-send). */
99
+ deliver;
100
+ /** Invoked once when the journal fails closed. */
101
+ onFailure;
102
+ constructor(worker, ledgerId, options) {
103
+ this.worker = worker;
104
+ this.ledgerId = ledgerId;
105
+ this.log = options.log ?? ((line) => process.stderr.write(`${line}
106
+ `));
107
+ this.maxBatchEntries = options.maxBatchEntries ?? WORKER_RECOVERY_JOURNAL_MAX_BATCH_ENTRIES;
108
+ this.maxEntryBytes = options.maxEntryBytes ?? WORKER_RECOVERY_JOURNAL_MAX_ENTRY_BYTES;
109
+ this.maxWaitingAdmissions = options.maxWaitingAdmissions ?? WORKER_RECOVERY_JOURNAL_MAX_WAITING_ADMISSIONS;
110
+ this.maxWaitingOutput = options.maxWaitingOutput ?? WORKER_RECOVERY_JOURNAL_MAX_WAITING_OUTPUT;
111
+ this.maxWaitingBytes = options.maxWaitingBytes ?? WORKER_RECOVERY_JOURNAL_MAX_WAITING_BYTES;
112
+ this.general = new JournalBudget(
113
+ options.maxPendingEntries ?? WORKER_RECOVERY_JOURNAL_MAX_PENDING_ENTRIES,
114
+ options.maxPendingBytes ?? WORKER_RECOVERY_JOURNAL_MAX_PENDING_BYTES
115
+ );
116
+ this.control = new JournalBudget(
117
+ options.maxControlEntries ?? WORKER_RECOVERY_JOURNAL_MAX_CONTROL_ENTRIES,
118
+ options.maxControlBytes ?? WORKER_RECOVERY_JOURNAL_MAX_CONTROL_BYTES
119
+ );
120
+ worker.on("message", (reply) => this.onReply(reply));
121
+ worker.on(
122
+ "error",
123
+ (error) => this.fail(new WorkerRecoveryJournalError(`Recovery journal thread error: ${error.message}`, { cause: error }))
124
+ );
125
+ worker.on("exit", (code) => {
126
+ if (!this.closed) this.fail(new WorkerRecoveryJournalError(`Recovery journal thread exited with code ${code}`));
127
+ });
128
+ worker.unref();
129
+ }
130
+ /** Start the journal thread, open the store there (its crash recovery runs there) and return once it is ready. */
131
+ static open(options) {
132
+ const threadModulePath = options.threadModulePath ?? workerRecoveryJournalThreadModulePath();
133
+ return new Promise((resolve, reject) => {
134
+ const worker = new Worker(threadModulePath, { workerData: { ...options.threadWorkerData, filename: options.filename } });
135
+ let settled = false;
136
+ const onReady = (reply) => {
137
+ if (settled || !reply || reply.type !== "ready") return;
138
+ settled = true;
139
+ worker.off("message", onReady);
140
+ worker.off("error", onError);
141
+ worker.off("exit", onExit);
142
+ resolve(new WorkerRecoveryJournal(worker, reply.ledgerId, options));
143
+ };
144
+ const onError = (error) => {
145
+ if (settled) return;
146
+ settled = true;
147
+ reject(new WorkerRecoveryJournalError(`Recovery journal could not open ${options.filename}: ${error.message}`, { cause: error }));
148
+ };
149
+ const onExit = (code) => {
150
+ if (settled) return;
151
+ settled = true;
152
+ reject(new WorkerRecoveryJournalError(`Recovery journal thread exited with code ${code} before it was ready`));
153
+ };
154
+ worker.on("message", onReady);
155
+ worker.on("error", onError);
156
+ worker.on("exit", onExit);
157
+ });
158
+ }
159
+ status() {
160
+ return {
161
+ pendingEntries: this.pending.length + (this.inFlight?.entries.length ?? 0),
162
+ retainedEntries: this.general.entries,
163
+ retainedBytes: this.general.bytes,
164
+ controlEntries: this.control.entries,
165
+ controlBytes: this.control.bytes,
166
+ waitingAdmissions: this.waiters.filter((waiter) => waiter.kind === "admit").length,
167
+ waitingOutput: this.waiters.filter((waiter) => waiter.kind === "output").length,
168
+ waitingBytes: this.waitingBytes,
169
+ retainedReplyBytes: this.retainedReplyBytes,
170
+ committedSeq: this.committedSeq,
171
+ appendedSeq: this.nextSeq - 1,
172
+ undelivered: this.outbound.length,
173
+ failed: this.failure?.message ?? null
174
+ };
175
+ }
176
+ /**
177
+ * Durably admit an inbound operation; resolves after the commit. Waits for
178
+ * general budget in a bounded waiting room; when that room is full the
179
+ * admission is refused at once (WorkerRecoveryJournalBusyError) so the
180
+ * message is neither acknowledged nor retained.
181
+ */
182
+ async admit(message) {
183
+ this.assertOpen();
184
+ const tracked = {
185
+ requestId: message.requestId,
186
+ sessionId: typeof message.sessionId === "string" ? message.sessionId : void 0,
187
+ runId: typeof message.runId === "string" ? message.runId : void 0,
188
+ requestType: message.type,
189
+ state: "accepted"
190
+ };
191
+ this.pendingAdmissions.set(message.requestId, tracked);
192
+ try {
193
+ const bytes = approximateBytes(message);
194
+ if (bytes > this.maxEntryBytes) {
195
+ throw new WorkerRecoveryJournalBusyError(
196
+ `Recovery journal refuses a ${bytes}-byte request; the entry bound is ${this.maxEntryBytes} bytes`
197
+ );
198
+ }
199
+ await this.reserveGeneral(bytes, "admit");
200
+ const result = await this.append("admit", [message], bytes, this.general, null);
201
+ if (result.cancelled) this.cancelledRequestIds.add(message.requestId);
202
+ if (result.admission === "new") this.activeOperations.set(message.requestId, tracked);
203
+ return result;
204
+ } finally {
205
+ this.pendingAdmissions.delete(message.requestId);
206
+ }
207
+ }
208
+ /**
209
+ * Re-admit an unknown branch deletion. Like `admit`, the request is a
210
+ * pending admission from before the await, so a session cancellation that
211
+ * lands while the disk is slow snapshots it and the handler's recheck after
212
+ * the await refuses to rerun the deletion.
213
+ */
214
+ async readmitUnknownBranchDeletion(requestId, sessionId) {
215
+ this.assertOpen();
216
+ const tracked = { requestId, sessionId, runId: void 0, requestType: "delete_project_branch", state: "accepted" };
217
+ this.pendingAdmissions.set(requestId, tracked);
218
+ try {
219
+ const readmitted = await this.appendControlOrThrow("readmitUnknownBranchDeletion", [requestId]);
220
+ if (readmitted && this.pendingAdmissions.get(requestId) === tracked) {
221
+ this.activeOperations.set(requestId, tracked);
222
+ if (!this.cancelledRequestIds.has(requestId)) this.unknownRequestIds.delete(requestId);
223
+ }
224
+ return readmitted;
225
+ } finally {
226
+ if (this.pendingAdmissions.get(requestId) === tracked) this.pendingAdmissions.delete(requestId);
227
+ }
228
+ }
229
+ /**
230
+ * Queue an outbound message. Keyed messages are recorded (when the store
231
+ * records their type) and delivered in order after their commit; other
232
+ * messages are delivered at once. Returns false when the message was not
233
+ * queued: its operation is already unknown, or the budget cannot hold it,
234
+ * in which case the operation is marked unknown so it is neither
235
+ * acknowledged early nor sent later.
236
+ */
237
+ send(message) {
238
+ if (this.failure || this.closed) return false;
239
+ if (!messageKeyed(message)) {
240
+ this.deliver?.(message);
241
+ return true;
242
+ }
243
+ const requestId = typeof message.requestId === "string" ? message.requestId : void 0;
244
+ const runId = typeof message.runId === "string" ? message.runId : void 0;
245
+ if (requestId && this.unknownRequestIds.has(requestId) || runId && this.unknownRunIds.has(runId)) return false;
246
+ const bytes = approximateBytes(message);
247
+ const journaled = messageJournaled(message);
248
+ if (journaled && bytes > this.maxEntryBytes) {
249
+ this.refuse(message, `is ${bytes} bytes, above the ${this.maxEntryBytes}-byte entry bound`);
250
+ return false;
251
+ }
252
+ if (!this.general.reserve(bytes)) {
253
+ if (journaled) {
254
+ this.refuse(message, `does not fit the journal budget (${this.general.entries} entries, ${this.general.bytes} bytes retained)`);
255
+ } else {
256
+ this.log(
257
+ `[r5d-worker] recovery journal budget is full; dropping ${message.type} for ${runId ?? requestId ?? String(message.ptyId)}`
258
+ );
259
+ }
260
+ return false;
261
+ }
262
+ const entry = { seq: this.nextSeq, journaled, message, bytes, requestId, runId, recorded: null, dropped: false };
263
+ if (!journaled) {
264
+ this.outbound.push(entry);
265
+ this.deliverReady();
266
+ return true;
267
+ }
268
+ this.enqueueRecord(entry);
269
+ return true;
270
+ }
271
+ /**
272
+ * Queue an output frame, split into bounded records, waiting for budget so
273
+ * a stalled journal throttles the producer (and through the pipe, the
274
+ * child) instead of growing memory.
275
+ */
276
+ async sendOutput(frame) {
277
+ if (this.failure || this.closed) return;
278
+ if (this.unknownRunIds.has(frame.runId)) return;
279
+ for (const data of splitOutput(frame.data, WORKER_RECOVERY_JOURNAL_OUTPUT_CHUNK_CHARACTERS)) {
280
+ const chunk = { ...frame, data };
281
+ const bytes = approximateBytes(chunk);
282
+ if (bytes > this.general.maxBytes) {
283
+ this.refuse(chunk, `is ${bytes} bytes, above the ${this.general.maxBytes}-byte journal budget`);
284
+ return;
285
+ }
286
+ await this.reserveGeneral(bytes, "output");
287
+ if (this.failure || this.closed) {
288
+ this.general.release(bytes);
289
+ return;
290
+ }
291
+ this.enqueueRecord({
292
+ seq: this.nextSeq,
293
+ journaled: true,
294
+ message: chunk,
295
+ bytes,
296
+ requestId: void 0,
297
+ runId: frame.runId,
298
+ recorded: null,
299
+ dropped: false
300
+ });
301
+ }
302
+ }
303
+ /**
304
+ * Reserve general budget atomically, waiting in the bounded room of `kind`
305
+ * when it does not fit now. The waiting room is bounded in count and in
306
+ * payload bytes (the message a waiting admission holds is charged before it
307
+ * waits), and an entry that could never fit the general budget is refused
308
+ * at once rather than parked at the head of the queue.
309
+ */
310
+ reserveGeneral(bytes, kind) {
311
+ if (this.failure) return Promise.reject(this.failure);
312
+ if (bytes > this.general.maxBytes) {
313
+ return Promise.reject(
314
+ new WorkerRecoveryJournalBusyError(`Recovery journal refuses a ${bytes}-byte entry; the budget is ${this.general.maxBytes} bytes`)
315
+ );
316
+ }
317
+ if (this.waiters.length === 0 && this.general.reserve(bytes)) return Promise.resolve();
318
+ const waiting = this.waiters.filter((waiter) => waiter.kind === kind).length;
319
+ const room = kind === "admit" ? this.maxWaitingAdmissions : this.maxWaitingOutput;
320
+ const overflow = waiting >= room ? `${waiting} ${kind === "admit" ? "admissions" : "output producers"} waiting` : this.waitingBytes + bytes > this.maxWaitingBytes ? `${this.waitingBytes} bytes waiting` : null;
321
+ if (overflow) {
322
+ if (kind === "output") {
323
+ this.fail(new WorkerRecoveryJournalError(`Recovery journal has ${overflow}; refusing to buffer more output`));
324
+ return Promise.reject(this.failure);
325
+ }
326
+ return Promise.reject(
327
+ new WorkerRecoveryJournalBusyError(`Recovery journal has ${overflow} for the disk; refusing another admission`)
328
+ );
329
+ }
330
+ this.waitingBytes += bytes;
331
+ return new Promise((resolve, reject) => this.waiters.push({ bytes, kind, resolve, reject }));
332
+ }
333
+ /** Reserve for the head waiter inside this synchronous call, so no continuation can outrun the accounting. */
334
+ wakeWaiters() {
335
+ while (this.waiters.length > 0) {
336
+ const waiter = this.waiters[0];
337
+ if (!this.general.reserve(waiter.bytes)) return;
338
+ this.waiters.shift();
339
+ this.waitingBytes -= waiter.bytes;
340
+ waiter.resolve();
341
+ }
342
+ }
343
+ enqueueRecord(entry) {
344
+ const promise = this.append("record", [entry.message], entry.bytes, this.general, entry, (seq) => {
345
+ entry.seq = seq;
346
+ this.outbound.push(entry);
347
+ });
348
+ promise.then(
349
+ (result) => {
350
+ if (entry.dropped) {
351
+ this.releaseOutbound(entry);
352
+ this.wakeWaiters();
353
+ return;
354
+ }
355
+ entry.recorded = { suppressed: result.suppressed, message: result.recorded };
356
+ this.trackRecorded(result.recorded, result.suppressed);
357
+ this.deliverReady();
358
+ },
359
+ () => {
360
+ }
361
+ );
362
+ }
363
+ refuse(message, reason) {
364
+ const requestId = typeof message.requestId === "string" ? message.requestId : void 0;
365
+ const runId = typeof message.runId === "string" ? message.runId : void 0;
366
+ this.log(
367
+ `[r5d-worker] recovery journal: ${message.type}${requestId ? ` ${requestId}` : ""}${runId ? ` run ${runId}` : ""} ${reason}; it is not sent and its operation becomes unknown`
368
+ );
369
+ if (requestId) this.unknown(requestId);
370
+ else if (runId) this.unknownRun(runId);
371
+ }
372
+ trackRecorded(message, suppressed) {
373
+ if (suppressed) return;
374
+ if ((message.type === "exec_exit" || message.type === "exec_error") && typeof message.runId === "string") {
375
+ for (const [requestId, operation2] of this.activeOperations) {
376
+ if (operation2.runId === message.runId && operation2.requestType === "exec_start") this.activeOperations.delete(requestId);
377
+ }
378
+ return;
379
+ }
380
+ if (typeof message.requestId !== "string") return;
381
+ const operation = this.activeOperations.get(message.requestId);
382
+ if (!operation) return;
383
+ if (message.type === "exec_accepted" || message.type === "operation_queued") operation.state = "preparing";
384
+ else if (message.type === "exec_started") operation.state = "running";
385
+ else if (message.type !== "operation_received") this.activeOperations.delete(message.requestId);
386
+ }
387
+ /**
388
+ * Mark an operation unknown: never sent again (undelivered messages for it
389
+ * are dropped now, and the durable mark keeps replay from resurrecting it).
390
+ * Coalesced per id; uses the reserved control budget.
391
+ */
392
+ unknown(requestId) {
393
+ this.unknownRequestIds.add(requestId);
394
+ const operation = this.activeOperations.get(requestId) ?? this.pendingAdmissions.get(requestId);
395
+ const runId = operation?.requestType === "exec_start" || operation?.requestType === "exec" ? operation.runId : void 0;
396
+ if (runId) this.unknownRunIds.add(runId);
397
+ this.activeOperations.delete(requestId);
398
+ this.pendingAdmissions.delete(requestId);
399
+ this.dropOutbound((entry) => entry.requestId === requestId || runId !== void 0 && entry.runId === runId);
400
+ this.appendControl(`unknown:${requestId}`, "unknown", [requestId]);
401
+ }
402
+ unknownRun(runId) {
403
+ this.unknownRunIds.add(runId);
404
+ for (const [requestId, operation] of this.activeOperations) if (operation.runId === runId) this.activeOperations.delete(requestId);
405
+ this.dropOutbound((entry) => entry.runId === runId);
406
+ this.appendControl(`unknownRun:${runId}`, "unknownRun", [runId]);
407
+ }
408
+ acknowledgeResult(requestId) {
409
+ this.appendControl(`acknowledgeResult:${requestId}`, "acknowledgeResult", [requestId]);
410
+ }
411
+ /** Monotonic per stream: a later acknowledgement replaces a pending earlier one. */
412
+ acknowledgeOutput(runId, stream, offset) {
413
+ this.appendControl(`acknowledgeOutput:${runId}:${stream}`, "acknowledgeOutput", [runId, stream, offset]);
414
+ }
415
+ acknowledgeTerminal(runId) {
416
+ this.appendControl(`acknowledgeTerminal:${runId}`, "acknowledgeTerminal", [runId]);
417
+ }
418
+ /**
419
+ * Snapshot every admitted and pending operation of the session as cancelled
420
+ * (the handlers check `isRequestCancelled` at their fences) and append the
421
+ * durable cancellation, which the thread applies after every admission
422
+ * appended before it. Resolves after the commit.
423
+ */
424
+ cancelSession(sessionId) {
425
+ for (const operation of [...this.pendingAdmissions.values(), ...this.activeOperations.values()]) {
426
+ if (operation.sessionId === sessionId) this.cancelledRequestIds.add(operation.requestId);
427
+ }
428
+ return this.appendControlOrThrow("cancelSession", [sessionId]).then(() => void 0);
429
+ }
430
+ isRequestCancelled(requestId) {
431
+ return this.cancelledRequestIds.has(requestId);
432
+ }
433
+ /** Every operation admitted or awaiting admission in this process that has not completed, for the runtime fence and lease expiry. */
434
+ activeAndPendingOperations() {
435
+ return [...this.pendingAdmissions.values(), ...this.activeOperations.values()].map((operation) => ({
436
+ requestId: operation.requestId,
437
+ requestType: operation.requestType,
438
+ state: operation.state,
439
+ ...operation.runId ? { runId: operation.runId } : {},
440
+ ...operation.sessionId ? { sessionId: operation.sessionId } : {}
441
+ }));
442
+ }
443
+ /**
444
+ * The handshake read. One entry, so it sees exactly the entries appended
445
+ * before it and none after: the replay boundary. Callers pause delivery
446
+ * before appending it and resume after the handshake, so every keyed
447
+ * message committed before the mark is replayed once (from the store) and
448
+ * nothing committed after it can enter the replay of responses, operations
449
+ * or terminals; output pages that follow may re-send frames the pump would
450
+ * re-send anyway, which the server's offset cursors absorb.
451
+ */
452
+ async recoveryState(requests) {
453
+ this.assertOpen();
454
+ this.replayCoveredSeq = this.nextSeq - 1;
455
+ this.dropOutbound((entry) => entry.journaled && entry.seq <= this.replayCoveredSeq && entry.recorded !== null);
456
+ const state = await this.appendControlOrThrow("recoveryState", [requests]);
457
+ this.retainedReplyBytes += approximateBytes(state);
458
+ this.retainedStateBytes += approximateBytes(state);
459
+ return state;
460
+ }
461
+ retainedStateBytes = 0;
462
+ /** Output replay, one bounded page at a time; a page is accounted until the consumer asks for the next one. */
463
+ async *outputReplay(offsets, pageFrames = WORKER_RECOVERY_JOURNAL_REPLAY_PAGE_FRAMES) {
464
+ this.assertOpen();
465
+ const replayId = await this.appendControlOrThrow("outputReplayBegin", [offsets ?? []]);
466
+ let retainedPageBytes = 0;
467
+ try {
468
+ while (true) {
469
+ const page = await this.appendControlOrThrow("outputReplayNext", [replayId, pageFrames]);
470
+ retainedPageBytes = approximateBytes(page.frames);
471
+ this.retainedReplyBytes += retainedPageBytes;
472
+ if (page.frames.length > 0) yield page.frames;
473
+ this.retainedReplyBytes -= retainedPageBytes;
474
+ retainedPageBytes = 0;
475
+ if (page.done) return;
476
+ }
477
+ } finally {
478
+ this.retainedReplyBytes -= retainedPageBytes;
479
+ if (!this.failure && !this.closed) void this.appendControlOrThrow("outputReplayEnd", [replayId]).catch(() => void 0);
480
+ }
481
+ }
482
+ terminals() {
483
+ this.assertOpen();
484
+ return this.appendControlOrThrow("terminals", []);
485
+ }
486
+ /** Delivery of committed keyed messages is held (in order) while a handshake replays from the store. */
487
+ pauseDelivery() {
488
+ this.deliveryPaused += 1;
489
+ }
490
+ resumeDelivery() {
491
+ this.deliveryPaused = Math.max(0, this.deliveryPaused - 1);
492
+ if (this.deliveryPaused === 0) {
493
+ this.retainedReplyBytes -= this.retainedStateBytes;
494
+ this.retainedStateBytes = 0;
495
+ }
496
+ this.deliverReady();
497
+ }
498
+ /** Resolves once every entry appended so far is committed (or the journal failed). */
499
+ flush() {
500
+ if (this.failure) return Promise.resolve();
501
+ const target = this.nextSeq - 1;
502
+ if (this.committedSeq >= target) return Promise.resolve();
503
+ return new Promise((resolve) => this.flushWaiters.push({ target, resolve }));
504
+ }
505
+ close() {
506
+ if (this.closed) return;
507
+ this.closed = true;
508
+ this.failEntries(new WorkerRecoveryJournalError("Recovery journal is closed"));
509
+ void this.worker.terminate();
510
+ }
511
+ assertOpen() {
512
+ if (this.failure) throw new WorkerRecoveryJournalError(`${this.failure.message}; the worker runtime must restart`);
513
+ if (this.closed) throw new WorkerRecoveryJournalError("Recovery journal is closed");
514
+ }
515
+ /** Control entries are tiny and idempotent per key: a pending one is updated in place instead of queued again. */
516
+ appendControl(key, op, args) {
517
+ if (this.failure || this.closed) return;
518
+ const existing = this.coalescedControl.get(key);
519
+ if (existing && !existing.dispatched) {
520
+ this.control.release(existing.bytes);
521
+ existing.args = args;
522
+ existing.bytes = approximateBytes(args);
523
+ this.control.entries += 1;
524
+ this.control.bytes += existing.bytes;
525
+ return;
526
+ }
527
+ const bytes = approximateBytes(args);
528
+ if (!this.control.reserve(bytes)) {
529
+ this.fail(
530
+ new WorkerRecoveryJournalError(
531
+ `Recovery journal control budget is exhausted (${this.control.entries} entries); cannot record ${op}`
532
+ )
533
+ );
534
+ return;
535
+ }
536
+ const appended = this.append(op, args, bytes, this.control, null);
537
+ appended.catch(() => void 0);
538
+ this.coalescedControl.set(key, this.lastAppended);
539
+ }
540
+ /** A control-budget entry whose result the caller awaits; rejects when the budget is exhausted. */
541
+ appendControlOrThrow(op, args) {
542
+ if (this.failure) return Promise.reject(this.failure);
543
+ if (this.closed) return Promise.reject(new WorkerRecoveryJournalError("Recovery journal is closed"));
544
+ const bytes = approximateBytes(args);
545
+ if (!this.control.reserve(bytes)) {
546
+ return Promise.reject(
547
+ new WorkerRecoveryJournalBusyError(
548
+ `Recovery journal control budget is exhausted (${this.control.entries} entries); cannot run ${op}`
549
+ )
550
+ );
551
+ }
552
+ return this.append(op, args, bytes, this.control, null);
553
+ }
554
+ lastAppended = null;
555
+ /** Queue an entry whose budget was already reserved from `budget`. */
556
+ append(op, args, bytes, budget, outbound, onSeq) {
557
+ if (this.failure || this.closed) {
558
+ budget.release(bytes);
559
+ return Promise.reject(this.failure ?? new WorkerRecoveryJournalError("Recovery journal is closed"));
560
+ }
561
+ const promise = new Promise((resolve, reject) => {
562
+ const seq = this.nextSeq++;
563
+ const entry = { seq, op, args, bytes, budget, outbound, dispatched: false, resolve, reject };
564
+ this.pending.push(entry);
565
+ this.lastAppended = entry;
566
+ onSeq?.(seq);
567
+ });
568
+ this.dispatch();
569
+ return promise;
570
+ }
571
+ dispatch() {
572
+ if (this.inFlight || this.pending.length === 0 || this.failure || this.closed) return;
573
+ const entries = this.pending.splice(0, this.maxBatchEntries);
574
+ for (const entry of entries) entry.dispatched = true;
575
+ const id = this.nextBatchId++;
576
+ this.inFlight = { id, entries };
577
+ this.worker.ref();
578
+ const request = {
579
+ type: "batch",
580
+ id,
581
+ entries: entries.map((entry) => ({ seq: entry.seq, op: entry.op, args: entry.args }))
582
+ };
583
+ try {
584
+ this.worker.postMessage(request);
585
+ } catch (error) {
586
+ this.fail(
587
+ new WorkerRecoveryJournalError(
588
+ `Recovery journal batch could not be sent: ${error instanceof Error ? error.message : String(error)}`
589
+ )
590
+ );
591
+ }
592
+ }
593
+ onReply(reply) {
594
+ if (!reply || typeof reply !== "object" || reply.type === "ready") return;
595
+ const inFlight = this.inFlight;
596
+ if (!inFlight || inFlight.id !== reply.id) return;
597
+ if (reply.type === "batch_failure") {
598
+ this.fail(new WorkerRecoveryJournalError(`Recovery journal commit failed: ${reply.error.message}`));
599
+ return;
600
+ }
601
+ this.inFlight = null;
602
+ const values = new Map(reply.results.map((result) => [result.seq, result.value]));
603
+ for (const entry of inFlight.entries) {
604
+ this.committedSeq = Math.max(this.committedSeq, entry.seq);
605
+ if (!entry.outbound) entry.budget.release(entry.bytes);
606
+ entry.resolve(values.get(entry.seq));
607
+ }
608
+ for (const [key, entry] of [...this.coalescedControl]) {
609
+ if (entry.dispatched) this.coalescedControl.delete(key);
610
+ }
611
+ for (const waiter of this.flushWaiters.splice(0)) {
612
+ if (waiter.target <= this.committedSeq) waiter.resolve();
613
+ else this.flushWaiters.push(waiter);
614
+ }
615
+ this.wakeWaiters();
616
+ if (this.pending.length === 0) this.worker.unref();
617
+ this.dispatch();
618
+ }
619
+ dropOutbound(predicate) {
620
+ for (let index = this.outbound.length - 1; index >= 0; index -= 1) {
621
+ const entry = this.outbound[index];
622
+ if (!predicate(entry)) continue;
623
+ this.outbound.splice(index, 1);
624
+ if (entry.journaled && !entry.recorded) entry.dropped = true;
625
+ else this.releaseOutbound(entry);
626
+ }
627
+ this.wakeWaiters();
628
+ }
629
+ releaseOutbound(entry) {
630
+ this.general.release(entry.bytes);
631
+ }
632
+ deliverReady() {
633
+ if (this.deliveryPaused > 0) return;
634
+ while (this.outbound.length > 0) {
635
+ const head = this.outbound[0];
636
+ if (head.journaled && !head.recorded) return;
637
+ this.outbound.shift();
638
+ const suppressed = head.recorded?.suppressed === true || head.journaled && head.seq <= this.replayCoveredSeq || head.requestId !== void 0 && this.unknownRequestIds.has(head.requestId) || head.runId !== void 0 && this.unknownRunIds.has(head.runId);
639
+ if (!suppressed) this.deliver?.(head.recorded ? head.recorded.message : head.message);
640
+ this.releaseOutbound(head);
641
+ }
642
+ this.wakeWaiters();
643
+ }
644
+ fail(error) {
645
+ if (this.failure || this.closed) return;
646
+ this.failure = error;
647
+ this.log(`[r5d-worker] ${error.message}`);
648
+ this.failEntries(error);
649
+ this.onFailure?.(error);
650
+ }
651
+ failEntries(error) {
652
+ const inFlight = this.inFlight;
653
+ this.inFlight = null;
654
+ for (const entry of [...inFlight?.entries ?? [], ...this.pending.splice(0)]) entry.reject(error);
655
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
656
+ this.waitingBytes = 0;
657
+ this.retainedReplyBytes = 0;
658
+ this.retainedStateBytes = 0;
659
+ for (const waiter of this.flushWaiters.splice(0)) waiter.resolve();
660
+ this.outbound.splice(0);
661
+ this.coalescedControl.clear();
662
+ this.general.entries = 0;
663
+ this.general.bytes = 0;
664
+ this.control.entries = 0;
665
+ this.control.bytes = 0;
666
+ }
667
+ }
668
+ function workerRecoveryJournalThreadModulePath() {
669
+ return path.join(path.dirname(__filename), `recovery-journal-thread${path.extname(__filename)}`);
670
+ }
671
+ export {
672
+ WORKER_RECOVERY_JOURNAL_MAX_BATCH_ENTRIES,
673
+ WORKER_RECOVERY_JOURNAL_MAX_CONTROL_BYTES,
674
+ WORKER_RECOVERY_JOURNAL_MAX_CONTROL_ENTRIES,
675
+ WORKER_RECOVERY_JOURNAL_MAX_ENTRY_BYTES,
676
+ WORKER_RECOVERY_JOURNAL_MAX_PENDING_BYTES,
677
+ WORKER_RECOVERY_JOURNAL_MAX_PENDING_ENTRIES,
678
+ WORKER_RECOVERY_JOURNAL_MAX_WAITING_ADMISSIONS,
679
+ WORKER_RECOVERY_JOURNAL_MAX_WAITING_BYTES,
680
+ WORKER_RECOVERY_JOURNAL_MAX_WAITING_OUTPUT,
681
+ WORKER_RECOVERY_JOURNAL_OUTPUT_CHUNK_CHARACTERS,
682
+ WORKER_RECOVERY_JOURNAL_REPLAY_PAGE_FRAMES,
683
+ WorkerRecoveryJournal,
684
+ WorkerRecoveryJournalBusyError,
685
+ WorkerRecoveryJournalError,
686
+ workerRecoveryJournalThreadModulePath
687
+ };