@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.
- package/dist/daemon.js +126 -17
- package/dist/daemon.js.map +1 -1
- package/dist/document-store.d.ts +0 -88
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +1 -344
- package/dist/document-store.js.map +1 -1
- package/dist/document-write-path.d.ts.map +1 -1
- package/dist/document-write-path.js +43 -10
- package/dist/document-write-path.js.map +1 -1
- package/dist/reconnect-drain.d.ts +7 -0
- package/dist/reconnect-drain.d.ts.map +1 -1
- package/dist/reconnect-drain.js +18 -4
- package/dist/reconnect-drain.js.map +1 -1
- package/dist/retry-queue.d.ts +16 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +46 -9
- package/dist/retry-queue.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +49 -11
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +13 -1
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +162 -9
- package/dist/session-node-manager.js.map +1 -1
- package/package.json +4 -4
- package/dist/document-delivery-transport.d.ts +0 -78
- package/dist/document-delivery-transport.d.ts.map +0 -1
- package/dist/document-delivery-transport.js +0 -109
- package/dist/document-delivery-transport.js.map +0 -1
- package/dist/document-delivery.d.ts +0 -130
- package/dist/document-delivery.d.ts.map +0 -1
- package/dist/document-delivery.js +0 -246
- package/dist/document-delivery.js.map +0 -1
- package/dist/document-handshake.d.ts +0 -88
- package/dist/document-handshake.d.ts.map +0 -1
- package/dist/document-handshake.js +0 -239
- package/dist/document-handshake.js.map +0 -1
- package/dist/document-lifecycle.d.ts +0 -104
- package/dist/document-lifecycle.d.ts.map +0 -1
- package/dist/document-lifecycle.js +0 -363
- package/dist/document-lifecycle.js.map +0 -1
- package/dist/document-notify.d.ts +0 -130
- package/dist/document-notify.d.ts.map +0 -1
- package/dist/document-notify.js +0 -313
- package/dist/document-notify.js.map +0 -1
- package/dist/document-reachability.d.ts +0 -42
- package/dist/document-reachability.d.ts.map +0 -1
- package/dist/document-reachability.js +0 -72
- package/dist/document-reachability.js.map +0 -1
- package/dist/document-rejection.d.ts +0 -224
- package/dist/document-rejection.d.ts.map +0 -1
- package/dist/document-rejection.js +0 -374
- package/dist/document-rejection.js.map +0 -1
- package/dist/line-lcs.d.ts +0 -51
- package/dist/line-lcs.d.ts.map +0 -1
- package/dist/line-lcs.js +0 -71
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
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):
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
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)
|
|
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;
|