@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
@@ -14,11 +14,31 @@
14
14
  * s-<sha256(key)>.lease.json { ownerId, pid, acquiredAt, heartbeatAt,
15
15
  * leaseMs } — created with exclusive-create so
16
16
  * exactly one drainer holds a key.
17
+ * s-<sha256(key)>.json.inbox.jsonl
18
+ * append-only hand-ins from a live buffer owner,
19
+ * one `\n`-prefixed JSON record per line:
20
+ * { id, key, ...entry }. mergeIntoClaim only
21
+ * appends here (O(incoming)); the claim a reader
22
+ * sees is the body with every record folded in
23
+ * by the identity-aware merge. The drainer's
24
+ * progress write (and ack) folds the inbox into
25
+ * the body, lists the folded ids in the body's
26
+ * `foldedRecords`, and only then deletes the
27
+ * inbox, so a kill between the two replays
28
+ * nothing. A torn last line is skipped: its
29
+ * owner never saw the append succeed.
30
+ * s-<sha256(key)>.json.count cached { pending, drained } turn counts of the
31
+ * body, valid while the body's size and mtime
32
+ * match, so listings and the hand-over gate
33
+ * never parse a body.
34
+ *
35
+ * Rolling back to a build that predates the inbox: run `foldAllInboxes()` (CLI:
36
+ * `remnic buffer drain --fold-inboxes`) first; that build never reads an inbox.
17
37
  *
18
38
  * Invariants (see the buffer-drain PR for the full protocol):
19
- * - At every instant the claim content file holds exactly the turns whose
20
- * extraction is not durably finished, so a kill at any point loses
21
- * nothing and the next run resumes.
39
+ * - At every instant the claim (body plus unfolded inbox) holds exactly the
40
+ * turns whose extraction is not durably finished, so a kill at any point
41
+ * loses nothing and the next run resumes.
22
42
  * - A live buffer owner that must persist a claimed session hands its
23
43
  * in-memory entry INTO the claim (identity-aware, occurrence-preserving
24
44
  * merge) instead of writing a session file — it can never resurrect
@@ -28,19 +48,22 @@
28
48
  * lease; the lock also fences lease takeover and progress writes).
29
49
  *
30
50
  * All content reads/writes go through the storage's secure file I/O
31
- * (`BufferFileIo`); only lease/existence checks touch raw paths, and claim
32
- * rewrites serialize on a cross-process held file lock.
51
+ * (`BufferFileIo`); only lease/existence checks and the count summary touch
52
+ * raw paths, and claim rewrites and inbox appends serialize on a cross-process
53
+ * held file lock.
33
54
  */
34
55
 
56
+ import { randomUUID } from "node:crypto";
35
57
  import { hostname } from "node:os";
36
58
  import { z } from "zod";
37
59
  import { existsSync } from "node:fs";
38
- import { mkdir, readdir, rename, unlink, writeFile } from "node:fs/promises";
60
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
39
61
  import path from "node:path";
40
62
 
41
63
  import { bufferSessionFileName, type BufferFileIo } from "./buffer-session-files.js";
42
64
  import { bufferTurnIdentityKey, subtractBufferTurns, type DrainedTurnSets } from "../buffer-turn-helpers.js";
43
65
  import { log } from "../logger.js";
66
+ import { isErrnoCode } from "../utils/errno.js";
44
67
  import { withHeldFileLock } from "../utils/serialize-mutations.js";
45
68
  import type { BufferEntryState, BufferTurn } from "../types.js";
46
69
 
@@ -83,6 +106,28 @@ export interface DrainClaimFile {
83
106
  * the stale copy in the list the turn started in.
84
107
  */
85
108
  movedTurns?: BufferTurn[];
109
+ /**
110
+ * Ids of the inbox records the latest compaction folded into this body. The
111
+ * inbox file is deleted AFTER the body write, so a kill in between leaves
112
+ * records that are already in the body; readers skip exactly these ids.
113
+ */
114
+ foldedRecords?: string[];
115
+ }
116
+
117
+ /** One hand-in appended to the claim's inbox by a live buffer owner. */
118
+ interface InboxRecord {
119
+ id: string;
120
+ entry: BufferEntryState;
121
+ }
122
+
123
+ /** A claim's turn counts, cached beside the body so listings never parse it. */
124
+ interface ClaimSummary {
125
+ key: string;
126
+ /** Identity of the body the counts describe; any rewrite invalidates them. */
127
+ size: number;
128
+ mtimeMs: number;
129
+ pending: number;
130
+ drained: number;
86
131
  }
