@cello-protocol/daemon 0.0.122 → 0.0.124

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 (57) hide show
  1. package/dist/daemon.js +126 -17
  2. package/dist/daemon.js.map +1 -1
  3. package/dist/document-store.d.ts +0 -88
  4. package/dist/document-store.d.ts.map +1 -1
  5. package/dist/document-store.js +1 -344
  6. package/dist/document-store.js.map +1 -1
  7. package/dist/document-write-path.d.ts.map +1 -1
  8. package/dist/document-write-path.js +43 -10
  9. package/dist/document-write-path.js.map +1 -1
  10. package/dist/reconnect-drain.d.ts +7 -0
  11. package/dist/reconnect-drain.d.ts.map +1 -1
  12. package/dist/reconnect-drain.js +18 -4
  13. package/dist/reconnect-drain.js.map +1 -1
  14. package/dist/retry-queue.d.ts +16 -1
  15. package/dist/retry-queue.d.ts.map +1 -1
  16. package/dist/retry-queue.js +46 -9
  17. package/dist/retry-queue.js.map +1 -1
  18. package/dist/session-content-handlers.d.ts.map +1 -1
  19. package/dist/session-content-handlers.js +49 -11
  20. package/dist/session-content-handlers.js.map +1 -1
  21. package/dist/session-node-manager.d.ts +13 -1
  22. package/dist/session-node-manager.d.ts.map +1 -1
  23. package/dist/session-node-manager.js +162 -9
  24. package/dist/session-node-manager.js.map +1 -1
  25. package/package.json +4 -4
  26. package/dist/document-delivery-transport.d.ts +0 -78
  27. package/dist/document-delivery-transport.d.ts.map +0 -1
  28. package/dist/document-delivery-transport.js +0 -109
  29. package/dist/document-delivery-transport.js.map +0 -1
  30. package/dist/document-delivery.d.ts +0 -130
  31. package/dist/document-delivery.d.ts.map +0 -1
  32. package/dist/document-delivery.js +0 -246
  33. package/dist/document-delivery.js.map +0 -1
  34. package/dist/document-handshake.d.ts +0 -88
  35. package/dist/document-handshake.d.ts.map +0 -1
  36. package/dist/document-handshake.js +0 -239
  37. package/dist/document-handshake.js.map +0 -1
  38. package/dist/document-lifecycle.d.ts +0 -104
  39. package/dist/document-lifecycle.d.ts.map +0 -1
  40. package/dist/document-lifecycle.js +0 -363
  41. package/dist/document-lifecycle.js.map +0 -1
  42. package/dist/document-notify.d.ts +0 -130
  43. package/dist/document-notify.d.ts.map +0 -1
  44. package/dist/document-notify.js +0 -313
  45. package/dist/document-notify.js.map +0 -1
  46. package/dist/document-reachability.d.ts +0 -42
  47. package/dist/document-reachability.d.ts.map +0 -1
  48. package/dist/document-reachability.js +0 -72
  49. package/dist/document-reachability.js.map +0 -1
  50. package/dist/document-rejection.d.ts +0 -224
  51. package/dist/document-rejection.d.ts.map +0 -1
  52. package/dist/document-rejection.js +0 -374
  53. package/dist/document-rejection.js.map +0 -1
  54. package/dist/line-lcs.d.ts +0 -51
  55. package/dist/line-lcs.d.ts.map +0 -1
  56. package/dist/line-lcs.js +0 -71
  57. package/dist/line-lcs.js.map +0 -1
