@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
package/dist/daemon.js CHANGED
@@ -315,6 +315,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
315
315
  // rebuild, auth_ok rebuild) and on its slow backstop sweep. Before this, a message that hit a
316
316
  // relay churn gap sat parked until a human restarted the receiving daemon.
317
317
  sessionNodeManager.setParkedDrainHook((agentName, reason) => {
318
+ // M12-P12 (review F1): the SENDER half of the same event. A refused park deposit leaves a
319
+ // durable retry_queue row, and until this call existed the only things that drained it were a
320
+ // daemon restart and cello_start_agent — so the fix made a lost message restart-recoverable
321
+ // while the DoD line promises "no restart". The watchdog rebuild is where parking actually
322
+ // happens, so it is where the re-park has to fire too.
323
+ void flushAwaitingContent(agentName).catch((err) => {
324
+ logger.warn("content.park.flush.failed", { agentName, trigger: reason, stage: "drain_hook", error: extractErrorMessage(err) });
325
+ });
318
326
  void autoRecoverForAgent(agentName, reason).catch((err) => {
319
327
  // autoRecoverForAgent catches per-relay errors internally; this is the backstop, and it uses
320
328
  // extractErrorMessage because the transport rejects with structured plain objects that
@@ -331,6 +339,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
331
339
  isAgentOnline: (agentName) => onlineAgents.has(agentName),
332
340
  ensureStandingReceiver: (agentName) => sessionNodeManager.ensureStandingReceiverForAgent(agentName),
333
341
  drainParked: (agentName) => autoRecoverForAgent(agentName, "signaling_reconnect"),
342
+ // M12-P12 (review F1): ordered AFTER ensureStandingReceiver by createReconnectDrain's own
343
+ // ensure→drain contract — a re-park needs the receiver that the ensure step rebuilds.
344
+ flushSender: (agentName) => flushAwaitingContent(agentName),
334
345
  });
335
346
  // Created HERE, not where the seal code used to sit (~2,500 lines down), because the listeners
336
347
  // are wired into every signaling manager below — and the originals were FUNCTION DECLARATIONS,
@@ -889,14 +900,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
889
900
  const rejectHash = createHash("sha256").update(new Uint8Array([0x00])).update(rejectBytes).digest();
890
901
  // Best-effort: a send failure still triggers the seal — we are closing regardless.
891
902
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(rejectHash), randomUUID());
892
- if (sendResult.ok) {
903
+ // M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
904
+ // either way the relay already witnessed its sequence. This caller is the sharpest case
905
+ // of the three: the seal is initiated immediately below, so a hole here does not merely
906
+ // stall the far side, it seals a tree that is one leaf short of a sequence the
907
+ // counterparty will still receive content at. The roots then cannot agree, and the
908
+ // session is unsealable for good.
909
+ if (sendResult.ok || sendResult.durable) {
893
910
  const rejectHashHex = Buffer.from(rejectHash).toString("hex");
894
911
  const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", rejectHashHex, randomUUID());
895
912
  sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", rejectBytes, randomUUID());
896
- logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: leafIndex });
913
+ logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: leafIndex, queued: !sendResult.ok });
897
914
  }
898
915
  else {
899
- logger.warn("session.away.inbox.oneshot.reject_send_failed", { agentName, sessionId, reason: sendResult.reason });
916
+ // Not queued anywhere — the rejection is gone. We still seal (we are closing regardless),
917
+ // and the counterparty simply never learns why, so say that plainly rather than at warn.
918
+ logger.error("session.away.inbox.oneshot.reject_send_failed", {
919
+ agentName, sessionId, reason: sendResult.reason, cause: sendResult.cause,
920
+ impact: "the rejection is lost and was NOT queued — the session seals with no explanation to the counterparty",
921
+ });
900
922
  }
901
923
  // DOD-INBOX-ONESHOT-1 / DOD-SEAL-BILATERAL-TIMEOUT-1: initiate the seal via the
902
924
  // relay-mediated path (submitSealLeaf → bilateral wait → unilateral escalation).
