@remnic/core 10.0.93 → 10.0.95

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 (140) hide show
  1. package/dist/access-admin-ops-surface.js +29 -29
  2. package/dist/access-cli.js +29 -29
  3. package/dist/access-extraction-force-flush.js +29 -29
  4. package/dist/access-http-briefing.js +29 -29
  5. package/dist/access-http-query.js +29 -29
  6. package/dist/access-http.js +29 -29
  7. package/dist/access-identity-continuity-surface.js +29 -29
  8. package/dist/access-lcm-surface.js +29 -29
  9. package/dist/access-mcp.js +29 -29
  10. package/dist/access-observe-write-surface.js +29 -29
  11. package/dist/access-operations-batch.js +29 -29
  12. package/dist/access-operations.js +29 -29
  13. package/dist/access-recall-concurrency.js +29 -29
  14. package/dist/access-recall-response.js +29 -29
  15. package/dist/access-recall-surface.js +29 -29
  16. package/dist/access-service.js +29 -29
  17. package/dist/active-recall.js +2 -2
  18. package/dist/briefing.js +3 -3
  19. package/dist/causal-consolidation.js +4 -4
  20. package/dist/{chunk-2LMA22UU.js → chunk-4EZWSDFL.js} +3 -3
  21. package/dist/{chunk-PRAUN6PN.js → chunk-BN2EBCLB.js} +2 -2
  22. package/dist/{chunk-B33YWUJV.js → chunk-BPK32FRM.js} +2 -2
  23. package/dist/{chunk-NAQ4A5IX.js → chunk-CL4JU3DF.js} +3 -3
  24. package/dist/{chunk-BGHCSLE5.js → chunk-D5XG2KPS.js} +2 -2
  25. package/dist/{chunk-YKJBSMMP.js → chunk-DDATG2SF.js} +63 -43
  26. package/dist/{chunk-YKJBSMMP.js.map → chunk-DDATG2SF.js.map} +1 -1
  27. package/dist/{chunk-EFL24KOI.js → chunk-DNY63QWJ.js} +2 -2
  28. package/dist/{chunk-5YT2IIFX.js → chunk-FQAQ63QA.js} +2 -2
  29. package/dist/{chunk-WQURALGV.js → chunk-FWRXW7CZ.js} +7 -3
  30. package/dist/chunk-FWRXW7CZ.js.map +1 -0
  31. package/dist/{chunk-XIYLWEXC.js → chunk-FZZMLGWQ.js} +2 -2
  32. package/dist/{chunk-ZSE774RV.js → chunk-GBJUTX44.js} +2 -2
  33. package/dist/{chunk-ATG2PQ4V.js → chunk-IL3RHBWM.js} +3 -3
  34. package/dist/{chunk-OJXJWNFY.js → chunk-ILY5SSOM.js} +2 -2
  35. package/dist/{chunk-XRDYRWZI.js → chunk-IQ55QGY5.js} +2 -2
  36. package/dist/{chunk-BTI4C4MA.js → chunk-J7BIFOYP.js} +2 -2
  37. package/dist/{chunk-WKSQTMU7.js → chunk-LNGELD2M.js} +2 -2
  38. package/dist/{chunk-GQXOBKPL.js → chunk-LS7PT3YD.js} +3 -3
  39. package/dist/{chunk-SGQPR5SX.js → chunk-OUVYYKYU.js} +2 -2
  40. package/dist/{chunk-CGJYL633.js → chunk-QHARXWTC.js} +2 -2
  41. package/dist/{chunk-SVT5IA2H.js → chunk-QMFAMOT2.js} +2 -2
  42. package/dist/{chunk-NT7F7WJQ.js → chunk-RFUL2ZIQ.js} +291 -93
  43. package/dist/chunk-RFUL2ZIQ.js.map +1 -0
  44. package/dist/{chunk-622V2AZA.js → chunk-RKABLU5M.js} +7 -7
  45. package/dist/{chunk-MH632KPO.js → chunk-RNG7S3FY.js} +2 -2
  46. package/dist/{chunk-MUX7IL77.js → chunk-SUTEBYGI.js} +2 -2
  47. package/dist/{chunk-FMKILENU.js → chunk-WGGRZYSQ.js} +7 -7
  48. package/dist/{chunk-ETBVCRVL.js → chunk-X5HGU2EM.js} +2 -2
  49. package/dist/{chunk-TJ4F6H4H.js → chunk-XJ2S5UBF.js} +2 -2
  50. package/dist/{chunk-TRQE3RYR.js → chunk-YIXJSW5M.js} +2 -2
  51. package/dist/{chunk-3CYOCE6U.js → chunk-Z3FTSHT5.js} +2 -2
  52. package/dist/{chunk-RM2YUFH6.js → chunk-ZDV5YWID.js} +2 -2
  53. package/dist/cli.js +29 -29
  54. package/dist/coding/export-okf-codegraph.js +2 -2
  55. package/dist/compounding/engine.js +4 -4
  56. package/dist/config.js +2 -2
  57. package/dist/connectors/codex-materialize-runner.js +3 -3
  58. package/dist/connectors/index.js +3 -3
  59. package/dist/convergence-refresh.js +5 -5
  60. package/dist/corpus-watermark.js +5 -5
  61. package/dist/entity-retrieval.js +3 -3
  62. package/dist/explicit-capture.js +29 -29
  63. package/dist/extraction.js +3 -3
  64. package/dist/harmonic-retrieval.js +3 -3
  65. package/dist/index.js +29 -29
  66. package/dist/location/index.js +4 -4
  67. package/dist/maintenance/memory-governance.js +3 -3
  68. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  69. package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
  70. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  71. package/dist/namespaces/migrate.js +4 -4
  72. package/dist/namespaces/storage.js +3 -3
  73. package/dist/operator-doctor-corpus.js +6 -6
  74. package/dist/operator-toolkit.js +9 -9
  75. package/dist/orchestration/backlog-scheduler.js +30 -30
  76. package/dist/orchestration/buffer-drain-runner.js +30 -30
  77. package/dist/orchestration/extraction-persist-failure.d.ts +25 -1
  78. package/dist/orchestration/extraction-persist-failure.d.ts.map +1 -1
  79. package/dist/orchestration/extraction-persist.d.ts +3 -6
  80. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  81. package/dist/orchestration/maintenance.js +11 -11
  82. package/dist/orchestrator.js +29 -29
  83. package/dist/recall-navigation.js +29 -29
  84. package/dist/resume-bundles.js +2 -2
  85. package/dist/semantic-consolidation.js +4 -4
  86. package/dist/semantic-rule-promotion.js +3 -3
  87. package/dist/semantic-rule-verifier.js +3 -3
  88. package/dist/source-agent-qualifier.js +3 -3
  89. package/dist/standup/build.js +3 -3
  90. package/dist/storage/buffer-drain-claims.d.ts +97 -12
  91. package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
  92. package/dist/storage/buffer-drain-claims.js +1 -1
  93. package/dist/storage/buffer-session-files.d.ts +4 -0
  94. package/dist/storage/buffer-session-files.d.ts.map +1 -1
  95. package/dist/storage/buffer-session-files.js +1 -1
  96. package/dist/storage.d.ts.map +1 -1
  97. package/dist/storage.js +2 -2
  98. package/dist/transfer/export-okf.js +3 -3
  99. package/dist/verified-recall.js +3 -3
  100. package/dist/who-knows-cli.js +4 -4
  101. package/dist/who-knows.js +3 -3
  102. package/package.json +2 -2
  103. package/src/buffer-drain-handover.test.ts +5 -1
  104. package/src/orchestration/extraction-persist-failure.test.ts +90 -0
  105. package/src/orchestration/extraction-persist-failure.ts +39 -1
  106. package/src/orchestration/extraction-persist.ts +29 -21
  107. package/src/orchestrator-persist-branch-failure.test.ts +271 -0
  108. package/src/storage/buffer-drain-claims.test.ts +385 -1
  109. package/src/storage/buffer-drain-claims.ts +380 -97
  110. package/src/storage/buffer-session-files.ts +4 -0
  111. package/src/storage.ts +5 -1
  112. package/dist/chunk-NT7F7WJQ.js.map +0 -1
  113. package/dist/chunk-WQURALGV.js.map +0 -1
  114. /package/dist/{chunk-2LMA22UU.js.map → chunk-4EZWSDFL.js.map} +0 -0
  115. /package/dist/{chunk-PRAUN6PN.js.map → chunk-BN2EBCLB.js.map} +0 -0
  116. /package/dist/{chunk-B33YWUJV.js.map → chunk-BPK32FRM.js.map} +0 -0
  117. /package/dist/{chunk-NAQ4A5IX.js.map → chunk-CL4JU3DF.js.map} +0 -0
  118. /package/dist/{chunk-BGHCSLE5.js.map → chunk-D5XG2KPS.js.map} +0 -0
  119. /package/dist/{chunk-EFL24KOI.js.map → chunk-DNY63QWJ.js.map} +0 -0
  120. /package/dist/{chunk-5YT2IIFX.js.map → chunk-FQAQ63QA.js.map} +0 -0
  121. /package/dist/{chunk-XIYLWEXC.js.map → chunk-FZZMLGWQ.js.map} +0 -0
  122. /package/dist/{chunk-ZSE774RV.js.map → chunk-GBJUTX44.js.map} +0 -0
  123. /package/dist/{chunk-ATG2PQ4V.js.map → chunk-IL3RHBWM.js.map} +0 -0
  124. /package/dist/{chunk-OJXJWNFY.js.map → chunk-ILY5SSOM.js.map} +0 -0
  125. /package/dist/{chunk-XRDYRWZI.js.map → chunk-IQ55QGY5.js.map} +0 -0
  126. /package/dist/{chunk-BTI4C4MA.js.map → chunk-J7BIFOYP.js.map} +0 -0
  127. /package/dist/{chunk-WKSQTMU7.js.map → chunk-LNGELD2M.js.map} +0 -0
  128. /package/dist/{chunk-GQXOBKPL.js.map → chunk-LS7PT3YD.js.map} +0 -0
  129. /package/dist/{chunk-SGQPR5SX.js.map → chunk-OUVYYKYU.js.map} +0 -0
  130. /package/dist/{chunk-CGJYL633.js.map → chunk-QHARXWTC.js.map} +0 -0
  131. /package/dist/{chunk-SVT5IA2H.js.map → chunk-QMFAMOT2.js.map} +0 -0
  132. /package/dist/{chunk-622V2AZA.js.map → chunk-RKABLU5M.js.map} +0 -0
  133. /package/dist/{chunk-MH632KPO.js.map → chunk-RNG7S3FY.js.map} +0 -0
  134. /package/dist/{chunk-MUX7IL77.js.map → chunk-SUTEBYGI.js.map} +0 -0
  135. /package/dist/{chunk-FMKILENU.js.map → chunk-WGGRZYSQ.js.map} +0 -0
  136. /package/dist/{chunk-ETBVCRVL.js.map → chunk-X5HGU2EM.js.map} +0 -0
  137. /package/dist/{chunk-TJ4F6H4H.js.map → chunk-XJ2S5UBF.js.map} +0 -0
  138. /package/dist/{chunk-TRQE3RYR.js.map → chunk-YIXJSW5M.js.map} +0 -0
  139. /package/dist/{chunk-3CYOCE6U.js.map → chunk-Z3FTSHT5.js.map} +0 -0
  140. /package/dist/{chunk-RM2YUFH6.js.map → chunk-ZDV5YWID.js.map} +0 -0