@@ -229,6 +229,46 @@ export class SessionNodeManager {
229
229
  // still fires and the ACK still resolves — only the durable crash-backstop is skipped.
230
230
  #onAwaitingPersisted = null;
231
231
  #onAwaitingTtf = null;
232
+ // M12-P12 verification: force the next N park deposits to be REFUSED, so the failure this unit
233
+ // fixes can be produced on demand instead of waited for. The real failure is a race — the deposit
234
+ // is refused only in the seconds-long window while the sender's standing receiver rebuilds — and
235
+ // no CLI lever reaches that window: set-agent-offline leaves an open session's node serving, and
236
+ // the CLI refuses a send from an offline agent. Without this the fix ships unwatched.
237
+ // INERT unless the daemon is started with CELLO_FAULT_INJECTION=1; the IPC handler that sets it
238
+ // refuses outright otherwise, so a normal daemon cannot be talked into dropping messages.
239
+ #parkFaultRemaining = 0;
240
+ #parkFaultCause = "standing_receiver_creating";
241
+ // The incident needs BOTH halves: the direct dial has to fail (or the park path is never entered
242
+ // — measured, the counterparty's session node accepts the frame and reports delivered:true even
243
+ // with its agent away), and the park deposit that follows has to be refused. One without the
244
+ // other reproduces nothing.
245
+ #sendFaultRemaining = 0;
246
+ /** Arm the park-deposit fault. Returns the count now armed. */
247
+ injectParkFault(count, cause) {
248
+ this.#parkFaultRemaining = Math.max(0, count);
249
+ if (cause)
250
+ this.#parkFaultCause = cause;
251
+ return this.#parkFaultRemaining;
252
+ }
253
+ /** Arm the direct-send fault — makes the next N sends take the dial-failure path. */
254
+ injectSendFault(count) {
255
+ this.#sendFaultRemaining = Math.max(0, count);
256
+ return this.#sendFaultRemaining;
257
+ }
258
+ getSendFaultRemaining() {
259
+ return this.#sendFaultRemaining;
260
+ }
261
+ /** Remaining armed park faults — so a test can assert the fault was actually consumed. */
262
+ getParkFaultRemaining() {
263
+ return this.#parkFaultRemaining;
264
+ }
265
+ // M12-P12: the durable enqueue for a park deposit that FAILED. Distinct from onTtf because the
266
+ // cause is distinct — nothing timed out here, the deposit was refused — and an event named for
267
+ // the wrong cause is how this path stayed invisible.
268
+ // M12-P13 (review HIGH-1): returns whether the content is ACTUALLY queued. `false` means the
269
+ // queue dropped it (today: the content-derived dedupe key collided), and the caller must then not
270
+ // claim durability — nor commit the leaf that claim now authorises.
271
+ #onParkFailed = null;
232
272
  /**
233
273
  * MSG-001-3b (2b): the live content-park deposit. The manager resolves the recipient + relay
234
274
  * endpoint from the session entry and calls this when a send is NOT confirmed delivered
@@ -277,6 +317,7 @@ export class SessionNodeManager {
277
317
  setAwaitingAckHooks(hooks) {
278
318
  this.#onAwaitingPersisted = hooks.onPersisted ?? null;
279
319
  this.#onAwaitingTtf = hooks.onTtf ?? null;
320
+ this.#onParkFailed = hooks.onParkFailed ?? null;
280
321
  }
281
322
  /**
282
323
  * MSG-001-3b (2b): inject the live content-park deposit (seal + ContentParkClient.deposit).
@@ -365,10 +406,27 @@ export class SessionNodeManager {
365
406
  * result — the deposit itself and its logging are unchanged either way.
366
407
  */
367
408
  async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
409
+ // Fault injection FIRST, so it reproduces the real shape: the refusal happens at the same point
410
+ // the live hook refuses (before any deposit), with the same event and the same `cause`.
411
+ if (this.#parkFaultRemaining > 0) {
412
+ this.#parkFaultRemaining -= 1;
413
+ this.#logger.warn("content.park.deposit.failed", {
414
+ sessionId,
415
+ contentHash: contentHashHex,
416
+ reason: "standing_receiver_unavailable",
417
+ cause: this.#parkFaultCause,
418
+ injected: true,
419
+ });
420
+ return { outcome: "refused", cause: this.#parkFaultCause };
421
+ }
368
422
  const hook = this.#contentParkHook;
369
423
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
424
+ // M12-P12 (review F6): "no park target configured" is NOT a refused deposit. Content in a
425
+ // session with no relay was never recoverable through the park, so queuing it for re-park would
426
+ // be a lie that grows the DB forever — every boot and every agent start would retry a row whose
427
+ // only possible outcome is no_persisted_relay_endpoint. Reported as unconfigured, not refused.
370
428
  if (!hook || !entry || !entry.relayPeerId || !entry.relayAddrs)
371
- return false;
429
+ return { outcome: "unconfigured" };
372
430
  try {
373
431
  const result = await hook({
374
432
  // SEC-1: the hook must sign as the SENDING agent — it needs to know who that is.
@@ -393,10 +451,11 @@ export class SessionNodeManager {
393
451
  sessionId,
394
452
  contentHash: contentHashHex,
395
453
  reason: result.reason,
454
+ cause: result.cause,
396
455
  });
397
- return false;
456
+ return { outcome: "refused", cause: result.cause ?? result.reason };
398
457
  }
399
- return true;
458
+ return { outcome: "parked" };
400
459
  }
401
460
  catch (err) {
402
461
  this.#logger.warn("content.park.deposit.failed", {
@@ -404,7 +463,7 @@ export class SessionNodeManager {
404
463
  contentHash: contentHashHex,
405
464
  error: err instanceof Error ? err.message : String(err),
406
465
  });