@@ -1045,14 +1067,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
1045
1067
  : draftBytes;
1046
1068
  const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
1047
1069
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
1048
- if (!sendResult.ok) {
1070
+ if (!sendResult.ok && !sendResult.durable) {
1049
1071
  // Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
1050
1072
  // away period — clear the guard so the next inbound arrival retries the ack.
1051
1073
  awayAckSent.delete(dedupKey);
1052
- logger.warn("session.away.response.failed", { agentName, sessionId, kind, reason: sendResult.reason });
1074
+ // M12-P13: this branch now means the reply is GONE, not merely late (the queued case is
1075
+ // handled below), so it is an error and it says what the consequence is. It was a bare warn
1076
+ // when it fired live on 2026-08-05 and read as routine churn.
1077
+ logger.error("session.away.response.failed", {
1078
+ agentName, sessionId, kind, reason: sendResult.reason, cause: sendResult.cause,
1079
+ impact: "the away reply is lost and was NOT queued — the counterparty gets no acknowledgement",
1080
+ });
1053
1081
  return;
1054
1082
  }
1055
1083
  const contentHashHex = Buffer.from(contentHash).toString("hex");
1084
+ if (!sendResult.ok) {
1085
+ // M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
1086
+ // owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
1087
+ // it this side's tree stays one short of that sequence, and since `nextExpected` is the tree
1088
+ // size, every message the counterparty sends afterwards is held behind a gap nothing can
1089
+ // fill. That is exactly how the receiver stranded its own session at sequence 0.
1090
+ //
1091
+ // The dedup guard deliberately STAYS SET: a queued reply is coming, and re-sending on the
1092
+ // next arrival would mint a second greeting at a second sequence.
1093
+ const { leafIndex: queuedLeaf } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1094
+ sessionNodeManager.recordTranscriptMessage(agentName, sessionId, queuedLeaf, "sent", contentBytes, randomUUID());
1095
+ logger.info("session.away.response.deferred", {
1096
+ agentName, sessionId, kind, isKnown, sequenceNumber: queuedLeaf,
1097
+ reason: sendResult.reason, cause: sendResult.cause,
1098
+ });
1099
+ return;
1100
+ }
1056
1101
  const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
1057
1102
  sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", contentBytes, randomUUID());
1058
1103
  logger.info("session.away.response.sent", { agentName, sessionId, kind, isKnown, sequenceNumber: leafIndex });
@@ -1176,8 +1221,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
1176
1221
  onPersisted: (agentName, sessionId, contentHashHex) => {
1177
1222
  retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
1178
1223
  },
1179
- onTtf: (agentName, sessionId, contentHashHex, content) => {
1180
- retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content);
1224
+ onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1225
+ retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1226
+ },
1227
+ // M12-P12: same durable destination, different cause — a park deposit the relay refused. The
1228
+ // TTF timer is already cancelled on this path, so this is the only thing holding the content.
1229
+ // M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
1230
+ // copy that reports success now buys a committed hash-chain leaf for content that is gone.
1231
+ onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
1232
+ return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
1181
1233
  },
1182
1234
  });
1183
1235
  // MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
@@ -1227,12 +1279,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
1227
1279
  const node = sessionNodeManager.getStandingReceiverNode();
1228
1280
  if (!node) {
1229
1281
  const reason = "standing_receiver_unavailable";
1230
- logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason });
1282
+ // M12-P12 (review F4): `standing_receiver_unavailable` is the exit-point label that already
1283
+ // misnamed this incident 102 times; standingReceiverAbsenceReason() names WHICH of the four
1284
+ // causes it is. That distinction is what separates "mid-rebuild, a retry in seconds works"
1285
+ // from "agent offline, this will never work" — the difference between a backstop that helps
1286
+ // and one that spins. The wire reason stays put; the cause rides alongside.
1287
+ const cause = sessionNodeManager.standingReceiverAbsenceReason(agentName);
1288
+ logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason, cause });
1231
1289
  // DOD-LEAVEMSG-1 (reviewer HIGH fix): return the typed failure, never resolve as if this