87
132
 
88
133
  export type ClaimFromSessionOutcome = "claimed" | "empty" | "already_claimed";
@@ -113,17 +158,26 @@ function turnCounts(turns: readonly BufferTurn[]): Map<string, number> {
113
158
  return counts;
114
159
  }
115
160
 
116
- function mergeTurns(existing: BufferTurn[], incoming: readonly BufferTurn[]): BufferTurn[] {
117
- const merged = [...existing];
118
- const existingCounts = turnCounts(existing);
161
+ /**
162
+ * Append the incoming occurrences the claim does not hold yet. `counts` is the
163
+ * claim's per-identity occurrence count at the START of this merge (it is
164
+ * updated after, not during, so two equal incoming turns can both land) and
165
+ * stays in step across successive merges, so folding N records costs one pass
166
+ * over the claim instead of N.
167
+ */
168
+ function appendNewOccurrences(target: BufferTurn[], counts: Map<string, number>, incoming: readonly BufferTurn[]): void {
119
169
  const incomingCounts = new Map<string, number>();
170
+ const added: string[] = [];
120
171
  for (const turn of incoming) {
121
172
  const key = bufferTurnIdentityKey(turn);
122
173
  const count = (incomingCounts.get(key) ?? 0) + 1;
123
174
  incomingCounts.set(key, count);
124
- if (count > (existingCounts.get(key) ?? 0)) merged.push(turn);
175
+ if (count > (counts.get(key) ?? 0)) {
176
+ target.push(turn);
177
+ added.push(key);
178
+ }
125
179
  }
126
- return merged;
180
+ for (const key of added) counts.set(key, (counts.get(key) ?? 0) + 1);
127
181
  }
128
182
 
129
183
  function preserveSourceOccurrences(
@@ -165,24 +219,36 @@ function takeByIdentity(source: readonly BufferTurn[], identity: string, count:
165
219
  return source.filter((turn) => bufferTurnIdentityKey(turn) === identity).slice(0, count);
166
220
  }
167
221
 
168
- function mergeEntries(claim: DrainClaimFile, incoming: BufferEntryState): DrainClaimFile {
222
+ /**
223
+ * The claim a reader sees: the body with every not-yet-compacted inbox record
224
+ * merged in order. A record whose id the body already lists (`foldedRecords`)
225
+ * was consumed by a compaction whose inbox delete never ran, so it is skipped
226
+ * exactly. The claim's own lists already exclude what was extracted; each
227
+ * tombstone applies only to the incoming copy in the list the turn was
228
+ * extracted from.
229
+ */
230
+ function foldRecords(claim: DrainClaimFile, records: readonly InboxRecord[]): DrainClaimFile {
231
+ const consumed = new Set(claim.foldedRecords);
232
+ const fresh = records.filter((record) => !consumed.has(record.id));
233
+ if (fresh.length === 0) return claim;
234
+ const turns = [...claim.entry.turns];
235
+ const retainedTurns = [...(claim.entry.retainedTurns ?? [])];
236
+ const activeCounts = turnCounts(turns);
237
+ const retainedCounts = turnCounts(retainedTurns);
169
238
  const entry = emptyEntry();
170
- // The claim's own lists already exclude what was extracted; each tombstone
171
- // applies only to the incoming copy in the list the turn was extracted from.
172
- entry.turns = mergeTurns(
173
- claim.entry.turns,
174
- subtractBufferTurns(incoming.turns, claim.drained ?? []),
175
- );
176
- entry.retainedTurns = mergeTurns(
177
- claim.entry.retainedTurns ?? [],
178
- subtractBufferTurns(incoming.retainedTurns ?? [], claim.drainedRetained ?? []),
179
- );
180
- if (entry.retainedTurns.length === 0) delete entry.retainedTurns;
181
- const stamps = [claim.entry.lastExtractionAt, incoming.lastExtractionAt].filter(
182
- (value): value is string => value !== null && value !== undefined,
183
- );
184
- entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
185
- entry.extractionCount = Math.max(claim.entry.extractionCount, incoming.extractionCount);
239
+ entry.lastExtractionAt = claim.entry.lastExtractionAt;
240
+ entry.extractionCount = claim.entry.extractionCount;
241
+ for (const { entry: incoming } of fresh) {
242
+ appendNewOccurrences(turns, activeCounts, subtractBufferTurns(incoming.turns, claim.drained ?? []));
243
+ appendNewOccurrences(retainedTurns, retainedCounts, subtractBufferTurns(incoming.retainedTurns ?? [], claim.drainedRetained ?? []));
244
+ const stamps = [entry.lastExtractionAt, incoming.lastExtractionAt].filter(
245
+ (value): value is string => value !== null && value !== undefined,
246
+ );
247
+ entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
248
+ entry.extractionCount = Math.max(entry.extractionCount, incoming.extractionCount);
249
+ }
250
+ entry.turns = turns;
251
+ if (retainedTurns.length > 0) entry.retainedTurns = retainedTurns;
186
252
  return { ...claim, entry };
187
253
  }
188
254
 
@@ -224,6 +290,7 @@ const claimBodySchema = z.object({
224
290
  drained: z.array(z.unknown()).optional(),
225
291
  drainedRetained: z.array(z.unknown()).optional(),
226
292
  movedTurns: z.array(z.unknown()).optional(),
293
+ foldedRecords: z.array(z.string()).optional(),
227
294
  });
228
295
 
229
296
  function parseBody(raw: string): DrainClaimFile | null {
@@ -269,9 +336,62 @@ function parseBody(raw: string): DrainClaimFile | null {
269
336
  // SAFETY: same boundary as the turns above (this module writes the file).
270
337
  claim.movedTurns = body.movedTurns as BufferTurn[];
271
338
  }
339
+ if (body.foldedRecords) claim.foldedRecords = body.foldedRecords;
272
340
  return claim;
273
341
  }
274
342
 
343
+ const inboxRecordSchema = z.object({
344
+ id: z.string().min(1),
345
+ turns: z.array(z.unknown()),
346
+ retainedTurns: z.array(z.unknown()).optional(),
347
+ lastExtractionAt: z.string().nullable().optional(),
348
+ extractionCount: z.number().optional(),
349
+ });
350
+
351
+ /**
352
+ * Parse an inbox file: one JSON record per line. A record that does not parse
353
+ * (a torn tail from a kill mid-append) is skipped: its owner never saw the
354
+ * append succeed and keeps the save pending. Records are newline-PREFIXED, so
355
+ * a torn tail can never fuse with the next record.
356
+ */
357
+ function parseInbox(raw: string): InboxRecord[] {
358
+ const records: InboxRecord[] = [];
359
+ for (const line of raw.split("\n")) {
360
+ if (line.length === 0) continue;
361
+ let record: z.infer<typeof inboxRecordSchema>;
362
+ try {
363
+ record = inboxRecordSchema.parse(JSON.parse(line));
364
+ } catch {
365
+ continue;
366
+ }
367
+ // SAFETY: the inbox is written only by mergeIntoClaim from a BufferEntryState, same boundary as claim bodies.
368
+ const turns = record.turns as BufferTurn[];
369
+ const entry: BufferEntryState = {
370
+ turns,
371
+ lastExtractionAt: record.lastExtractionAt ?? null,
372
+ extractionCount: Number.isFinite(record.extractionCount) ? Number(record.extractionCount) : 0,
373
+ };
374
+ if (record.retainedTurns) {
375
+ // SAFETY: same boundary as the turns above.
376
+ entry.retainedTurns = record.retainedTurns as BufferTurn[];
377
+ }
378
+ records.push({ id: record.id, entry });
379
+ }
380
+ return records;
381
+ }
382
+
383
+ function pendingTurns(entry: BufferEntryState): number {
384
+ return entry.turns.length + (entry.retainedTurns?.length ?? 0);
385
+ }
386
+
387
+ const summarySchema = z.object({
388
+ key: z.string().min(1),
389
+ size: z.number(),
390
+ mtimeMs: z.number(),
391
+ pending: z.number(),
392
+ drained: z.number(),
393
+ });
394
+
275
395
  const leaseSchema = z.object({
276
396
  ownerId: z.string().min(1),
277
397
  pid: z.number().optional(),
@@ -302,6 +422,11 @@ function leaseBelongsToProcess(lease: DrainLease | null, ownerId: string): boole
302
422
  }
303
423
 
304
424
 
425
+ /** Hand-in log beside a claim body; `.jsonl`, so claim-directory scans (`.json`) never mistake it for a claim. */
426
+ const INBOX_SUFFIX = ".inbox.jsonl";
427
+ /** Cached turn counts beside a claim body (see `ClaimSummary`). */
428
+ const COUNT_SUFFIX = ".count";
429
+
305
430
  /** Claim bodies `listPage` may read per requested entry: bounds one scan's
306
431
  * I/O even when the directory is mostly empty tombstones. */
307
432
  const LIST_PAGE_SCAN_FACTOR = 8;
@@ -328,6 +453,11 @@ export class BufferDrainClaims {
328
453
  return `${this.fileFor(key)}.lock`;
329
454
  }
330
455
 
456
+ /** Append-only hand-in log next to the body (see the file header). */
457
+ private inboxFor(key: string): string {
458
+ return `${this.fileFor(key)}${INBOX_SUFFIX}`;
459
+ }
460
+
331
461
  /** Sync existence check for hot-path gates (`SmartBuffer.isSessionClaimed`). */
332
462
  isClaimed(key: string): boolean {
333
463
  return existsSync(this.fileFor(key));
@@ -346,14 +476,104 @@ export class BufferDrainClaims {
346
476
  }
347
477
  }
348
478
 
349
- /** The claim content for `key`, or null when the key is not claimed. */
350
- load(key: string): Promise<DrainClaimFile | null> {
351
- return this.readClaimBody(key);
479
+ /**
480
+ * Every inbox record, oldest first. Only "no inbox" reads as empty: any
481
+ * other failure (a locked or undecryptable store) propagates, because a
482
+ * compaction that mistook an unreadable inbox for an empty one would delete
483
+ * the handed-in turns.
484
+ */
485
+ private async readInbox(key: string): Promise<InboxRecord[]> {
486
+ try {
487
+ return parseInbox(await this.io.read(this.inboxFor(key)));
488
+ } catch (err) {
489
+ if (isErrnoCode(err, "ENOENT")) return [];
490
+ throw err;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * The body with its inbox folded in, plus the ids of every record the inbox
496
+ * holds (what a compaction must list as consumed). The inbox is read BEFORE
497
+ * the body: a compaction landing between the two reads leaves records the
498
+ * newer body already lists, which the fold skips by id, whereas the reverse
499
+ * order could pair an old body with a deleted inbox and lose their turns.
500
+ */
501
+ private async readFolded(key: string): Promise<{ claim: DrainClaimFile; consumed: string[] } | null> {
502
+ const records = await this.readInbox(key);
503
+ const body = await this.readClaimBody(key);
504
+ if (!body) return null;
505
+ return { claim: foldRecords(body, records), consumed: records.map((record) => record.id) };
506
+ }
507
+
508
+ /** The claim content for `key` (body plus inbox), or null when it is not claimed. */
509
+ async load(key: string): Promise<DrainClaimFile | null> {
510
+ return (await this.readFolded(key))?.claim ?? null;
352
511
  }
353
512
 
354
- private async writeClaimBody(key: string, claim: DrainClaimFile): Promise<void> {
513
+ /**
514
+ * Rewrite the body from a folded claim and consume the inbox it folded.
515
+ * Order is the crash contract: body first (it lists `consumed` so a replay
516
+ * skips them), then the summary, then the inbox delete. Callers hold the
517
+ * claim lock, so no append can land between the read and the delete.
518
+ */
519
+ private async writeClaimBody(key: string, claim: DrainClaimFile, consumed: readonly string[] = []): Promise<void> {
355
520
  await mkdir(this.dir, { recursive: true });
356
- await this.io.write(this.fileFor(key), JSON.stringify({ ...claim, key }));
521
+ const bodyPath = this.fileFor(key);
522
+ const foldedRecords = consumed.length > 0 ? [...consumed] : undefined;
523
+ await this.io.write(bodyPath, JSON.stringify({ ...claim, key, foldedRecords }));
524
+ await stat(bodyPath).then((identity) => this.writeSummary(bodyPath, claim, identity), () => {});
525
+ await unlink(this.inboxFor(key)).catch(() => {});
526
+ }
527
+
528
+ /**
529
+ * Best-effort: a missing or stale summary only costs one body parse.
530
+ * `identity` MUST come from a stat taken before `claim` was read (or while
531
+ * the lock holds the body still), never after, or the counts could be
532
+ * tagged with a newer body's identity.
533
+ */
534
+ private async writeSummary(bodyPath: string, claim: DrainClaimFile, identity: { size: number; mtimeMs: number }): Promise<ClaimSummary> {
535
+ const summary: ClaimSummary = {
536
+ key: claim.key,
537
+ size: identity.size,
538
+ mtimeMs: identity.mtimeMs,
539
+ pending: pendingTurns(claim.entry),
540
+ drained: (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0),
541
+ };
542
+ try {
543
+ await writeFile(`${bodyPath}${COUNT_SUFFIX}`, JSON.stringify(summary));
544
+ } catch (err) {
545
+ log.debug(`buffer drain: claim summary write skipped for ${claim.key}: ${err instanceof Error ? err.message : String(err)}`);
546
+ }
547
+ return summary;
548
+ }
549
+
550
+ /**
551
+ * Turn counts of the BODY at `bodyPath` without parsing it: the summary file
552
+ * is trusted only while the body's size and mtime still match the ones it
553
+ * was written against; otherwise the body is parsed once and the summary
554
+ * refreshed. Null when the claim body is absent or unreadable.
555
+ */
556
+ private async readSummary(bodyPath: string): Promise<ClaimSummary | null> {
557
+ let identity;
558
+ try {
559
+ identity = await stat(bodyPath);
560
+ } catch {
561
+ return null;
562
+ }
563
+ try {
564
+ const cached = summarySchema.parse(JSON.parse(await readFile(`${bodyPath}${COUNT_SUFFIX}`, "utf8")));
565
+ if (cached.size === identity.size && cached.mtimeMs === identity.mtimeMs) return cached;
566
+ } catch {
567
+ // No usable summary: fall through to the body.
568
+ }
569
+ let claim: DrainClaimFile | null;
570
+ try {
571
+ claim = parseBody(await this.io.read(bodyPath));
572
+ } catch (err) {
573
+ log.debug(`buffer drain: skipping unreadable claim file ${path.basename(bodyPath)}: ${err instanceof Error ? err.message : String(err)}`);
574
+ return null;
575
+ }
576
+ return claim ? await this.writeSummary(bodyPath, claim, identity) : null;
357
577
  }
358
578
 
359
579
  private async readLease(key: string): Promise<DrainLease | null> {
@@ -405,6 +625,9 @@ export class BufferDrainClaims {
405
625
  // keeps its content and its lease; the session file stays with its
406
626
  // owner (their next save merges into the claim).
407
627
  if (this.isClaimed(key)) return "already_claimed";
628
+ // No claim exists, so an inbox here is an orphan from a crash between
629
+ // a release's deletes: the new claim must not replay it.
630
+ await unlink(this.inboxFor(key)).catch(() => {});
408
631
  try {
409
632
  await rename(sessionPath, this.fileFor(key));
410
633
  } catch (err) {
@@ -417,12 +640,13 @@ export class BufferDrainClaims {
417
640
  throw err;
418
641
  }
419
642
  const claim = await this.readClaimBody(key);
420
- if (!claim || (claim.entry.turns.length === 0 && (claim.entry.retainedTurns?.length ?? 0) === 0)) {
643
+ if (!claim || pendingTurns(claim.entry) === 0) {
421
644
  // Empty content: nothing to drain. Delete the moved file so the
422
645
  // empty entry keeps living with its owner, not as a stale claim.
423
646
  await unlink(this.fileFor(key)).catch(() => {});
424
647
  return "empty";
425
648
  }
649
+ await stat(this.fileFor(key)).then((identity) => this.writeSummary(this.fileFor(key), claim, identity), () => {});
426
650
  await this.writeLease(key, lease);
427
651
  return "claimed";
428
652
  },
@@ -472,9 +696,12 @@ export class BufferDrainClaims {
472
696
  }
473
697
 
474
698
  /**
475
- * Merge a live owner's in-memory entry INTO the claim (the handover path a
476
- * buffer owner takes instead of persisting a claimed session). Serialized
477
- * 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.
478
705
  */
479
706
  async mergeIntoClaim(key: string, incoming: BufferEntryState): Promise<void> {
480
707
  await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
@@ -484,13 +711,12 @@ export class BufferDrainClaims {
484
711
  // pending and retries (SmartBuffer keeps pending state on failure).
485
712
  throw new Error(`buffer drain claim for ${key} is busy; retrying the save later`);
486
713
  }
487
- const claim = await this.readClaimBody(key);
488
- if (!claim) {
489
- // The claim vanished (acked between the isClaimed check and now):
490
- // nothing to hand over; the drain already owns everything.
491
- return;
492
- }
493
- await this.writeClaimBody(key, mergeEntries(claim, incoming));
714
+ // The claim vanished (acked between the isClaimed check and now):
715
+ // nothing to hand over; the drain already owns everything.
716
+ if (!this.isClaimed(key)) return;
717
+ // Leading newline: a torn tail from a kill mid-append ends at this
718
+ // record's newline instead of fusing into it.
719
+ await this.io.append(this.inboxFor(key), `\n${JSON.stringify({ id: randomUUID(), key, ...incoming })}`);
494
720
  });
495
721
  }
496
722
 
@@ -515,8 +741,9 @@ export class BufferDrainClaims {
515
741
  if (!leaseBelongsToProcess(lease, ownerId)) {
516
742
  throw new Error(`buffer drain claim for ${key} is no longer owned by ${ownerId}; progress write skipped`);
517
743
  }
518
- const claim = await this.readClaimBody(key);
519
- if (!claim) return;
744
+ const folded = await this.readFolded(key);
745
+ if (!folded) return;
746
+ const { claim, consumed } = folded;
520
747
  const seenTurns = seen?.turns ?? claim.entry.turns;
521
748
  const seenRetained = seen?.retainedTurns ?? claim.entry.retainedTurns ?? [];
522
749
  const entryRetained = entry.retainedTurns ?? [];
@@ -600,7 +827,7 @@ export class BufferDrainClaims {
600
827
  drained,
601
828
  drainedRetained,
602
829
  movedTurns: moved.length > 0 ? moved : undefined,
603
- });
830
+ }, consumed);
604
831
  });
605
832
  }
606
833
 
@@ -644,12 +871,15 @@ export class BufferDrainClaims {
644
871
  if (!acquired) return "busy";
645
872
  const lease = await this.readLease(key);
646
873
  if (!leaseBelongsToProcess(lease, ownerId)) return "busy";
647
- const claim = await this.readClaimBody(key);
648
- if (!claim) {
874
+ const folded = await this.readFolded(key);
875
+ if (!folded) {
649
876
  await unlink(this.leaseFileFor(key)).catch(() => {});
650
877
  return "acked";
651
878
  }
652
- if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return "not_empty";
879
+ // Compact here too: a claim whose inbox only repeats drained turns
880
+ // would otherwise keep reading as unfinished forever.
881
+ if (folded.consumed.length > 0) await this.writeClaimBody(key, folded.claim, folded.consumed);
882
+ if (pendingTurns(folded.claim.entry) > 0) return "not_empty";
653
883
  // Keep the empty claim as a tombstone until its live owner consumes it.
654
884
  await unlink(this.leaseFileFor(key)).catch(() => {});
655
885
  return "acked";
@@ -688,40 +918,72 @@ export class BufferDrainClaims {
688
918
  * merge into it, never release it.
689
919
  */
690
920
  async isDrained(key: string): Promise<boolean> {
691
- const claim = await this.readClaimBody(key);
692
- if (!claim) return false;
693
- if ((claim.drained?.length ?? 0) === 0 && (claim.drainedRetained?.length ?? 0) === 0) return false;
694
- if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return false;
695
- return (await this.readLease(key)) === null;
921
+ if ((await this.readLease(key)) !== null) return false;
922
+ // This runs on every hand-over save. The cached counts may only answer
923
+ // FALSE (pending turns, nothing drained): a stale cache in that direction
924
+ // costs a deferred tombstone. They must never answer TRUE, because the
925
+ // caller then releases the claim and unlinks whatever is still pending, and
926
+ // a same-size rewrite inside one mtime tick can leave a stale cache that
927
+ // says "nothing pending". So a TRUE is verified by one real fold, once per
928
+ // finished drain.
929
+ const summary = await this.readSummary(this.fileFor(key));
930
+ if (!summary || summary.drained === 0 || summary.pending > 0) return false;
931
+ const claim = await this.load(key);
932
+ return claim !== null && (claim.drained?.length ?? 0) + (claim.drainedRetained?.length ?? 0) > 0 && pendingTurns(claim.entry) === 0;
696
933
  }
697
934
 
698
- /** Drop the claim file entirely (its owner consumed the tombstone). */
935
+ /** Drop the claim entirely (its owner consumed the tombstone), inbox and summary included. */
699
936
  async release(key: string): Promise<void> {
700
- await unlink(this.fileFor(key)).catch(() => {});
701
- await unlink(this.leaseFileFor(key)).catch(() => {});
937
+ const bodyPath = this.fileFor(key);
938
+ for (const file of [this.inboxFor(key), bodyPath, `${bodyPath}${COUNT_SUFFIX}`, this.leaseFileFor(key)]) {
939
+ await unlink(file).catch(() => {});
940
+ }
702
941
  }
703
942
 
704
- /** Every claim with its lease state (CLI scan + summaries). */
705
- async list(): Promise<DrainClaimListing[]> {
706
- let names: string[];
943
+ /** Claim body file names in the drain directory (no leases, inboxes, summaries or locks). */
944
+ private async claimNames(): Promise<string[] | null> {
707
945
  try {
708
- names = (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json"));
946
+ return (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
709
947
  } catch (err) {
710
- // SAFETY: the value comes from this module's own write path; the shape is checked above.
711
- if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return [];
948
+ // SAFETY: readdir rejects with NodeJS system errors; only the ENOENT
949
+ // (no claims directory yet) case maps to an empty listing.
950
+ if ((err as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
712
951
  throw err;
713
952
  }
953
+ }
954
+
955
+ /**
956
+ * One claim's listing from its summary plus its inbox: the body is never
957
+ * parsed for a count. While an inbox is unfolded the turn count is an UPPER
958
+ * bound (records can repeat turns the body holds or already drained); the
959
+ * drain that picks the claim up folds exactly and its ack compacts the
960
+ * inbox, so an over-count costs one cheap drain run, never a lost turn.
961
+ */
962
+ private async describeClaim(name: string): Promise<DrainClaimListing | null> {
963
+ const summary = await this.readSummary(path.join(this.dir, name));
964
+ if (!summary) return null;
965
+ let inboxTurns = 0;
966
+ try {
967
+ for (const record of await this.readInbox(summary.key)) inboxTurns += pendingTurns(record.entry);
968
+ } catch (err) {
969
+ log.debug(`buffer drain: skipping claim ${name} with an unreadable inbox: ${err instanceof Error ? err.message : String(err)}`);
970
+ return null;
971
+ }
972
+ const lease = await this.readLease(summary.key);
973
+ return {
974
+ key: summary.key,
975
+ turnCount: summary.pending + inboxTurns,
976
+ leased: lease !== null,
977
+ leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
978
+ };
979
+ }
980
+
981
+ /** Every claim with its lease state (CLI scan + summaries). */
982
+ async list(): Promise<DrainClaimListing[]> {
714
983
  const out: DrainClaimListing[] = [];
715
- for (const name of names) {
716
- const claim = await this.readClaimBodyByKey(name);
717
- if (!claim) continue;
718
- const lease = await this.readLease(claim.key);
719
- out.push({
720
- key: claim.key,
721
- turnCount: claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0),
722
- leased: lease !== null,
723
- leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
724
- });
984
+ for (const name of (await this.claimNames()) ?? []) {
985
+ const listing = await this.describeClaim(name);
986
+ if (listing) out.push(listing);
725
987
  }
726
988
  return out;
727
989
  }
@@ -732,22 +994,14 @@ export class BufferDrainClaims {
732
994
  * `nextCursor` by a previous page — file-name order is stable, so a
733
995
  * rotating cursor visits every claim fairly without ever materializing
734
996
  * the whole directory's bodies). Empty tombstones never count toward the
735
- * limit; the number of bodies read per call is bounded separately
997
+ * limit; the number of claims examined per call is bounded separately
736
998
  * (`LIST_PAGE_SCAN_FACTOR` per requested entry), and the cursor advances
737
999
  * past every scanned name so a long tombstone run is crossed over several
738
1000
  * pages instead of starving the pending claims behind it.
739
1001
  */
740
1002
  async listPage(afterName: string | undefined, limit: number): Promise<{ entries: DrainClaimListing[]; nextCursor?: string }> {
741
- let names: string[];
742
- try {
743
- names = (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json")).sort();
744
- } catch (err) {
745
- // SAFETY: readdir rejects with NodeJS system errors; only the ENOENT
746
- // (no claims directory yet) case maps to an empty listing.
747
- const errno = (err as NodeJS.ErrnoException | undefined)?.code;
748
- if (errno === "ENOENT") return { entries: [] };
749
- throw err;
750
- }
1003
+ const names = await this.claimNames();
1004
+ if (names === null) return { entries: [] };
751
1005
  const start = afterName === undefined ? 0 : names.findIndex((name) => name > afterName);
752
1006
  const out: DrainClaimListing[] = [];
753
1007
  // The cursor is the LAST CONSUMED entry name: the next page starts
@@ -762,22 +1016,51 @@ export class BufferDrainClaims {
762
1016
  const name = names[index]!;
763
1017
  scanned += 1;
764
1018
  cursor = name;
765
- const claim = await this.readClaimBodyByKey(name);
766
- if (!claim) continue;
767
- const turnCount = claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0);
768
- if (turnCount <= 0) continue;
769
- const lease = await this.readLease(claim.key);
770
- out.push({
771
- key: claim.key,
772
- turnCount,
773
- leased: lease !== null,
774
- leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
775
- });
1019
+ const listing = await this.describeClaim(name);
1020
+ if (listing && listing.turnCount > 0) out.push(listing);
776
1021
  }
777
1022
  return { entries: out, nextCursor: cursor };
778
1023
  }
779
1024
 
780
- private async readClaimBodyByKey(name: string): Promise<DrainClaimFile | null> {
1025
+ /**
1026
+ * Rollback helper: fold every claim's inbox into its body and delete the
1027
+ * inboxes, so a build that predates the inbox (it never reads one) sees every
1028
+ * handed-in turn. Run it with the daemon stopped, BEFORE downgrading; the
1029
+ * same fold is what compaction does, so it is safe to run on a live store.
1030
+ * Returns how many inboxes were folded.
1031
+ */
1032
+ async foldAllInboxes(): Promise<number> {
1033
+ let names: string[];
1034
+ try {
1035
+ names = (await readdir(this.dir)).filter((name) => name.endsWith(INBOX_SUFFIX));
1036
+ } catch (err) {
1037
+ if (isErrnoCode(err, "ENOENT")) return 0;
1038
+ throw err;
1039
+ }
1040
+ let folded = 0;
1041
+ for (const name of names) {
1042
+ const claim = await this.readClaimBodyByName(name.slice(0, -INBOX_SUFFIX.length));
1043
+ if (!claim) continue;
1044
+ folded += await withHeldFileLock(
1045
+ this.lockFileFor(claim.key),
1046
+ { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS },
1047
+ async (acquired) => {
1048
+ if (!acquired) throw new Error(`buffer drain claim for ${claim.key} is busy; its inbox was not folded`);
1049
+ const current = await this.readFolded(claim.key);
1050
+ if (!current) return 0;
1051
+ if (current.consumed.length === 0) {
1052
+ await unlink(this.inboxFor(claim.key)).catch(() => {});
1053
+ return 0;
1054
+ }
1055
+ await this.writeClaimBody(claim.key, current.claim, current.consumed);
1056
+ return 1;
1057
+ },
1058
+ );
1059
+ }
1060
+ return folded;
1061
+ }
1062
+
1063
+ private async readClaimBodyByName(name: string): Promise<DrainClaimFile | null> {
781
1064
  try {
782
1065
  return parseBody(await this.io.read(path.join(this.dir, name)));
783
1066
  } catch (err) {