407
- return false;
466
+ return { outcome: "refused", cause: err instanceof Error ? err.message : String(err) };
408
467
  }
409
468
  }
410
469
  // ─── Initialization ──────────────────────────────────────────────────────
@@ -2886,7 +2945,10 @@ export class SessionNodeManager {
2886
2945
  async sendContent(agentName, sessionId, content, contentHash, correlationId) {
2887
2946
  const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
2888
2947
  if (!entry) {
2889
- return { ok: false, reason: "session_node_unavailable", error: "no active session node for this session" };
2948
+ // M12-P13: no node, so nothing was witnessed and nothing was queued — the caller must NOT
2949
+ // commit a leaf for this. `durable` is a required field precisely so a new failure branch
2950
+ // cannot be added without answering the question every caller now asks.
2951
+ return { ok: false, reason: "session_node_unavailable", error: "no active session node for this session", durable: false };
2890
2952
  }
2891
2953
  // R1 (MSG-001-3b): witness the message-leaf HASH to the relay FIRST, INDEPENDENT of
2892
2954
  // direct delivery. The relay is the ordering authority (Structure 2): it assigns the
@@ -2960,6 +3022,14 @@ export class SessionNodeManager {
2960
3022
  structure1_cbor: orderingS1,
2961
3023
  structure2_cbor: orderingS2,
2962
3024
  });
3025
+ // Injected dial failure — thrown from inside the try so it lands in exactly the catch the
3026
+ // real connection_lost lands in, and the whole downstream path (untrack → park → durable
3027
+ // enqueue) runs unmodified.
3028
+ if (this.#sendFaultRemaining > 0) {
3029
+ this.#sendFaultRemaining -= 1;
3030
+ this.#logger.warn("content.send.fault.injected", { sessionId, contentHash: Buffer.from(contentHash).toString("hex") });
3031
+ throw new Error("connection_lost: injected direct-send fault");
3032
+ }
2963
3033
  stream.send(lp.encode.single(frame));
2964
3034
  try {
2965
3035
  await stream.close();
@@ -2977,10 +3047,69 @@ export class SessionNodeManager {
2977
3047
  // DOD-LEAVEMSG-1: the deposit is now AWAITED (was fire-and-forget) so a genuine park success
2978
3048
  // can be reported as "dispatched to relay" instead of a raw stream failure — the operator/
2979
3049
  // agent sees the truth (the message IS in flight, just not direct), not a false negative.
2980
- const parked = await this.#parkContent(agentName, sessionId, Buffer.from(contentHash).toString("hex"), content, orderingS1, orderingS2);
2981
- if (parked) {
3050
+ const hashHex = Buffer.from(contentHash).toString("hex");
3051
+ const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
3052
+ if (attempt.outcome === "parked") {
2982
3053
  return { ok: true, delivered: false, parked: true };
2983
3054
  }
3055
+ // M12-P12: the deposit was refused, and #untrackAwaitingAck above already dropped the
3056
+ // in-memory entry — so without this, NOTHING holds the content and the TTF timer that would
3057
+ // have enqueued it is cancelled. The recipient has already witnessed this sequence, so it
3058
+ // holds every later message in the session behind the gap, forever, and tells no one.
3059
+ // Enqueue durably instead; the drain hook re-parks it the moment the standing receiver is
3060
+ // rebuilt. Only on a REFUSAL — a successful deposit must not be re-parked, and an
3061
+ // unconfigured session has no park target to retry against (F6).
3062
+ let durable = false;
3063
+ if (attempt.outcome === "refused") {
3064
+ try {
3065
+ // M12-P13 (review HIGH-1): `durable` is now OBSERVED from the enqueue, not asserted around
3066
+ // it. Two ways this used to lie, both of which now commit a chain leaf and so cannot be
3067
+ // allowed to: the queue's content-derived dedupe key collides and it silently drops the
3068
+ // copy, and the `?.` no-ops entirely when the composition root never wired the hook. An
3069
+ // absent hook is not a queue.
3070
+ if (this.#onParkFailed === null) {
3071
+ this.#logger.error("content.park.durable_enqueue.unwired", {
3072
+ sessionId, contentHash: hashHex, agentName,
3073
+ impact: "no durable queue is wired — the content is NOT retained and will NOT be retried",
3074
+ });
3075
+ }
3076
+ else {
3077
+ durable = this.#onParkFailed(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
3078
+ }
3079
+ if (!durable) {
3080
+ if (this.#onParkFailed !== null) {
3081
+ this.#logger.error("content.park.durable_enqueue.dropped", {
3082
+ sessionId, contentHash: hashHex, agentName,
3083
+ impact: "the durable queue refused this copy (identical content already queued) — it is NOT separately retained",
3084
+ });
3085
+ }
3086
+ }
3087
+ else {
3088
+ // F5: the successful enqueue must be visible. Without this the live run that has to
3089
+ // PROVE this fix has nothing to point at, and this log is the sender-side counterpart to
3090
+ // `session.content.held` on the receiver — the two together make the trace readable.
3091
+ // M12-P13: `witnessed` rides along because the caller is about to commit a hash-chain
3092
+ // leaf on the strength of this. Without a relay ordering record the recipient recovers
3093
+ // in arrival order instead — the accepted degradation, but it must not be invisible.
3094
+ this.#logger.info("content.park.deferred", {
3095
+ sessionId, contentHash: hashHex, agentName,
3096
+ selfOrdering: Boolean(orderingS1 && orderingS2),
3097
+ witnessed: Boolean(orderingS1 && orderingS2),
3098
+ });
3099
+ }
3100
+ }
3101
+ catch (hookErr) {
3102
+ // F3: enqueueAwaitingContent throws ON PURPOSE when the persist fails, because that is
3103
+ // data loss. Swallowing it into the same response the durable case returns would tell the
3104
+ // operator "it will retry" about a message that is simply gone. Named for its own cause,
3105
+ // and the response says so below.
3106
+ this.#logger.error("content.park.durable_enqueue.failed", {
3107
+ sessionId, contentHash: hashHex, agentName,
3108
+ impact: "content is NOT durable and will NOT be retried — the message is lost",
3109
+ error: hookErr instanceof Error ? hookErr.message : String(hookErr),
3110
+ });
3111
+ }
3112
+ }
2984
3113
  // error.message extracted — never [object Object]. libp2p/cross-package errors are not