1232
1290
  // were a success — #parkContent's caller (sendContent) shapes a live "dispatched to relay"
1233
1291
  // response from this, and a silently-resolved void here would report a message as safely
1234
1292
  // parked when nothing was ever deposited.
1235
- return { ok: false, reason };
1293
+ return { ok: false, reason, cause };
1236
1294
  }
1237
1295
  // SEC-1: sign the entry as the SENDING agent. Without a key we cannot produce an envelope the
1238
1296
  // recipient will accept, so fail LOUD rather than depositing something that will be refused on
@@ -1311,27 +1369,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
1311
1369
  if (!node)
1312
1370
  return { parked: false, error: "standing_receiver_unavailable" };
1313
1371
  // SEC-1: the crash backstop must sign too — an unsigned re-park would be REFUSED on recovery,
1314
- // which would turn the message-loss backstop into a message-loss cause. This is why the SEC-1
1315
- // signature binds to the sender's own K_local and NOT to the relay's ordering record: the
1316
- // ordering record is not persisted in retry_queue (see below) and so cannot be reproduced here,
1317
- // but the KEY can — the owning agent still holds it after a crash. Hence: NO schema migration.
1372
+ // which would turn the message-loss backstop into a message-loss cause. The SEC-1 signature
1373
+ // binds to the sender's own K_local, which the owning agent still holds after a crash.
1374
+ // M12-P12 (review F2): the ordering record IS now persisted (retry_queue.structure{1,2}_cbor),
1375
+ // so a re-park is self-ordering whenever the row carries one. Rows written before that column
1376
+ // existed carry none and still recover in arrival order — the pre-existing behaviour, now the
1377
+ // exception rather than the rule.
1318
1378
  const senderKp = keyProviders.get(ownerName);
1319
1379
  if (!senderKp)
1320
1380
  return { parked: false, error: "signing_key_unavailable" };
1321
1381
  const recipientPubkey = Buffer.from(record.counterparty_pubkey, "hex");
1322
1382
  const contentHashBytes = Buffer.from(entry.contentHashHex, "hex");