@@ -9,15 +9,19 @@ import {
9
9
  SecureStoreDecryptError,
10
10
  SecureStoreLockedError
11
11
  } from "./chunk-EYGOM6AI.js";
12
+ import {
13
+ isErrnoCode
14
+ } from "./chunk-KNA3BXXL.js";
12
15
  import {
13
16
  log
14
17
  } from "./chunk-3WCASNQZ.js";
15
18
 
16
19
  // src/storage/buffer-drain-claims.ts
20
+ import { randomUUID } from "crypto";
17
21
  import { hostname } from "os";
18
22
  import { z } from "zod";
19
23
  import { existsSync } from "fs";
20
- import { mkdir, readdir as readdir2, rename, unlink as unlink2, writeFile } from "fs/promises";
24
+ import { mkdir, readdir as readdir2, readFile, rename, stat, unlink as unlink2, writeFile } from "fs/promises";
21
25
  import path2 from "path";
22
26
 
23
27
  // src/storage/buffer-session-files.ts
@@ -89,16 +93,16 @@ var BufferSessionFiles = class {
89
93
  */
90
94
  async assertDirSafe() {
91
95
  if (this.dirVerified) return;
92
- let stat = null;
96
+ let stat2 = null;
93
97
  try {
94
- stat = await lstat(this.dir);
98
+ stat2 = await lstat(this.dir);
95
99
  } catch (err) {
96
100
  if (!(err instanceof Error) || !isMissing(asFsError(err))) throw err;
97
101
  }
98
- if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
102
+ if (stat2 && (stat2.isSymbolicLink() || !stat2.isDirectory())) {
99
103
  throw new Error(`buffer session directory must be a real directory, not a symlink or file: ${this.dir}`);
100
104
  }
101
- this.dirVerified = stat !== null;
105
+ this.dirVerified = stat2 !== null;
102
106
  }
103
107
  /** Persist ONE session atomically. Compact JSON: machine-read state, and
104
108
  * pretty-printing a large buffer costs CPU and bytes on every observe.
@@ -258,17 +262,19 @@ function turnCounts(turns) {
258
262
  }
259
263
  return counts;
260
264
  }
261
- function mergeTurns(existing, incoming) {
262
- const merged = [...existing];
263
- const existingCounts = turnCounts(existing);
265
+ function appendNewOccurrences(target, counts, incoming) {
264
266
  const incomingCounts = /* @__PURE__ */ new Map();
267
+ const added = [];
265
268
  for (const turn of incoming) {
266
269
  const key = bufferTurnIdentityKey(turn);
267
270
  const count = (incomingCounts.get(key) ?? 0) + 1;
268
271
  incomingCounts.set(key, count);
269
- if (count > (existingCounts.get(key) ?? 0)) merged.push(turn);
272
+ if (count > (counts.get(key) ?? 0)) {
273
+ target.push(turn);
274
+ added.push(key);
275
+ }
270
276
  }
271
- return merged;
277
+ for (const key of added) counts.set(key, (counts.get(key) ?? 0) + 1);
272
278
  }
