@cello-protocol/daemon 0.0.123 → 0.0.125
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/close-session-handler.d.ts +2 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +58 -1
- package/dist/close-session-handler.js.map +1 -1
- package/dist/daemon.js +65 -6
- package/dist/daemon.js.map +1 -1
- package/dist/inbound-seal-request.d.ts.map +1 -1
- package/dist/inbound-seal-request.js +14 -1
- package/dist/inbound-seal-request.js.map +1 -1
- package/dist/retry-queue.d.ts +8 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +9 -1
- package/dist/retry-queue.js.map +1 -1
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +18 -0
- package/dist/seal-flows.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +42 -10
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +48 -4
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +168 -20
- package/dist/session-node-manager.js.map +1 -1
- package/package.json +3 -3
|
@@ -229,9 +229,45 @@ 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
|
+
}
|
|
232
265
|
// M12-P12: the durable enqueue for a park deposit that FAILED. Distinct from onTtf because the
|
|
233
266
|
// cause is distinct — nothing timed out here, the deposit was refused — and an event named for
|
|
234
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.
|
|
235
271
|
#onParkFailed = null;
|
|
236
272
|
/**
|
|
237
273
|
* MSG-001-3b (2b): the live content-park deposit. The manager resolves the recipient + relay
|
|
@@ -370,6 +406,19 @@ export class SessionNodeManager {
|
|
|
370
406
|
* result — the deposit itself and its logging are unchanged either way.
|
|
371
407
|
*/
|
|
372
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
|
+
}
|
|
373
422
|
const hook = this.#contentParkHook;
|
|
374
423
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
375
424
|
// M12-P12 (review F6): "no park target configured" is NOT a refused deposit. Content in a
|
|
@@ -377,7 +426,7 @@ export class SessionNodeManager {
|
|
|
377
426
|
// be a lie that grows the DB forever — every boot and every agent start would retry a row whose
|
|
378
427
|
// only possible outcome is no_persisted_relay_endpoint. Reported as unconfigured, not refused.
|
|
379
428
|
if (!hook || !entry || !entry.relayPeerId || !entry.relayAddrs)
|
|
380
|
-
return "unconfigured";
|
|
429
|
+
return { outcome: "unconfigured" };
|
|
381
430
|
try {
|
|
382
431
|
const result = await hook({
|
|
383
432
|
// SEC-1: the hook must sign as the SENDING agent — it needs to know who that is.
|
|
@@ -404,9 +453,9 @@ export class SessionNodeManager {
|
|
|
404
453
|
reason: result.reason,
|
|
405
454
|
cause: result.cause,
|
|
406
455
|
});
|
|
407
|
-
return "refused";
|
|
456
|
+
return { outcome: "refused", cause: result.cause ?? result.reason };
|
|
408
457
|
}
|
|
409
|
-
return "parked";
|
|
458
|
+
return { outcome: "parked" };
|
|
410
459
|
}
|
|
411
460
|
catch (err) {
|
|
412
461
|
this.#logger.warn("content.park.deposit.failed", {
|
|
@@ -414,7 +463,7 @@ export class SessionNodeManager {
|
|
|
414
463
|
contentHash: contentHashHex,
|
|
415
464
|
error: err instanceof Error ? err.message : String(err),
|
|
416
465
|
});
|
|
417
|
-
return "refused";
|
|
466
|
+
return { outcome: "refused", cause: err instanceof Error ? err.message : String(err) };
|
|
418
467
|
}
|
|
419
468
|
}
|
|
420
469
|
// ─── Initialization ──────────────────────────────────────────────────────
|
|
@@ -2896,7 +2945,10 @@ export class SessionNodeManager {
|
|
|
2896
2945
|
async sendContent(agentName, sessionId, content, contentHash, correlationId) {
|
|
2897
2946
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
2898
2947
|
if (!entry) {
|
|
2899
|
-
|
|
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 };
|
|
2900
2952
|
}
|
|
2901
2953
|
// R1 (MSG-001-3b): witness the message-leaf HASH to the relay FIRST, INDEPENDENT of
|
|
2902
2954
|
// direct delivery. The relay is the ordering authority (Structure 2): it assigns the
|
|
@@ -2970,6 +3022,14 @@ export class SessionNodeManager {
|
|
|
2970
3022
|
structure1_cbor: orderingS1,
|
|
2971
3023
|
structure2_cbor: orderingS2,
|
|
2972
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
|
+
}
|
|
2973
3033
|
stream.send(lp.encode.single(frame));
|
|
2974
3034
|
try {
|
|
2975
3035
|
await stream.close();
|
|
@@ -2989,7 +3049,7 @@ export class SessionNodeManager {
|
|
|
2989
3049
|
// agent sees the truth (the message IS in flight, just not direct), not a false negative.
|
|
2990
3050
|
const hashHex = Buffer.from(contentHash).toString("hex");
|
|
2991
3051
|
const attempt = await this.#parkContent(agentName, sessionId, hashHex, content, orderingS1, orderingS2);
|
|
2992
|
-
if (attempt === "parked") {
|
|
3052
|
+
if (attempt.outcome === "parked") {
|
|
2993
3053
|
return { ok: true, delivered: false, parked: true };
|
|
2994
3054
|
}
|
|
2995
3055
|
// M12-P12: the deposit was refused, and #untrackAwaitingAck above already dropped the
|
|
@@ -3000,17 +3060,43 @@ export class SessionNodeManager {
|
|
|
3000
3060
|
// rebuilt. Only on a REFUSAL — a successful deposit must not be re-parked, and an
|
|
3001
3061
|
// unconfigured session has no park target to retry against (F6).
|
|
3002
3062
|
let durable = false;
|
|
3003
|
-
if (attempt === "refused") {
|
|
3063
|
+
if (attempt.outcome === "refused") {
|
|
3004
3064
|
try {
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
//
|
|
3008
|
-
//
|
|
3009
|
-
//
|
|
3010
|
-
this.#
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
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
|
+
}
|
|
3014
3100
|
}
|
|
3015
3101
|
catch (hookErr) {
|
|
3016
3102
|
// F3: enqueueAwaitingContent throws ON PURPOSE when the persist fails, because that is
|
|
@@ -3045,8 +3131,19 @@ export class SessionNodeManager {
|
|
|
3045
3131
|
ok: false,
|
|
3046
3132
|
reason: "session_stream_unavailable",
|
|
3047
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 } : {}),
|
|
3048
3145
|
guidance: durable
|
|
3049
|
-
? "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.
|
|
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."
|
|
3050
3147
|
: "Direct delivery failed and the message could NOT be queued for retry — it is lost. Send it again.",
|
|
3051
3148
|
};
|
|
3052
3149
|
}
|
|
@@ -3768,9 +3865,11 @@ export class SessionNodeManager {
|
|
|
3768
3865
|
}
|
|
3769
3866
|
/**
|
|
3770
3867
|
* DOD-MSG-4: the relay's high-water canonical sequence for this session (largest witnessed leaf),
|
|
3771
|
-
* or -1 if none.
|
|
3772
|
-
*
|
|
3773
|
-
*
|
|
3868
|
+
* or -1 if none. The relay is the ordering authority, so this is the outside view of how far the
|
|
3869
|
+
* session has actually progressed — which is why it is the right input to a catch-up-before-live
|
|
3870
|
+
* gate. Consumed by `sealReadiness` (M12-P14) for REPORTING only: the missing-leaf decision is made
|
|
3871
|
+
* from `#witnessedSeq`, because this counts the relay's sequence space (which includes ctrl leaves)
|
|
3872
|
+
* and the tree does not. Maintained by `recordWitnessedSequence`.
|
|
3774
3873
|
*/
|
|
3775
3874
|
/**
|
|
3776
3875
|
* DOD-COATTEND-1 (review F2): leaf sequences whose plaintext failed to reach the transcript and
|
|
@@ -3782,6 +3881,55 @@ export class SessionNodeManager {
|
|
|
3782
3881
|
getHighWaterSeq(agentName, sessionId) {
|
|
3783
3882
|
return this.#highWaterSeq.get(this.#k(agentName, sessionId)) ?? -1;
|
|
3784
3883
|
}
|
|
3884
|
+
/**
|
|
3885
|
+
* M12-P14: is this side's chain COMPLETE enough to be sealed?
|
|
3886
|
+
*
|
|
3887
|
+
* A seal is a bilateral signature over the same conversation, so a side that is missing a leaf
|
|
3888
|
+
* cannot produce a signable one — the counterparty compares frontiers and refuses with
|
|
3889
|
+
* `leaf_count_mismatch`. That refusal is correct and it is also terminal: there is no backfill
|
|
3890
|
+
* request in the protocol, so the only exit is a force-abandon, which yields NO notarized receipt.
|
|
3891
|
+
* Measured 2026-08-05 on two sessions that died exactly this way (initiator 2 leaves, responder 3).
|
|
3892
|
+
*
|
|
3893
|
+
* The cheap prevention is to notice BEFORE asking. Two local signals already exist and, until now,
|
|
3894
|
+
* nothing read either of them at close time:
|
|
3895
|
+
* - `#highWaterSeq` — the largest canonical sequence the RELAY has witnessed for this session.
|
|
3896
|
+
* The relay is the ordering authority, so a high-water above our own frontier is proof that a
|
|
3897
|
+
* leaf exists which we have not appended. (Its own doc comment called it "reserved … NOT yet
|
|
3898
|
+
* consumed by the gate" — this is that consumer.)
|
|
3899
|
+
* - `#heldContent` — content we HAVE received and verified but cannot append because it sits
|
|
3900
|
+
* behind a gap. Holding content and sealing anyway would seal a chain we know is short.
|
|
3901
|
+
*
|
|
3902
|
+
* Deliberately NOT a network call: it must work when the counterparty is unreachable, which is
|
|
3903
|
+
* the whole situation a seal-interrupted exists for.
|
|
3904
|
+
*
|
|
3905
|
+
* KNOWN LIMIT, stated rather than hidden: both maps are in-memory and cleared on teardown, so
|
|
3906
|
+
* after a daemon restart this returns ready for a session whose gap predates the restart — which
|
|
3907
|
+
* is the shape of the 2026-08-05 incident itself. Closing that needs the mailbox drained (or the
|
|
3908
|
+
* high-water persisted) before the check; tracked with M12-P14, not claimed here.
|
|
3909
|
+
*/
|
|
3910
|
+
sealReadiness(agentName, sessionId) {
|
|
3911
|
+
const key = this.#k(agentName, sessionId);
|
|
3912
|
+
const treeSize = this.getSessionTree(agentName, sessionId).size();
|
|
3913
|
+
const highWaterSeq = this.#highWaterSeq.get(key) ?? -1;
|
|
3914
|
+
const heldCount = this.#heldContent.get(key)?.size ?? 0;
|
|
3915
|
+
// Review HIGH-2: NOT `(highWaterSeq + 1) - treeSize`. That subtraction silently assumes the
|
|
3916
|
+
// relay's sequence space and this tree's index space count the same things, and they do not:
|
|
3917
|
+
// `relay-node.ts` increments seq_counter for EVERY accepted leaf including CTRL (0x02), while
|
|
3918
|
+
// `appendSessionLeaf` is only ever called with "msg" — `submitSealLeaf` deliberately computes
|
|
3919
|
+
// its root without mutating the durable tree. So one seal ctrl leaf offsets the two spaces
|
|
3920
|
+
// permanently, and any msg witnessed afterwards would read as a missing leaf FOREVER. That is a
|
|
3921
|
+
// false positive, and a false positive here is worse than the bug it guards: it makes a healthy
|
|
3922
|
+
// session unsealable, leaving force-abandon (no receipt) as the only exit. `seal-upgrade.ts`
|
|
3923
|
+
// already documents the same `leaf_count - 1` offset.
|
|
3924
|
+
//
|
|
3925
|
+
// `#witnessedSeq` answers the question directly instead of inferring it. It gains an entry when
|
|
3926
|
+
// the relay witnesses a COUNTERPARTY msg leaf (ctrl leaves are excluded at the call site) and
|
|
3927
|
+
// loses it the moment that leaf is appended. So its remaining size IS the count of leaves the
|
|
3928
|
+
// ordering authority has committed and this tree has not — no arithmetic, no space mismatch,
|
|
3929
|
+
// and it cannot go negative.
|
|
3930
|
+
const missingLeaves = this.#witnessedSeq.get(key)?.size ?? 0;
|
|
3931
|
+
return { ready: missingLeaves === 0 && heldCount === 0, treeSize, highWaterSeq, heldCount, missingLeaves };
|
|
3932
|
+
}
|
|
3785
3933
|
/** DOD-MSG-4 / DAEMON-004: append a verified message leaf and buffer it for cello_receive. */
|
|
3786
3934
|
#appendVerifiedContent(agentName, sessionId, content, contentHashHex, senderPubkey, correlationId) {
|
|
3787
3935
|
const { leafIndex } = this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId);
|