1323
1383
  logger.info("content.park.signed", { agentName: ownerName, sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
1324
- // DOD-MSG-4 (2b): seal the envelope shape too (content only — the durable awaiting queue does not
1325
- // persist the ordering record, so a crash-backstop re-park recovers in arrival order; the common
1326
- // live-park path above carries the full Structure2). Keeps ONE envelope format on the recover side.
1384
+ // DOD-MSG-4 (2b): ONE envelope format on the recover side. M12-P12 (F2): carry the persisted
1385
+ // ordering record when the row has one, so the recipient places the content at its WITNESSED
1386
+ // sequence rather than its arrival index — the receiver's #witnessedSeq map is in-memory and
1387
+ // empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
1327
1388
  // SEC-1: same sole producer as the live hook — the backstop signs from the persisted
1328
- // (sessionId, recipient, contentHash), which is why it survives a crash with NO schema migration.
1389
+ // (sessionId, recipient, contentHash).
1329
1390
  const ciphertext = await sealParkEnvelope({
1330
1391
  signer: senderKp,
1331
1392
  sessionIdHex: entry.sessionId,
1332
1393
  recipientPubkey,
1333
1394
  contentHash: contentHashBytes,
1334
1395
  content: entry.contentBlob,
1396
+ structure1Cbor: entry.structure1Cbor,
1397
+ structure2Cbor: entry.structure2Cbor,
1335
1398
  });
1336
1399
  const client = new ContentParkClient({ relayPeerId: ep.relayPeerId, relayAddrs: [...ep.relayAddrs], logger });
1337
1400
  const res = await client.deposit(node, {
@@ -1351,7 +1414,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
1351
1414
  // the native `startupParkFn` needs the OWNING agent's standing receiver, which exists only once
1352
1415
  // that agent is online. `filterAgentName` scopes the drain to one agent's sessions on the agent-
1353
1416
  // online re-run; with no filter it attempts all (the pre-IPC pass / injected-target test path).
1417
+ // M12-P12 (review pass 2): the sender flush now has FOUR triggers (boot, agent start, the parked-
1418
+ // drain hook, signaling reconnect), and two of them fire deterministically together on agent start
1419
+ // — the drain hook runs inside ensureStandingReceiver, whose own .then() then calls this again
1420
+ // while the first pass is still dialling the relay. The receiver twin (contentPark) already
1421
+ // coalesces for exactly this reason; adding triggers to the sender half without the same guard was
1422
+ // an asymmetry, not a decision. Re-run once at the end if a trigger arrived mid-flight, so a
1423
+ // coalesced call never DROPS work — it defers it.
1424
+ const flushInFlight = new Set();
1425
+ const flushRerunRequested = new Set();
1354
1426
  async function flushAwaitingContent(filterAgentName) {
1427
+ const flushKey = filterAgentName ?? "*";
1428
+ if (flushInFlight.has(flushKey)) {
1429
+ flushRerunRequested.add(flushKey);
1430
+ return;
1431
+ }
1432
+ flushInFlight.add(flushKey);
1433
+ try {
1434
+ await flushAwaitingContentInner(filterAgentName);
1435
+ }
1436
+ finally {
1437
+ flushInFlight.delete(flushKey);
1438
+ }
1439
+ if (flushRerunRequested.delete(flushKey)) {
1440
+ await flushAwaitingContent(filterAgentName);
1441
+ }
1442
+ }
1443
+ async function flushAwaitingContentInner(filterAgentName) {
1355
1444
  // DOD-AGENT-ID-JOINKEY-1: the queue is keyed by the STABLE agent_id, but the caller (and the
1356
1445
  // human-readable log) speak the NAME. Resolve once here; log the name, filter by the id.
1357
1446
  const filterAgentId = filterAgentName !== undefined
@@ -2681,6 +2770,26 @@ async function startDaemonHoldingLock(config, singletonLock) {
2681
2770
  // CELLO-M7-MSG-001 (AC-004/AC-005): the send path records un-acked content here when
2682
2771
  // its TTF timer fires, so a crash before the relay park confirms is recoverable at the
2683
2772
  // next startup flush. Stored in the SAME retry_queue table (awaiting_ack = 1).
2773
+ // M12-P12 verification surface. REFUSES unless the daemon was started with
2774
+ // CELLO_FAULT_INJECTION=1 — the gate is here rather than at the call site so a normal daemon
2775
+ // cannot be talked into dropping messages by anything that can reach the socket, and the refusal
2776
+ // names why rather than silently no-opping.
2777
+ handlers.set("debug_inject_park_fault", async (params) => {
2778
+ if (process.env.CELLO_FAULT_INJECTION !== "1") {
2779
+ return {
2780
+ error: "fault_injection_disabled",
2781
+ guidance: "Start the daemon with CELLO_FAULT_INJECTION=1 to enable. This is a verification surface for M12-P12 and is inert in a normal daemon.",
2782
+ };
2783
+ }
2784
+ const count = typeof params?.count === "number" ? params.count : 1;
2785
+ const cause = typeof params?.cause === "string" ? params.cause : undefined;
2786
+ const armed = sessionNodeManager.injectParkFault(count, cause);
2787
+ // The park fault alone reproduces nothing — the counterparty's session node accepts the frame
2788
+ // and the park path is never entered. Arm the dial failure with it unless told otherwise.
2789
+ const sendArmed = params?.withSendFault === false ? 0 : sessionNodeManager.injectSendFault(count);
2790
+ logger.warn("content.park.fault.armed", { count: armed, sendArmed, cause: cause ?? "standing_receiver_creating" });
2791
+ return { armed, sendArmed };
2792
+ });
2684
2793
  handlers.set("enqueue_awaiting_content", async (params, connectionId) => {
2685
2794
  const sessionId = params?.sessionId;
2686
2795
  const contentHashHex = params?.contentHash;