273
279
  function preserveSourceOccurrences(target, source, identity) {
274
280
  const targetCount = turnCounts(target).get(identity) ?? 0;
@@ -297,22 +303,28 @@ function removeOneByIdentity(turns, identity) {
297
303
  function takeByIdentity(source, identity, count) {
298
304
  return source.filter((turn) => bufferTurnIdentityKey(turn) === identity).slice(0, count);
299
305
  }
300
- function mergeEntries(claim, incoming) {
306
+ function foldRecords(claim, records) {
307
+ const consumed = new Set(claim.foldedRecords);
308
+ const fresh = records.filter((record) => !consumed.has(record.id));
309
+ if (fresh.length === 0) return claim;
310
+ const turns = [...claim.entry.turns];
311
+ const retainedTurns = [...claim.entry.retainedTurns ?? []];
312
+ const activeCounts = turnCounts(turns);
313
+ const retainedCounts = turnCounts(retainedTurns);
301
314
  const entry = emptyEntry();
302
- entry.turns = mergeTurns(
303
- claim.entry.turns,
304
- subtractBufferTurns(incoming.turns, claim.drained ?? [])
305
- );
306
- entry.retainedTurns = mergeTurns(
307
- claim.entry.retainedTurns ?? [],
308
- subtractBufferTurns(incoming.retainedTurns ?? [], claim.drainedRetained ?? [])
309
- );
310
- if (entry.retainedTurns.length === 0) delete entry.retainedTurns;
311
- const stamps = [claim.entry.lastExtractionAt, incoming.lastExtractionAt].filter(
312
- (value) => value !== null && value !== void 0
313
- );
314
- entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
315
- entry.extractionCount = Math.max(claim.entry.extractionCount, incoming.extractionCount);
315
+ entry.lastExtractionAt = claim.entry.lastExtractionAt;
316
+ entry.extractionCount = claim.entry.extractionCount;
317
+ for (const { entry: incoming } of fresh) {
318
+ appendNewOccurrences(turns, activeCounts, subtractBufferTurns(incoming.turns, claim.drained ?? []));
319
+ appendNewOccurrences(retainedTurns, retainedCounts, subtractBufferTurns(incoming.retainedTurns ?? [], claim.drainedRetained ?? []));
320
+ const stamps = [entry.lastExtractionAt, incoming.lastExtractionAt].filter(
321
+ (value) => value !== null && value !== void 0
322
+ );
323
+ entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
324
+ entry.extractionCount = Math.max(entry.extractionCount, incoming.extractionCount);
325
+ }
326
+ entry.turns = turns;
327
+ if (retainedTurns.length > 0) entry.retainedTurns = retainedTurns;
316
328
  return { ...claim, entry };
317
329
  }
318
330
  function isOwnerAlive(lease) {
@@ -344,7 +356,8 @@ var claimBodySchema = z.object({
344
356
  claimedAt: z.string().optional(),
345
357
  drained: z.array(z.unknown()).optional(),
346
358
  drainedRetained: z.array(z.unknown()).optional(),
347
- movedTurns: z.array(z.unknown()).optional()
359
+ movedTurns: z.array(z.unknown()).optional(),
360
+ foldedRecords: z.array(z.string()).optional()
348
361
  });
349
362
  function parseBody(raw) {
350
363
  let body;
@@ -376,8 +389,49 @@ function parseBody(raw) {
376
389
  if (Array.isArray(body.movedTurns)) {
377
390
  claim.movedTurns = body.movedTurns;
378
391
  }
392
+ if (body.foldedRecords) claim.foldedRecords = body.foldedRecords;
379
393
  return claim;
380
394
  }
395
+ var inboxRecordSchema = z.object({
396
+ id: z.string().min(1),
397
+ turns: z.array(z.unknown()),
398
+ retainedTurns: z.array(z.unknown()).optional(),
399
+ lastExtractionAt: z.string().nullable().optional(),
400
+ extractionCount: z.number().optional()
401
+ });
402
+ function parseInbox(raw) {
403
+ const records = [];
404
+ for (const line of raw.split("\n")) {
405
+ if (line.length === 0) continue;
406
+ let record;
407
+ try {
408
+ record = inboxRecordSchema.parse(JSON.parse(line));
409
+ } catch {
410
+ continue;
411
+ }
412
+ const turns = record.turns;
413
+ const entry = {
414
+ turns,
415
+ lastExtractionAt: record.lastExtractionAt ?? null,
416
+ extractionCount: Number.isFinite(record.extractionCount) ? Number(record.extractionCount) : 0
417
+ };
418
+ if (record.retainedTurns) {
419
+ entry.retainedTurns = record.retainedTurns;
420
+ }
421
+ records.push({ id: record.id, entry });
422
+ }
423
+ return records;
424
+ }
425
+ function pendingTurns(entry) {
426
+ return entry.turns.length + (entry.retainedTurns?.length ?? 0);
427
+ }
428
+ var summarySchema = z.object({
429
+ key: z.string().min(1),
430
+ size: z.number(),
431
+ mtimeMs: z.number(),
432
+ pending: z.number(),
433
+ drained: z.number()
434
+ });
381
435
  var leaseSchema = z.object({
382
436
  ownerId: z.string().min(1),
383
437
  pid: z.number().optional(),
@@ -405,6 +459,8 @@ function parseLease(raw) {
405
459
  function leaseBelongsToProcess(lease, ownerId) {
406
460
  return lease !== null && lease.ownerId === ownerId && lease.pid === process.pid && lease.host === hostname();
407
461
  }
462
+ var INBOX_SUFFIX = ".inbox.jsonl";
463
+ var COUNT_SUFFIX = ".count";
408
464
  var LIST_PAGE_SCAN_FACTOR = 8;
409
465
  var BufferDrainClaims = class {
410
466
  constructor(stateDir, io) {
@@ -425,6 +481,10 @@ var BufferDrainClaims = class {
425
481
  lockFileFor(key) {
426
482
  return `${this.fileFor(key)}.lock`;
427
483
  }
484
+ /** Append-only hand-in log next to the body (see the file header). */
485
+ inboxFor(key) {
486
+ return `${this.fileFor(key)}${INBOX_SUFFIX}`;
487
+ }
428
488
  /** Sync existence check for hot-path gates (`SmartBuffer.isSessionClaimed`). */
429
489
  isClaimed(key) {
430
490
  return existsSync(this.fileFor(key));
@@ -440,13 +500,100 @@ var BufferDrainClaims = class {
440
500
  return null;
441
501
  }
442
502
  }
443
- /** The claim content for `key`, or null when the key is not claimed. */
444
- load(key) {
445
- return this.readClaimBody(key);
503
+ /**
504
+ * Every inbox record, oldest first. Only "no inbox" reads as empty: any
505
+ * other failure (a locked or undecryptable store) propagates, because a
506
+ * compaction that mistook an unreadable inbox for an empty one would delete
507
+ * the handed-in turns.
508
+ */
509
+ async readInbox(key) {
510
+ try {
511
+ return parseInbox(await this.io.read(this.inboxFor(key)));
512
+ } catch (err) {
513
+ if (isErrnoCode(err, "ENOENT")) return [];
514
+ throw err;
515
+ }
446
516
  }
447
- async writeClaimBody(key, claim) {
517
+ /**
518
+ * The body with its inbox folded in, plus the ids of every record the inbox
519
+ * holds (what a compaction must list as consumed). The inbox is read BEFORE
520
+ * the body: a compaction landing between the two reads leaves records the
521
+ * newer body already lists, which the fold skips by id, whereas the reverse
522
+ * order could pair an old body with a deleted inbox and lose their turns.
523
+ */
524
+ async readFolded(key) {
525
+ const records = await this.readInbox(key);
526
+ const body = await this.readClaimBody(key);
527
+ if (!body) return null;
528
+ return { claim: foldRecords(body, records), consumed: records.map((record) => record.id) };
529
+ }
530
+ /** The claim content for `key` (body plus inbox), or null when it is not claimed. */
531
+ async load(key) {
532
+ return (await this.readFolded(key))?.claim ?? null;
533
+ }
534
+ /**
535
+ * Rewrite the body from a folded claim and consume the inbox it folded.
536
+ * Order is the crash contract: body first (it lists `consumed` so a replay
537
+ * skips them), then the summary, then the inbox delete. Callers hold the
538
+ * claim lock, so no append can land between the read and the delete.
539
+ */
540
+ async writeClaimBody(key, claim, consumed = []) {
448
541
  await mkdir(this.dir, { recursive: true });
449
- await this.io.write(this.fileFor(key), JSON.stringify({ ...claim, key }));
542
+ const bodyPath = this.fileFor(key);
543
+ const foldedRecords = consumed.length > 0 ? [...consumed] : void 0;
544
+ await this.io.write(bodyPath, JSON.stringify({ ...claim, key, foldedRecords }));
545
+ await stat(bodyPath).then((identity) => this.writeSummary(bodyPath, claim, identity), () => {
546
+ });
547
+ await unlink2(this.inboxFor(key)).catch(() => {
548
+ });
549
+ }
550
+ /**
551
+ * Best-effort: a missing or stale summary only costs one body parse.
552
+ * `identity` MUST come from a stat taken before `claim` was read (or while
553
+ * the lock holds the body still), never after, or the counts could be
554
+ * tagged with a newer body's identity.
555
+ */
556
+ async writeSummary(bodyPath, claim, identity) {
557
+ const summary = {
558
+ key: claim.key,
559
+ size: identity.size,
560
+ mtimeMs: identity.mtimeMs,
561
+ pending: pendingTurns(claim.entry),
562
+ drained: (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0)
563
+ };
564
+ try {
565
+ await writeFile(`${bodyPath}${COUNT_SUFFIX}`, JSON.stringify(summary));
566
+ } catch (err) {
567
+ log.debug(`buffer drain: claim summary write skipped for ${claim.key}: ${err instanceof Error ? err.message : String(err)}`);
568
+ }
569
+ return summary;
570
+ }
571
+ /**
572
+ * Turn counts of the BODY at `bodyPath` without parsing it: the summary file
573
+ * is trusted only while the body's size and mtime still match the ones it
574
+ * was written against; otherwise the body is parsed once and the summary
575
+ * refreshed. Null when the claim body is absent or unreadable.
576
+ */
577
+ async readSummary(bodyPath) {
578
+ let identity;
579
+ try {
580
+ identity = await stat(bodyPath);
581
+ } catch {
582
+ return null;
583
+ }
584
+ try {
585
+ const cached = summarySchema.parse(JSON.parse(await readFile(`${bodyPath}${COUNT_SUFFIX}`, "utf8")));
586
+ if (cached.size === identity.size && cached.mtimeMs === identity.mtimeMs) return cached;
587
+ } catch {
588
+ }
589
+ let claim;
590
+ try {
591
+ claim = parseBody(await this.io.read(bodyPath));
592
+ } catch (err) {
593
+ log.debug(`buffer drain: skipping unreadable claim file ${path2.basename(bodyPath)}: ${err instanceof Error ? err.message : String(err)}`);
594
+ return null;
595
+ }
596
+ return claim ? await this.writeSummary(bodyPath, claim, identity) : null;
450
597
  }
451
598
  async readLease(key) {
452
599
  try {
@@ -487,6 +634,8 @@ var BufferDrainClaims = class {
487
634
  async (acquired) => {
488
635
  if (!acquired) return "already_claimed";
489
636
  if (this.isClaimed(key)) return "already_claimed";
637
+ await unlink2(this.inboxFor(key)).catch(() => {
638
+ });
490
639
  try {
491
640
  await rename(sessionPath, this.fileFor(key));
492
641
  } catch (err) {
@@ -497,11 +646,13 @@ var BufferDrainClaims = class {
497
646
  throw err;
498
647
  }
499
648
  const claim = await this.readClaimBody(key);
500
- if (!claim || claim.entry.turns.length === 0 && (claim.entry.retainedTurns?.length ?? 0) === 0) {
649
+ if (!claim || pendingTurns(claim.entry) === 0) {
501
650
  await unlink2(this.fileFor(key)).catch(() => {
502
651
  });
503
652
  return "empty";
504
653
  }
654
+ await stat(this.fileFor(key)).then((identity) => this.writeSummary(this.fileFor(key), claim, identity), () => {
655
+ });
505
656
  await this.writeLease(key, lease);
506
657
  return "claimed";
507
658
  }
@@ -545,20 +696,21 @@ var BufferDrainClaims = class {
545
696
  );
546
697
  }
547
698
  /**
548
- * Merge a live owner's in-memory entry INTO the claim (the handover path a
549
- * buffer owner takes instead of persisting a claimed session). Serialized
550
- * against drainer progress writes on the same lock.
699
+ * Hand a live owner's in-memory entry to the claim (the path a buffer owner
700
+ * takes instead of persisting a claimed session): ONE append to the inbox,
701
+ * O(incoming), never a read or rewrite of the claim body. Readers fold the
702
+ * inbox over the body with the identity-aware merge and the drainer's next
703
+ * progress write compacts it. The lock serializes the append against that
704
+ * compaction, so a record can never land between its read and its delete.
551
705
  */
552
706
  async mergeIntoClaim(key, incoming) {
553
707
  await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
554
708
  if (!acquired) {
555
709
  throw new Error(`buffer drain claim for ${key} is busy; retrying the save later`);
556
710
  }
557
- const claim = await this.readClaimBody(key);
558
- if (!claim) {
559
- return;
560
- }
561
- await this.writeClaimBody(key, mergeEntries(claim, incoming));
711
+ if (!this.isClaimed(key)) return;
712
+ await this.io.append(this.inboxFor(key), `
713
+ ${JSON.stringify({ id: randomUUID(), key, ...incoming })}`);
562
714
  });
563
715
  }
564
716
  /** Drainer progress: replace the claim content with what is left to do.
@@ -582,8 +734,9 @@ var BufferDrainClaims = class {
582
734
  if (!leaseBelongsToProcess(lease, ownerId)) {
583
735
  throw new Error(`buffer drain claim for ${key} is no longer owned by ${ownerId}; progress write skipped`);
584
736
  }
585
- const claim = await this.readClaimBody(key);
586
- if (!claim) return;
737
+ const folded = await this.readFolded(key);
738
+ if (!folded) return;
739
+ const { claim, consumed } = folded;
587
740
  const seenTurns = seen?.turns ?? claim.entry.turns;
588
741
  const seenRetained = seen?.retainedTurns ?? claim.entry.retainedTurns ?? [];
589
742
  const entryRetained = entry.retainedTurns ?? [];
@@ -660,7 +813,7 @@ var BufferDrainClaims = class {
660
813
  drained,
661
814
  drainedRetained,
662
815
  movedTurns: moved.length > 0 ? moved : void 0
663
- });
816
+ }, consumed);
664
817
  });
665
818
  }
666
819
  /** Extend the lease. "lost" means another drainer took the claim over.
@@ -702,13 +855,14 @@ var BufferDrainClaims = class {
702
855
  if (!acquired) return "busy";
703
856
  const lease = await this.readLease(key);
704
857
  if (!leaseBelongsToProcess(lease, ownerId)) return "busy";
705
- const claim = await this.readClaimBody(key);
706
- if (!claim) {
858
+ const folded = await this.readFolded(key);
859
+ if (!folded) {
707
860
  await unlink2(this.leaseFileFor(key)).catch(() => {
708
861
  });
709
862
  return "acked";
710
863
  }
711
- if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return "not_empty";
864
+ if (folded.consumed.length > 0) await this.writeClaimBody(key, folded.claim, folded.consumed);
865
+ if (pendingTurns(folded.claim.entry) > 0) return "not_empty";
712
866
  await unlink2(this.leaseFileFor(key)).catch(() => {
713
867
  });
714
868
  return "acked";
@@ -744,39 +898,60 @@ var BufferDrainClaims = class {
744
898
  * merge into it, never release it.
745
899
  */
746
900
  async isDrained(key) {
747
- const claim = await this.readClaimBody(key);
748
- if (!claim) return false;
749
- if ((claim.drained?.length ?? 0) === 0 && (claim.drainedRetained?.length ?? 0) === 0) return false;
750
- if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return false;
751
- return await this.readLease(key) === null;
901
+ if (await this.readLease(key) !== null) return false;
902
+ const summary = await this.readSummary(this.fileFor(key));
903
+ if (!summary || summary.drained === 0 || summary.pending > 0) return false;
904
+ const claim = await this.load(key);
905
+ return claim !== null && (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0) > 0 && pendingTurns(claim.entry) === 0;
752
906
  }
753
- /** Drop the claim file entirely (its owner consumed the tombstone). */
907
+ /** Drop the claim entirely (its owner consumed the tombstone), inbox and summary included. */
754
908
  async release(key) {
755
- await unlink2(this.fileFor(key)).catch(() => {
756
- });
757
- await unlink2(this.leaseFileFor(key)).catch(() => {
758
- });
909
+ const bodyPath = this.fileFor(key);
910
+ for (const file of [this.inboxFor(key), bodyPath, `${bodyPath}${COUNT_SUFFIX}`, this.leaseFileFor(key)]) {
911
+ await unlink2(file).catch(() => {
912
+ });
913
+ }
759
914
  }
760
- /** Every claim with its lease state (CLI scan + summaries). */
761
- async list() {
762
- let names;
915
+ /** Claim body file names in the drain directory (no leases, inboxes, summaries or locks). */
916
+ async claimNames() {
763
917
  try {
764
- names = (await readdir2(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json"));
918
+ return (await readdir2(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
765
919
  } catch (err) {
766
- if (err?.code === "ENOENT") return [];
920
+ if (err?.code === "ENOENT") return null;
767
921
  throw err;
768
922
  }
923
+ }
924
+ /**
925
+ * One claim's listing from its summary plus its inbox: the body is never
926
+ * parsed for a count. While an inbox is unfolded the turn count is an UPPER
927
+ * bound (records can repeat turns the body holds or already drained); the
928
+ * drain that picks the claim up folds exactly and its ack compacts the
929
+ * inbox, so an over-count costs one cheap drain run, never a lost turn.
930
+ */
931
+ async describeClaim(name) {
932
+ const summary = await this.readSummary(path2.join(this.dir, name));
933
+ if (!summary) return null;
934
+ let inboxTurns = 0;
935
+ try {
936
+ for (const record of await this.readInbox(summary.key)) inboxTurns += pendingTurns(record.entry);
937
+ } catch (err) {
938
+ log.debug(`buffer drain: skipping claim ${name} with an unreadable inbox: ${err instanceof Error ? err.message : String(err)}`);
939
+ return null;
940
+ }
941
+ const lease = await this.readLease(summary.key);
942
+ return {
943
+ key: summary.key,
944
+ turnCount: summary.pending + inboxTurns,
945
+ leased: lease !== null,
946
+ leaseExpired: lease !== null ? isLeaseExpired(lease) : true
947
+ };
948
+ }
949
+ /** Every claim with its lease state (CLI scan + summaries). */
950
+ async list() {
769
951
  const out = [];
770
- for (const name of names) {
771
- const claim = await this.readClaimBodyByKey(name);
772
- if (!claim) continue;
773
- const lease = await this.readLease(claim.key);
774
- out.push({
775
- key: claim.key,
776
- turnCount: claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0),
777
- leased: lease !== null,
778
- leaseExpired: lease !== null ? isLeaseExpired(lease) : true
779
- });
952
+ for (const name of await this.claimNames() ?? []) {
953
+ const listing = await this.describeClaim(name);
954
+ if (listing) out.push(listing);
780
955
  }
781
956
  return out;
782
957
  }
@@ -786,20 +961,14 @@ var BufferDrainClaims = class {
786
961
  * `nextCursor` by a previous page — file-name order is stable, so a
787
962
  * rotating cursor visits every claim fairly without ever materializing
788
963
  * the whole directory's bodies). Empty tombstones never count toward the
789
- * limit; the number of bodies read per call is bounded separately
964
+ * limit; the number of claims examined per call is bounded separately
790
965
  * (`LIST_PAGE_SCAN_FACTOR` per requested entry), and the cursor advances
791
966
  * past every scanned name so a long tombstone run is crossed over several
792
967
  * pages instead of starving the pending claims behind it.
793
968
  */
794
969
  async listPage(afterName, limit) {
795
- let names;
796
- try {
797
- names = (await readdir2(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
798
- } catch (err) {
799
- const errno = err?.code;
800
- if (errno === "ENOENT") return { entries: [] };
801
- throw err;
802
- }
970
+ const names = await this.claimNames();
971
+ if (names === null) return { entries: [] };
803
972
  const start = afterName === void 0 ? 0 : names.findIndex((name) => name > afterName);
804
973
  const out = [];
805
974
  let cursor;
@@ -810,21 +979,50 @@ var BufferDrainClaims = class {
810
979
  const name = names[index];
811
980
  scanned += 1;
812
981
  cursor = name;
813
- const claim = await this.readClaimBodyByKey(name);
814
- if (!claim) continue;
815
- const turnCount = claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0);
816
- if (turnCount <= 0) continue;
817
- const lease = await this.readLease(claim.key);
818
- out.push({
819
- key: claim.key,
820
- turnCount,
821
- leased: lease !== null,
822
- leaseExpired: lease !== null ? isLeaseExpired(lease) : true
823
- });
982
+ const listing = await this.describeClaim(name);
983
+ if (listing && listing.turnCount > 0) out.push(listing);
824
984
  }
825
985
  return { entries: out, nextCursor: cursor };
826
986
  }
827
- async readClaimBodyByKey(name) {
987
+ /**
988
+ * Rollback helper: fold every claim's inbox into its body and delete the
989
+ * inboxes, so a build that predates the inbox (it never reads one) sees every
990
+ * handed-in turn. Run it with the daemon stopped, BEFORE downgrading; the
991
+ * same fold is what compaction does, so it is safe to run on a live store.
992
+ * Returns how many inboxes were folded.
993
+ */
994
+ async foldAllInboxes() {
995
+ let names;
996
+ try {
997
+ names = (await readdir2(this.dir)).filter((name) => name.endsWith(INBOX_SUFFIX));
998
+ } catch (err) {
999
+ if (isErrnoCode(err, "ENOENT")) return 0;
1000
+ throw err;
1001
+ }
1002
+ let folded = 0;
1003
+ for (const name of names) {
1004
+ const claim = await this.readClaimBodyByName(name.slice(0, -INBOX_SUFFIX.length));
1005
+ if (!claim) continue;
1006
+ folded += await withHeldFileLock(
1007
+ this.lockFileFor(claim.key),
1008
+ { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS },
1009
+ async (acquired) => {
1010
+ if (!acquired) throw new Error(`buffer drain claim for ${claim.key} is busy; its inbox was not folded`);
1011
+ const current = await this.readFolded(claim.key);
1012
+ if (!current) return 0;
1013
+ if (current.consumed.length === 0) {
1014
+ await unlink2(this.inboxFor(claim.key)).catch(() => {
1015
+ });
1016
+ return 0;
1017
+ }
1018
+ await this.writeClaimBody(claim.key, current.claim, current.consumed);
1019
+ return 1;
1020
+ }
1021
+ );
1022
+ }
1023
+ return folded;
1024
+ }
1025
+ async readClaimBodyByName(name) {
828
1026
  try {
829
1027
  return parseBody(await this.io.read(path2.join(this.dir, name)));
830
1028
  } catch (err) {
@@ -840,4 +1038,4 @@ export {
840
1038
  legacyStateToEntries,
841
1039
  BufferSessionFiles
842
1040
  };
843
- //# sourceMappingURL=chunk-NT7F7WJQ.js.map
1041
+ //# sourceMappingURL=chunk-RFUL2ZIQ.js.map