2985
3114
  // always `instanceof Error` in this realm, so fall back to a message property / JSON.
2986
3115
  const errMsg = err instanceof Error
@@ -2995,7 +3124,28 @@ export class SessionNodeManager {
2995
3124
  return String(err);
2996
3125
  }
2997
3126
  })();
2998
- return { ok: false, reason: "session_stream_unavailable", error: errMsg };
3127
+ // F3: the two failures are NOT interchangeable to the caller. `reason` is a contract string
3128
+ // and stays put; `guidance` carries the difference, because "we are retrying this" and "this
3129
+ // message is gone, send it again" demand opposite actions from the operator.
3130
+ return {
3131
+ ok: false,
3132
+ reason: "session_stream_unavailable",
3133
+ error: errMsg,
3134
+ // M12-P13: the machine-readable half of the distinction below. M12-P12 shipped it in the
3135
+ // guidance SENTENCE only, so the callers that have to ACT on it — commit the leaf for a
3136
+ // queued message, never for a lost one — would have had to substring-match English. None
3137
+ // did, and the sequence the relay had already witnessed was left as a permanent hole.
3138
+ durable,
3139
+ // M12-P13 (review MEDIUM-5): the specific standing-receiver state, carried rather than
3140
+ // discarded. `reason` names where this surfaced; `cause` names what actually blocked it —
3141
+ // the exact distinction M12-P12 added `standingReceiverAbsenceReason()` for, which then
3142
+ // died inside #parkContent. An operator keying on `reason` alone is sent to the transport
3143
+ // when the blocker is the receiver.
3144
+ ...(attempt.cause !== undefined ? { cause: attempt.cause } : {}),
3145
+ guidance: durable
3146
+ ? "Direct delivery failed and the relay refused the hand-off, so the message is queued and will be re-sent automatically when the relay link is back. Do not re-send it: an identical re-send is not separately queued."
3147
+ : "Direct delivery failed and the message could NOT be queued for retry — it is lost. Send it again.",
3148
+ };
2999
3149
  }
3000
3150
  }
3001
3151
  /**
@@ -3966,7 +4116,10 @@ export class SessionNodeManager {
3966
4116
  this.#awaitingAck.delete(ackKey);
3967
4117
  this.#logger.debug("content.delivery.ttf_expired", { sessionId, contentHash: hashHex });
3968
4118
  try {
3969
- this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content);
4119
+ // M12-P12 (review pass 2): the ordering record travels on THIS path too. It is in hand — the
4120
+ // very next statement hands it to #parkContent — and a TTF row written without it re-parks in
4121
+ // arrival order, which is the divergent-leaf-index failure the durable columns exist to stop.
4122
+ this.#onAwaitingTtf?.(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
3970
4123
  }
3971
4124
  catch (err) {
3972
4125
  this.#logger.error("content.park.backstop.failed", {