@cello-protocol/daemon 0.0.168 → 0.0.170
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/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +16 -0
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/away-detection.d.ts +62 -15
- package/dist/away-detection.d.ts.map +1 -1
- package/dist/away-detection.js +77 -20
- package/dist/away-detection.js.map +1 -1
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +69 -3
- package/dist/close-session-handler.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +406 -303
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-open-registry.d.ts +92 -0
- package/dist/delivery-open-registry.d.ts.map +1 -0
- package/dist/delivery-open-registry.js +121 -0
- package/dist/delivery-open-registry.js.map +1 -0
- package/dist/document-amendment-store.d.ts +94 -46
- package/dist/document-amendment-store.d.ts.map +1 -1
- package/dist/document-amendment-store.js +217 -102
- package/dist/document-amendment-store.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +51 -53
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +16 -157
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-frame-router.d.ts +13 -17
- package/dist/document-frame-router.d.ts.map +1 -1
- package/dist/document-frame-router.js +21 -93
- package/dist/document-frame-router.js.map +1 -1
- package/dist/document-handlers.d.ts +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +462 -511
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-handshake.d.ts +4 -1
- package/dist/document-handshake.d.ts.map +1 -1
- package/dist/document-handshake.js +3 -1
- package/dist/document-handshake.js.map +1 -1
- package/dist/document-inbound.d.ts +18 -5
- package/dist/document-inbound.d.ts.map +1 -1
- package/dist/document-inbound.js +94 -110
- package/dist/document-inbound.js.map +1 -1
- package/dist/document-layer.d.ts +49 -67
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +688 -565
- package/dist/document-layer.js.map +1 -1
- package/dist/document-lifecycle.d.ts +11 -157
- package/dist/document-lifecycle.d.ts.map +1 -1
- package/dist/document-lifecycle.js +26 -568
- package/dist/document-lifecycle.js.map +1 -1
- package/dist/document-publish.d.ts +13 -0
- package/dist/document-publish.d.ts.map +1 -1
- package/dist/document-publish.js +19 -11
- package/dist/document-publish.js.map +1 -1
- package/dist/document-reconcile-engine.d.ts +66 -0
- package/dist/document-reconcile-engine.d.ts.map +1 -0
- package/dist/document-reconcile-engine.js +225 -0
- package/dist/document-reconcile-engine.js.map +1 -0
- package/dist/document-reconcile-scheduler.d.ts +124 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +226 -0
- package/dist/document-reconcile-scheduler.js.map +1 -0
- package/dist/document-rejection.d.ts +2 -0
- package/dist/document-rejection.d.ts.map +1 -1
- package/dist/document-rejection.js +5 -1
- package/dist/document-rejection.js.map +1 -1
- package/dist/document-store.d.ts +18 -247
- package/dist/document-store.d.ts.map +1 -1
- package/dist/document-store.js +95 -827
- package/dist/document-store.js.map +1 -1
- package/dist/inbound-sessions.d.ts +7 -0
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +27 -6
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/initiate-session-handler.d.ts +5 -0
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +1 -0
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +39 -2
- package/dist/notification-handlers.js.map +1 -1
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +134 -8
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +153 -8
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +1376 -35
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +64 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -5
- package/dist/delivery-sweep-bound.d.ts +0 -79
- package/dist/delivery-sweep-bound.d.ts.map +0 -1
- package/dist/delivery-sweep-bound.js +0 -109
- package/dist/delivery-sweep-bound.js.map +0 -1
- package/dist/document-ack-inbound.d.ts +0 -71
- package/dist/document-ack-inbound.d.ts.map +0 -1
- package/dist/document-ack-inbound.js +0 -210
- package/dist/document-ack-inbound.js.map +0 -1
- package/dist/document-control-notifier.d.ts +0 -124
- package/dist/document-control-notifier.d.ts.map +0 -1
- package/dist/document-control-notifier.js +0 -176
- package/dist/document-control-notifier.js.map +0 -1
- package/dist/document-delivery.d.ts +0 -250
- package/dist/document-delivery.d.ts.map +0 -1
- package/dist/document-delivery.js +0 -608
- package/dist/document-delivery.js.map +0 -1
- package/dist/document-join-store.d.ts +0 -80
- package/dist/document-join-store.d.ts.map +0 -1
- package/dist/document-join-store.js +0 -197
- package/dist/document-join-store.js.map +0 -1
package/dist/daemon.js
CHANGED
|
@@ -67,13 +67,10 @@ import { createTelegramDoorbell } from "./telegram-doorbell.js";
|
|
|
67
67
|
import { registerSessionContentHandlers } from "./session-content-handlers.js";
|
|
68
68
|
import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
|
|
69
69
|
import { registerDocumentHandlers } from "./document-handlers.js";
|
|
70
|
-
import { createDocumentControlNotifier } from "./document-control-notifier.js";
|
|
71
70
|
import { wireContentHash } from "./wire-content-hash.js";
|
|
72
71
|
import { DocumentPublish } from "./document-publish.js";
|
|
73
72
|
import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
|
|
74
|
-
import {
|
|
75
|
-
import { sweepAgentEvicting } from "./delivery-sweep-bound.js";
|
|
76
|
-
import { encodeDocumentUpdateEnvelope, DOCUMENT_UPDATE_ENCODING_V1 } from "@cello-protocol/protocol-types";
|
|
73
|
+
import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
|
|
77
74
|
import { createSealFlows } from "./seal-flows.js";
|
|
78
75
|
import { registerCloseSessionHandler } from "./close-session-handler.js";
|
|
79
76
|
import { createInboundSessions } from "./inbound-sessions.js";
|
|
@@ -94,7 +91,8 @@ import { startRegistryPoll } from "./registry-poll.js";
|
|
|
94
91
|
import { CONSENT_ACCEPTED } from "./consent-migration.js";
|
|
95
92
|
import { TrustSignalStore } from "./trust-signal-store.js";
|
|
96
93
|
import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
|
|
97
|
-
import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS } from "./away-detection.js";
|
|
94
|
+
import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
|
|
95
|
+
import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
|
|
98
96
|
import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
|
|
99
97
|
import { decodeCbor } from "@cello-protocol/protocol-types";
|
|
100
98
|
// Minimal no-op KeyProvider stub for session nodes.
|
|
@@ -933,6 +931,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
933
931
|
logger.info("session.away.mutual.skipped", {
|
|
934
932
|
agentName,
|
|
935
933
|
sessionId,
|
|
934
|
+
// DOD-M12B-AWAY-MARK-1: WHICH branch matched. The marker is in-band and therefore
|
|
935
|
+
// typeable, so prefixing every message with it skips the one-shot auto-close for free —
|
|
936
|
+
// the cost of that attack fell from "reproduce the exact away wording" to "type 15
|
|
937
|
+
// characters". This field is what makes a peer doing it on every message visible;
|
|
938
|
+
// without it the line reads as routine machine-to-machine traffic.
|
|
939
|
+
matched: isAutoReplyMarked(text) ? "marker" : "legacy_exact",
|
|
936
940
|
impact: "no away reply and no one-shot seal — two away agents must not notarize a conversation nobody had",
|
|
937
941
|
});
|
|
938
942
|
return;
|
|
@@ -956,7 +960,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
956
960
|
// Reviewer F2: token at the END — the daemon's own output must honor the
|
|
957
961
|
// DOD-SIGNAL-TOKEN-1 append-at-end contract that DOD-WRAP-SUBSTRING-1 detection
|
|
958
962
|
// is anchored on (a counterparty daemon's end-anchored detector must see this close).
|
|
959
|
-
|
|
963
|
+
// DOD-M12B-AWAY-MARK-1: machine-generated, so it is marked — and the marker goes at the
|
|
964
|
+
// FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
|
|
965
|
+
const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
|
|
960
966
|
const rejectBytes = new TextEncoder().encode(rejectText);
|
|
961
967
|
const rejectHash = wireContentHash(rejectBytes);
|
|
962
968
|
// Best-effort: a send failure still triggers the seal — we are closing regardless.
|
|
@@ -969,9 +975,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
969
975
|
// session is unsealable for good.
|
|
970
976
|
if (sendResult.ok || sendResult.durable) {
|
|
971
977
|
const rejectHashHex = Buffer.from(rejectHash).toString("hex");
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
978
|
+
// DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
|
|
979
|
+
// in the codebase for that — the seal is initiated a few lines below, so a leaf at the
|
|
980
|
+
// wrong index does not merely stall the far side, it seals a tree the counterparty can
|
|
981
|
+
// never agree with.
|
|
982
|
+
const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID());
|
|
983
|
+
if (placed.placed) {
|
|
984
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
|
|
985
|
+
}
|
|
986
|
+
logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
|
|
975
987
|
}
|
|
976
988
|
else {
|
|
977
989
|
// Not queued anywhere — the rejection is gone. We still seal (we are closing regardless),
|
|
@@ -1105,6 +1117,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1105
1117
|
try {
|
|
1106
1118
|
// DOD-AWAY-TIER-1: resolve most-specific-first — per-contact away_message → per-tier away
|
|
1107
1119
|
// (settings) → agent default (settings) → the system default (code, per kind). Total.
|
|
1120
|
+
// DOD-M12B-AWAY-MARK-1: THE choke point. Every away reply this daemon sends passes through
|
|
1121
|
+
// this one expression — the per-contact message, the per-tier message, the agent default, the
|
|
1122
|
+
// system default and the stranger ack — so marking here is what makes "an operator's
|
|
1123
|
+
// CONFIGURED away message is indistinguishable from a person" go away, which is the half
|
|
1124
|
+
// exact-text matching could not reach by construction. markAsAutoReply is idempotent, so the
|
|
1125
|
+
// system defaults (already marked at their source) do not pick up a second token.
|
|
1108
1126
|
const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
|
|
1109
1127
|
?? (isKnown ? systemDefault : STRANGER_TEXT);
|
|
1110
1128
|
const draftBytes = new TextEncoder().encode(awayText);
|
|
@@ -1123,9 +1141,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1123
1141
|
logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
|
|
1124
1142
|
return;
|
|
1125
1143
|
}
|
|
1126
|
-
|
|
1144
|
+
// DOD-M12B-AWAY-MARK-1: mark AFTER screening, never before. A redact verdict REPLACES the
|
|
1145
|
+
// bytes, and marking the draft would let that replacement silently strip the marker — an away
|
|
1146
|
+
// reply back on the wire indistinguishable from a person, with no log line saying so. Marking
|
|
1147
|
+
// here also means the gateway screens the operator's actual disclosure rather than a daemon
|
|
1148
|
+
// token bolted to the front of it. markAsAutoReply is idempotent and the system defaults are
|
|
1149
|
+
// marked at their source, so nothing gains a second token.
|
|
1150
|
+
const screenedBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
|
|
1127
1151
|
? new Uint8Array(awayVerdict.content)
|
|
1128
1152
|
: draftBytes;
|
|
1153
|
+
const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
|
|
1129
1154
|
const contentHash = wireContentHash(contentBytes);
|
|
1130
1155
|
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
|
|
1131
1156
|
if (!sendResult.ok && !sendResult.durable) {
|
|
@@ -1151,17 +1176,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1151
1176
|
//
|
|
1152
1177
|
// The dedup guard deliberately STAYS SET: a queued reply is coming, and re-sending on the
|
|
1153
1178
|
// next arrival would mint a second greeting at a second sequence.
|
|
1154
|
-
|
|
1155
|
-
|
|
1179
|
+
// DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
|
|
1180
|
+
// that is where its leaf goes.
|
|
1181
|
+
const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
|
|
1182
|
+
if (placedQueued.placed) {
|
|
1183
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
|
|
1184
|
+
}
|
|
1156
1185
|
logger.info("session.away.response.deferred", {
|
|
1157
|
-
agentName, sessionId, kind, isKnown,
|
|
1186
|
+
agentName, sessionId, kind, isKnown,
|
|
1187
|
+
sequenceNumber: placedQueued.placed ? placedQueued.leafIndex : placedQueued.heldAt,
|
|
1188
|
+
committed: placedQueued.placed,
|
|
1158
1189
|
reason: sendResult.reason, cause: sendResult.cause,
|
|
1159
1190
|
});
|
|
1160
1191
|
return;
|
|
1161
1192
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1193
|
+
// DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
|
|
1194
|
+
// path most likely to have a gap open under it — exactly where a tail append does damage.
|
|
1195
|
+
const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
|
|
1196
|
+
if (placedReply.placed) {
|
|
1197
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
|
|
1198
|
+
}
|
|
1199
|
+
logger.info("session.away.response.sent", {
|
|
1200
|
+
agentName, sessionId, kind, isKnown,
|
|
1201
|
+
sequenceNumber: placedReply.placed ? placedReply.leafIndex : placedReply.heldAt,
|
|
1202
|
+
committed: placedReply.placed,
|
|
1203
|
+
});
|
|
1165
1204
|
}
|
|
1166
1205
|
catch (err) {
|
|
1167
1206
|
// Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
|
|
@@ -1213,10 +1252,88 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1213
1252
|
});
|
|
1214
1253
|
return { who: resolved.who, whoKnown: resolved.whoKnown };
|
|
1215
1254
|
}
|
|
1255
|
+
// SYNC-P5: assigned when the document layer is wired (below); the state-change hook runs for
|
|
1256
|
+
// sessions, which exist only after startup completes — the guard covers the boot window where
|
|
1257
|
+
// agent restoration can dispatch state events before the scheduler exists.
|
|
1258
|
+
let reconcileScheduler;
|
|
1259
|
+
let documentOwnerKeyForHook;
|
|
1260
|
+
// DOD-M12B-DELIVERY-QUIET-1: which peers this daemon's document delivery worker is mid-dial to.
|
|
1261
|
+
// Read by BOTH reachability triggers and by the doorbell below. See delivery-open-registry.ts for
|
|
1262
|
+
// why this records an intent keyed on the peer rather than the session id — the short version is
|
|
1263
|
+
// that the directory mints the id and pushes the assignment to the counterparty before the opener
|
|
1264
|
+
// ever sees it, so on a one-daemon pair the inbound doorbell fires first and a session-id
|
|
1265
|
+
// registry is always too late.
|
|
1266
|
+
const deliveryOpens = createDeliveryOpenRegistry();
|
|
1267
|
+
/**
|
|
1268
|
+
* An agent's own pubkey, synchronously. The registry keys on PUBKEYS at both ends because that is
|
|
1269
|
+
* the only pair the dialling side and the accepting side can both name — the accepting side never
|
|
1270
|
+
* learns the dialler's local agent name, and the dialling side never learns ours.
|
|
1271
|
+
*/
|
|
1272
|
+
const pubkeyOfAgent = (name) => loadedAgents.find((a) => a.name === name)?.pubkey ?? "";
|
|
1273
|
+
/**
|
|
1274
|
+
* Is a delivery worker mid-dial from `openerPubkey` to `targetPubkey`?
|
|
1275
|
+
*
|
|
1276
|
+
* One predicate, passed to every consumer, so the doorbell, the phone and both reachability
|
|
1277
|
+
* triggers cannot drift apart on the answer. An unresolvable agent yields "" and never matches,
|
|
1278
|
+
* which fails OPEN — an unknown agent rings rather than being silently muted.
|
|
1279
|
+
*/
|
|
1280
|
+
const isDeliveryOpenInFlight = (openerPubkey, targetPubkey) => openerPubkey !== "" && targetPubkey !== "" && deliveryOpens.isDeliveryOpening(openerPubkey, targetPubkey);
|
|
1281
|
+
/**
|
|
1282
|
+
* The ACCEPTING side's question: "did a delivery worker on `openerPubkey`'s daemon dial the local
|
|
1283
|
+
* agent `targetAgentName`?"
|
|
1284
|
+
*
|
|
1285
|
+
* A named predicate rather than a reversed call, because getting the order wrong here is exactly
|
|
1286
|
+
* the defect this line was re-opened to fix: the accepting side naturally reaches for its own
|
|
1287
|
+
* agent first, which compares the receiver's tuple against a registry holding the dialler's and
|
|
1288
|
+
* silently never matches.
|
|
1289
|
+
*/
|
|
1290
|
+
const isDeliveryOpenToAgent = (openerPubkey, targetAgentName) => isDeliveryOpenInFlight(openerPubkey, pubkeyOfAgent(targetAgentName));
|
|
1216
1291
|
function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
|
|
1292
|
+
// DOD-M12B-DELIVERY-QUIET-1: this session came up because a DELIVERY WORKER dialled, so its
|
|
1293
|
+
// creation is machine traffic — not news about the peer, not a conversation, and not something
|
|
1294
|
+
// to buzz a phone about. Scoped to `created` on purpose: a session going DOWN is news whoever
|
|
1295
|
+
// opened it, and it is the operator's only signal that a conversation was cut off.
|
|
1296
|
+
//
|
|
1297
|
+
// ARGUMENTS ARE REVERSED HERE, AND THAT IS THE POINT. This function runs on the side that
|
|
1298
|
+
// ACCEPTED, so the opener is the COUNTERPARTY and the target is US. Asking
|
|
1299
|
+
// `(agentName, counterpartyPubkey)` — our own agent first, like the initiator half does — is
|
|
1300
|
+
// what made the first version of this guard unreachable in production: it compared the
|
|
1301
|
+
// receiver's tuple against a registry holding the dialler's.
|
|
1302
|
+
if (state === "created" && counterpartyPubkey !== null && isDeliveryOpenToAgent(counterpartyPubkey, agentName)) {
|
|
1303
|
+
// Suppression is LOGGED. A doorbell that silently stops ringing is the next defect, and the
|
|
1304
|
+
// measured storm was so hard to read precisely because nothing named its cause.
|
|
1305
|
+
logger.info("session.doorbell.suppressed_delivery", {
|
|
1306
|
+
agentName, sessionId, state, opener: counterpartyPubkey.slice(0, 16),
|
|
1307
|
+
impact: "document delivery opened this session — no doorbell, no phone push, no backoff reset",
|
|
1308
|
+
});
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1217
1311
|
// MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
|
|
1218
|
-
// offered-name drop below so a terminal state's own doorbell still shows the name.
|
|
1312
|
+
// offered-name drop below so a terminal state's own doorbell still shows the name. Below the
|
|
1313
|
+
// guard: on a suppressed event it was computed and thrown away.
|
|
1219
1314
|
const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
|
|
1315
|
+
// SYNC-P5 (R39): a session coming up IS the party-became-reachable signal — every shared
|
|
1316
|
+
// document gets a reconcile attempt, and the scheduler's backoff resets (they just answered).
|
|
1317
|
+
// Sound ONLY when the peer caused it, which is what the guard above establishes.
|
|
1318
|
+
if (reconcileScheduler && documentOwnerKeyForHook && counterpartyPubkey && state === "created") {
|
|
1319
|
+
const ownerAgentId = documentOwnerKeyForHook(agentName);
|
|
1320
|
+
if (ownerAgentId !== null) {
|
|
1321
|
+
// DOD-M12B-DELIVERY-QUIET-1: the trigger FIRING is logged, not only its failure. This is
|
|
1322
|
+
// the storm driver — it zeroes the backoff and sweeps every shared document — and it was
|
|
1323
|
+
// invisible in the log unless it threw, so 321 attempts in 85 minutes named no cause. It is
|
|
1324
|
+
// also the only way either direction of the delivery exemption is observable.
|
|
1325
|
+
logger.info("document.reconcile.reachable_trigger_fired", {
|
|
1326
|
+
agentName, trigger: "inbound_session_created", peer: counterpartyPubkey.slice(0, 16),
|
|
1327
|
+
});
|
|
1328
|
+
void reconcileScheduler
|
|
1329
|
+
.onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
|
|
1330
|
+
.catch((err) => {
|
|
1331
|
+
logger.warn("document.reconcile.reachable_trigger_failed", {
|
|
1332
|
+
agentName, reason: err instanceof Error ? err.message : String(err),
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1220
1337
|
notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey, who);
|
|
1221
1338
|
void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
|
|
1222
1339
|
// Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
|
|
@@ -1608,7 +1725,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1608
1725
|
// down below the module. Construction is synchronous and its deps are all ready — the handlers
|
|
1609
1726
|
// it registers are fine this early because the handler map already exists.
|
|
1610
1727
|
// Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
|
|
1611
|
-
const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
|
|
1728
|
+
const { registerHandlers: registerInboundSessionHandlers, wirePerAgentSessionInbound, handleTrustSignalPickup, enqueueInboundSession, recordRefusal, reapExpiredInboundSessions, inboundSessionQueues, inboundSessionWaiters, expiredSessionRequests, refusedSessionRequests, offeredMonikers, offerKey, } = createInboundSessions({
|
|
1612
1729
|
logger,
|
|
1613
1730
|
sessionNodeManager,
|
|
1614
1731
|
agents,
|
|
@@ -1625,6 +1742,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1625
1742
|
sendAwayResponse,
|
|
1626
1743
|
dispatchSessionStateChangedWithTelegram,
|
|
1627
1744
|
sendTelegramDoorbell,
|
|
1745
|
+
isDeliveryOpenToAgent,
|
|
1628
1746
|
});
|
|
1629
1747
|
if (!sharedSignaling) {
|
|
1630
1748
|
for (const agent of loadedAgents) {
|
|
@@ -1671,6 +1789,29 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1671
1789
|
// unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
|
|
1672
1790
|
// / cello_list_sessions (with filter + limit flags).
|
|
1673
1791
|
const STATUS_RESUMABLE_CAP = 10;
|
|
1792
|
+
/**
|
|
1793
|
+
* DOD-M12B-SEAL-STUCK-1 — ask the seal gate for ONE session, and never let the answer take the
|
|
1794
|
+
* status response with it.
|
|
1795
|
+
*
|
|
1796
|
+
* This runs per session on every `cello status` and every `cello_status`, and it touches the
|
|
1797
|
+
* database. An unguarded throw here rejects the whole response, after which the CLI finds the
|
|
1798
|
+
* singleton lock still held and prints `daemon: "broken_shutdown"` — telling the operator their
|
|
1799
|
+
* healthy daemon has failed to stop, over one session row it could not read. There is a
|
|
1800
|
+
* documented precedent in this codebase for exactly that shape.
|
|
1801
|
+
*
|
|
1802
|
+
* A failure yields `unknown`, never `ready`: "we could not check" must not read as "safe to
|
|
1803
|
+
* close", because a close on a short chain is terminal.
|
|
1804
|
+
*/
|
|
1805
|
+
function probeSealReadiness(agentName, sessionId) {
|
|
1806
|
+
try {
|
|
1807
|
+
return sessionNodeManager.sealReadinessView(agentName, sessionId);
|
|
1808
|
+
}
|
|
1809
|
+
catch (err) {
|
|
1810
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1811
|
+
logger.warn("session.seal.readiness.probe.failed", { agentName, sessionId, error: message });
|
|
1812
|
+
return { state: "unknown", reason: `probe_failed: ${message}` };
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1674
1815
|
function buildInterruptedSessions() {
|
|
1675
1816
|
return sessionNodeManager
|
|
1676
1817
|
.getSessionsByStatus("interrupted")
|
|
@@ -1685,6 +1826,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1685
1826
|
// DOD-SESSION-NAME-1 (AC-A11): an interrupted session is one you may want to resume or seal
|
|
1686
1827
|
// — the name is how you tell which one it was.
|
|
1687
1828
|
sessionName: row.session_name ?? null,
|
|
1829
|
+
// DOD-M12B-SEAL-STUCK-1: the same answer the active list carries, for the same reason —
|
|
1830
|
+
// an interrupted session can seal, so it can also be blocked from sealing by a gap, and
|
|
1831
|
+
// that is knowable here instead of only after a failed close. A plain property, never a
|
|
1832
|
+
// spread: a spread bypasses excess-property checking, which is how `frontierMismatch`
|
|
1833
|
+
// below came to typecheck while no renderer could read it.
|
|
1834
|
+
sealReadiness: probeSealReadiness(row.agent_name, row.session_id),
|
|
1688
1835
|
// DOD-FRONTIER-STRAND-1 AC3: if a seal exchange has already proved the two sides disagree on
|
|
1689
1836
|
// how many messages this session holds, SAY SO HERE. Otherwise a stranded session is listed
|
|
1690
1837
|
// exactly like a healthy paused one, and the only way to learn it can never seal is to
|
|
@@ -1716,7 +1863,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1716
1863
|
continue;
|
|
1717
1864
|
if (now - row.created_at <= HALF_OPEN_TTL_MS)
|
|
1718
1865
|
continue; // too young — may just be setting up
|
|
1719
|
-
|
|
1866
|
+
// DOD-M12B-ACK-1: 'impaired' counts as live HERE. This reaper's question is "did the
|
|
1867
|
+
// counterparty ever establish?", and impaired means it did — the connection is up, only
|
|
1868
|
+
// delivery on it is failing. Reaping on impaired would abandon exactly the sessions this
|
|
1869
|
+
// milestone exists to repair.
|
|
1870
|
+
const liveness = sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id);
|
|
1871
|
+
if (liveness === "alive" || liveness === "impaired")
|
|
1720
1872
|
continue; // live
|
|
1721
1873
|
if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
|
|
1722
1874
|
continue; // counterparty spoke
|
|
@@ -1741,13 +1893,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1741
1893
|
// was invisible to the operator.
|
|
1742
1894
|
function buildActiveSessions() {
|
|
1743
1895
|
reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
|
|
1744
|
-
return sessionNodeManager.getSessionsByStatus("active").map((row) =>
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1896
|
+
return sessionNodeManager.getSessionsByStatus("active").map((row) => {
|
|
1897
|
+
// DOD-M12B-SEAL-STUCK-1: ask the SAME gate the close path asks, so the surface and the
|
|
1898
|
+
// refusal can never disagree. Computed here rather than cached because the answer changes
|
|
1899
|
+
// the moment a gap fills, and a stale "stuck" is its own lie.
|
|
1900
|
+
return {
|
|
1901
|
+
sessionId: row.session_id,
|
|
1902
|
+
agentName: row.agent_name,
|
|
1903
|
+
counterpartyPubkey: row.counterparty_pubkey,
|
|
1904
|
+
liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
|
|
1905
|
+
sessionName: row.session_name ?? null, // DOD-SESSION-NAME-1 (AC-A11)
|
|
1906
|
+
sealReadiness: probeSealReadiness(row.agent_name, row.session_id),
|
|
1907
|
+
};
|
|
1908
|
+
});
|
|
1751
1909
|
}
|
|
1752
1910
|
/**
|
|
1753
1911
|
* The state an operator sees, for one agent — used by BOTH status surfaces.
|
|
@@ -2927,6 +3085,38 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2927
3085
|
handlers,
|
|
2928
3086
|
logger,
|
|
2929
3087
|
sessionNodeManager,
|
|
3088
|
+
// SYNC-P5 (R39, review F5): the INITIATOR-side reachable trigger — the inbound side fires
|
|
3089
|
+
// from the session-state dispatch; without this half, only the answering daemon reconciled
|
|
3090
|
+
// on session-up and the initiator waited out a sweep interval.
|
|
3091
|
+
onSessionOpened: (agentName, counterpartyPubkey) => {
|
|
3092
|
+
if (!reconcileScheduler || !documentOwnerKeyForHook)
|
|
3093
|
+
return;
|
|
3094
|
+
// DOD-M12B-DELIVERY-QUIET-1: the INITIATOR half of the same circularity. This hook fires
|
|
3095
|
+
// inside openSessionAs, which the delivery worker's adapter brackets with begin/release — so
|
|
3096
|
+
// the intent is still in flight here and the check needs no plumbing of its own. Resetting
|
|
3097
|
+
// the backoff on a session WE opened to deliver a frame wipes a refusal the peer may have
|
|
3098
|
+
// given seconds ago and immediately sweeps every document, which opens more sessions.
|
|
3099
|
+
// We are the OPENER here, so our own pubkey goes first — the mirror of the inbound half above.
|
|
3100
|
+
if (isDeliveryOpenInFlight(pubkeyOfAgent(agentName), counterpartyPubkey)) {
|
|
3101
|
+
logger.debug("document.reconcile.reachable_trigger_skipped", {
|
|
3102
|
+
agentName, reason: "session_opened_by_document_delivery",
|
|
3103
|
+
});
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
3106
|
+
const ownerAgentId = documentOwnerKeyForHook(agentName);
|
|
3107
|
+
if (ownerAgentId === null)
|
|
3108
|
+
return;
|
|
3109
|
+
logger.info("document.reconcile.reachable_trigger_fired", {
|
|
3110
|
+
agentName, trigger: "outbound_session_opened", peer: counterpartyPubkey.slice(0, 16),
|
|
3111
|
+
});
|
|
3112
|
+
void reconcileScheduler
|
|
3113
|
+
.onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
|
|
3114
|
+
.catch((err) => {
|
|
3115
|
+
logger.warn("document.reconcile.reachable_trigger_failed", {
|
|
3116
|
+
agentName, reason: err instanceof Error ? err.message : String(err),
|
|
3117
|
+
});
|
|
3118
|
+
});
|
|
3119
|
+
},
|
|
2930
3120
|
getConnState: (connectionId) => perConnectionState.get(connectionId),
|
|
2931
3121
|
resolveCurrentAgent,
|
|
2932
3122
|
NO_CURRENT_AGENT_RESPONSE,
|
|
@@ -3145,6 +3335,45 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3145
3335
|
}
|
|
3146
3336
|
return { ok: true };
|
|
3147
3337
|
});
|
|
3338
|
+
// DOD-M12B-DELIVERY-QUIET-1: drive the delivery-open intent directly, so the doorbell exemption
|
|
3339
|
+
// is testable without standing up a document, a peer daemon and a real directory negotiation.
|
|
3340
|
+
// The registry's begin() hands back a closure, so the releases are held here by key.
|
|
3341
|
+
// The hook takes AGENT NAMES and resolves both to pubkeys through `pubkeyOfAgent`, exactly as the
|
|
3342
|
+
// real delivery adapter does. That is deliberate: the first version of this hook let a test
|
|
3343
|
+
// register a tuple production never produces — an agent registered as the dialler and then
|
|
3344
|
+
// emitted as the receiver of its own dial — so the suite proved the guard's boolean logic and
|
|
3345
|
+
// nothing about the wiring, and stayed green against a guard that could never fire.
|
|
3346
|
+
const testDeliveryReleases = new Map();
|
|
3347
|
+
// DOD-M12B-INBOX-TRUTH-1: seed a refusal, so the ended-unread return branch's lost
|
|
3348
|
+
// `refused_session_requests` has a regression test. The field had none before or after the fix.
|
|
3349
|
+
handlers.set("__test_record_refusal", async (params, _connectionId) => {
|
|
3350
|
+
const agentName = params?.agentName;
|
|
3351
|
+
const sessionId = params?.sessionId;
|
|
3352
|
+
if (!agentName || !sessionId)
|
|
3353
|
+
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3354
|
+
recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", params?.reason ?? "test");
|
|
3355
|
+
return { ok: true };
|
|
3356
|
+
});
|
|
3357
|
+
handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
|
|
3358
|
+
const openerAgent = params?.openerAgent;
|
|
3359
|
+
const targetAgent = params?.targetAgent;
|
|
3360
|
+
if (!openerAgent || !targetAgent)
|
|
3361
|
+
return { error: "missing_params", guidance: "Provide openerAgent and targetAgent (both agent NAMES)." };
|
|
3362
|
+
const openerPubkey = pubkeyOfAgent(openerAgent);
|
|
3363
|
+
const targetPubkey = pubkeyOfAgent(targetAgent);
|
|
3364
|
+
testDeliveryReleases.set(`${openerAgent}>${targetAgent}`, deliveryOpens.begin(openerPubkey, targetPubkey));
|
|
3365
|
+
return { ok: true, openerPubkey, targetPubkey };
|
|
3366
|
+
});
|
|
3367
|
+
handlers.set("__test_delivery_open_end", async (params, _connectionId) => {
|
|
3368
|
+
const openerAgent = params?.openerAgent;
|
|
3369
|
+
const targetAgent = params?.targetAgent;
|
|
3370
|
+
if (!openerAgent || !targetAgent)
|
|
3371
|
+
return { error: "missing_params", guidance: "Provide openerAgent and targetAgent (both agent NAMES)." };
|
|
3372
|
+
const key = `${openerAgent}>${targetAgent}`;
|
|
3373
|
+
testDeliveryReleases.get(key)?.();
|
|
3374
|
+
testDeliveryReleases.delete(key);
|
|
3375
|
+
return { ok: true };
|
|
3376
|
+
});
|
|
3148
3377
|
// M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
|
|
3149
3378
|
// flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
|
|
3150
3379
|
// without standing up the full libp2p inbound path.
|
|
@@ -3310,14 +3539,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3310
3539
|
};
|
|
3311
3540
|
// Create and start IPC server
|
|
3312
3541
|
const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
catch (err) {
|
|
3317
|
-
// As above: startDaemon's catch releases the singleton lock for us.
|
|
3318
|
-
await removeLockIfOwned(lockFilePath, process.pid, logger);
|
|
3319
|
-
throw err;
|
|
3320
|
-
}
|
|
3542
|
+
// THE SOCKET OPENS LAST — see the deferred start below. Accepting clients here would accept
|
|
3543
|
+
// `cello_start_agent`, which brings an agent online, creates its standing receiver, and drains
|
|
3544
|
+
// parked content — all of it arriving before the document frame hook exists.
|
|
3321
3545
|
// MCP-002: Instantiate NotificationDispatcher (wired to IPC server)
|
|
3322
3546
|
const notificationDispatcher = new NotificationDispatcher({
|
|
3323
3547
|
logger,
|
|
@@ -3387,6 +3611,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3387
3611
|
});
|
|
3388
3612
|
return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
|
|
3389
3613
|
},
|
|
3614
|
+
// Read LAZILY: the scheduler is constructed after this layer (it consumes the layer's sweep
|
|
3615
|
+
// targets), so the binding must be resolved at call time rather than captured here.
|
|
3616
|
+
onPeerRefusal: (ownerAgentId, peerAgentId, terminal) => reconcileScheduler?.noteRefusal(ownerAgentId, peerAgentId, terminal),
|
|
3390
3617
|
// ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
|
|
3391
3618
|
// how the surface tests passed while the feature did nothing: the test wired this seam to
|
|
3392
3619
|
// `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
|
|
@@ -3399,38 +3626,6 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3399
3626
|
return;
|
|
3400
3627
|
notificationDispatcher.dispatchDocumentWatch(agentName, documentId, paths);
|
|
3401
3628
|
},
|
|
3402
|
-
notifyPeer: createDocumentControlNotifier({
|
|
3403
|
-
get store() {
|
|
3404
|
-
// Lazily, because `documentLayer` is what is being constructed. The notifier is only ever
|
|
3405
|
-
// called from lifecycle, long after construction returns.
|
|
3406
|
-
return documentLayer.store;
|
|
3407
|
-
},
|
|
3408
|
-
owners: () => loadedAgents
|
|
3409
|
-
.filter((a) => a.pubkey)
|
|
3410
|
-
.map((a) => ({ agentName: a.name, ownerAgentId: a.pubkey.toLowerCase() })),
|
|
3411
|
-
// DOD-MP-CONTROL-N-1 — the ONE implementation, on the layer, shared with every test fixture.
|
|
3412
|
-
// A closure here instead would be untested by construction and hand-copied into fixtures
|
|
3413
|
-
// that cannot disagree with it, which is exactly how the original defect survived.
|
|
3414
|
-
holders: (ownerAgentId, documentId) => documentLayer.controlHolders(ownerAgentId, documentId),
|
|
3415
|
-
sign: async (agentName, tbs) => {
|
|
3416
|
-
const provider = keyProviders.get(agentName);
|
|
3417
|
-
if (!provider)
|
|
3418
|
-
throw new Error(`document_control_unsigned: no key provider for ${agentName}`);
|
|
3419
|
-
return provider.sign(tbs);
|
|
3420
|
-
},
|
|
3421
|
-
send: (agentName, input) => documentTransportFor(agentName).sendBytes(input),
|
|
3422
|
-
// Given a logger so a signing failure is REPORTED rather than swallowed — the reason a
|
|
3423
|
-
// local key fault used to reach the operator dressed as an absent peer.
|
|
3424
|
-
logger,
|
|
3425
|
-
now: () => Date.now(),
|
|
3426
|
-
}),
|
|
3427
|
-
rollback: () => ({
|
|
3428
|
-
// Likewise: withdraw REFUSES rather than claiming a rollback that did not happen. Reporting
|
|
3429
|
-
// success having reverted nothing tells an operator their update was retracted while their
|
|
3430
|
-
// file still contains it.
|
|
3431
|
-
ok: false,
|
|
3432
|
-
reason: "document_rollback_not_wired",
|
|
3433
|
-
}),
|
|
3434
3629
|
sign: async (ownerAgentId, tbs) => {
|
|
3435
3630
|
// Signs as the OWNING agent, over the rejection's canonical preimage (DOD-DOC-REJECT-2).
|
|
3436
3631
|
//
|
|
@@ -3467,14 +3662,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3467
3662
|
// agent, sessions belong to one agent, and `openSessionFor` signs as one agent. A single worker
|
|
3468
3663
|
// with an agent-shaped hole in it is how a delivery ends up sent under the wrong identity.
|
|
3469
3664
|
//
|
|
3470
|
-
//
|
|
3471
|
-
//
|
|
3472
|
-
// The sweep's serial loop happens to cover it today; a cached worker means it is covered because
|
|
3473
|
-
// the guard exists, not by accident of loop shape.
|
|
3474
|
-
const documentDeliveryWorkers = new Map();
|
|
3475
|
-
// Cached separately from the worker because the OPERATOR SURFACE needs it too: a proposal is not
|
|
3476
|
-
// in the envelope log — there is no document yet — so it has no delivery record to schedule, and
|
|
3477
|
-
// it still has to travel the same open-or-reuse-then-seal path an update does.
|
|
3665
|
+
// The frame carrier, cached per agent (SYNC-P4: the delivery worker and its sweep are deleted —
|
|
3666
|
+
// scheduling returns as the P5 reconcile triggers).
|
|
3478
3667
|
const documentTransports = new Map();
|
|
3479
3668
|
const documentTransportFor = (agentName) => {
|
|
3480
3669
|
const cached = documentTransports.get(agentName);
|
|
@@ -3504,13 +3693,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3504
3693
|
.map((row) => row.session_id)
|
|
3505
3694
|
.reverse(),
|
|
3506
3695
|
openSession: async (agent, peerAgentId, correlationId) => {
|
|
3507
|
-
//
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3696
|
+
// DOD-M12B-DELIVERY-QUIET-1: THIS is the only place the delivery worker opens a session,
|
|
3697
|
+
// so bracketing it is what makes "who opened this" knowable everywhere else. The window
|
|
3698
|
+
// must cover the whole open — the inbound doorbell on a co-resident pair fires DURING
|
|
3699
|
+
// negotiation, and the initiator-side reachability hook fires just before this returns.
|
|
3700
|
+
// Released in `finally`: a throw that left the intent registered would silence this
|
|
3701
|
+
// peer's doorbell for the life of the process, which is a worse outage than the storm.
|
|
3702
|
+
const releaseDeliveryOpen = deliveryOpens.begin(pubkeyOfAgent(agent), peerAgentId);
|
|
3703
|
+
try {
|
|
3704
|
+
// The same path `cello_initiate_session` takes, now callable without an IPC connection.
|
|
3705
|
+
const res = (await openSessionFor(agent, { targetPubkey: peerAgentId }));
|
|
3706
|
+
if (res.ok !== true || typeof res.sessionId !== "string") {
|
|
3707
|
+
return { ok: false, reason: res.reason ?? "session_open_failed", guidance: res.guidance };
|
|
3708
|
+
}
|
|
3709
|
+
logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
|
|
3710
|
+
return { ok: true, sessionId: res.sessionId };
|
|
3711
|
+
}
|
|
3712
|
+
finally {
|
|
3713
|
+
releaseDeliveryOpen();
|
|
3511
3714
|
}
|
|
3512
|
-
logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
|
|
3513
|
-
return { ok: true, sessionId: res.sessionId };
|
|
3514
3715
|
},
|
|
3515
3716
|
sealSession: async (agent, sessionId, correlationId) => {
|
|
3516
3717
|
// §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
|
|
@@ -3541,97 +3742,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3541
3742
|
// The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
|
|
3542
3743
|
// successful send. See the comment at the call site for why this is delivery-critical and
|
|
3543
3744
|
// not audit bookkeeping.
|
|
3544
|
-
appendLeaf: (agent, sessionId, contentHash, correlationId) => {
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
epoch_id: envelope.epochId,
|
|
3552
|
-
doc_prev_hash: envelope.docPrevHash,
|
|
3553
|
-
sender_agent_id: envelope.senderAgentId,
|
|
3554
|
-
sender_client_id: envelope.senderClientId ?? 0,
|
|
3555
|
-
update_encoding: DOCUMENT_UPDATE_ENCODING_V1,
|
|
3556
|
-
state_vector: envelope.stateVector,
|
|
3557
|
-
update: envelope.payload ?? new Uint8Array(0),
|
|
3558
|
-
signature: envelope.signature,
|
|
3559
|
-
});
|
|
3560
|
-
// Same defect as `sendBytes` had, on the UPDATE path: the receiver recomputes with the
|
|
3561
|
-
// `0x00` domain prefix, so a bare hash is discarded at the authenticity check.
|
|
3562
|
-
return { bytes, hash: wireContentHash(bytes) };
|
|
3563
|
-
},
|
|
3564
|
-
// The owner KEY, not the agent name — every document row is scoped by our own pubkey hex
|
|
3565
|
-
// (M14-D5), and the store query behind this reads that column. Passing the name here
|
|
3566
|
-
// returns no rows and the wait times out on every delivery.
|
|
3567
|
-
awaitAck: async (envelopeHash, expectedAckerAgentId, timeoutMs) => {
|
|
3568
|
-
const ownerKey = documentOwnerKeyFor(agentName);
|
|
3569
|
-
if (ownerKey === null) {
|
|
3570
|
-
// NOT "the peer stayed silent". `false` means the grace expired with no answer, and the
|
|
3571
|
-
// caller logs exactly that — `graceMs: 10000`, a claim that ten seconds elapsed when
|
|
3572
|
-
// none did, about a peer that was never asked. A delivery worker running for an agent
|
|
3573
|
-
// whose own pubkey cannot be resolved is a wiring bug, and it says so here rather than
|
|
3574
|
-
// arriving downstream dressed as a fact about the counterparty. Same distinction
|
|
3575
|
-
// `abandoned_at` exists to keep: giving up is our decision, not evidence about them.
|
|
3576
|
-
logger.error("document.delivery.ack_wait_unwired", { agentName, envelopeHash });
|
|
3577
|
-
return null;
|
|
3578
|
-
}
|
|
3579
|
-
return documentLayer.awaitAck(ownerKey, envelopeHash, expectedAckerAgentId, timeoutMs);
|
|
3580
|
-
},
|
|
3581
|
-
drainHeld: async (sessionId, correlationId) => {
|
|
3582
|
-
// ONLY WHEN THERE IS A GAP. `sealReadiness` already answers exactly this question, and
|
|
3583
|
-
// gating on it keeps the ordinary delivery — the overwhelming majority — free of a relay
|
|
3584
|
-
// round trip it does not need.
|
|
3585
|
-
const readiness = sessionNodeManager.sealReadiness(agentName, sessionId);
|
|
3586
|
-
if (readiness.heldCount === 0 && readiness.missingLeaves === 0)
|
|
3587
|
-
return;
|
|
3588
|
-
logger.info("document.delivery.drain_held", {
|
|
3589
|
-
agentName,
|
|
3590
|
-
sessionId,
|
|
3591
|
-
heldCount: readiness.heldCount,
|
|
3592
|
-
missingLeaves: readiness.missingLeaves,
|
|
3593
|
-
correlationId,
|
|
3594
|
-
});
|
|
3595
|
-
await autoRecoverForAgent(agentName, "delivery_ack_grace").catch((err) => {
|
|
3596
|
-
// CONTAINED. A recovery that fails leaves the ack held and the grace expires — the same
|
|
3597
|
-
// outcome as before, and never a reason to fail a delivery whose content already left.
|
|
3598
|
-
logger.warn("document.delivery.drain_held_failed", {
|
|
3599
|
-
agentName,
|
|
3600
|
-
sessionId,
|
|
3601
|
-
correlationId,
|
|
3602
|
-
error: err instanceof Error ? err.message : String(err),
|
|
3603
|
-
});
|
|
3604
|
-
});
|
|
3745
|
+
appendLeaf: (agent, sessionId, contentHash, frameBytes, correlationId, assignedSeq) => {
|
|
3746
|
+
// DOD-M12B-INDEX-1: a document leaf takes a position in the CONVERSATION's sequence space
|
|
3747
|
+
// (deliberate — f75ea09), so it obeys the same discipline a message does — including
|
|
3748
|
+
// being HELD when the position is ahead of the tail, which is why the real frame bytes
|
|
3749
|
+
// have to travel with it.
|
|
3750
|
+
const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc");
|
|
3751
|
+
return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
|
|
3605
3752
|
},
|
|
3606
3753
|
});
|
|
3607
3754
|
documentTransports.set(agentName, transport);
|
|
3608
3755
|
return transport;
|
|
3609
3756
|
};
|
|
3610
|
-
const documentDeliveryFor = (agentName) => {
|
|
3611
|
-
const existing = documentDeliveryWorkers.get(agentName);
|
|
3612
|
-
if (existing)
|
|
3613
|
-
return existing;
|
|
3614
|
-
const worker = new DocumentDelivery(documentLayer.store, documentTransportFor(agentName), logger,
|
|
3615
|
-
// DOD-MP-CONTROL-DURABLE-1 — control frames derive their recipients the way the NOTIFIER
|
|
3616
|
-
// does, not the way envelopes do. `holdersFor` returns null for a document with no stored
|
|
3617
|
-
// genesis proposal; `controlHolders` has an explicit legacy branch for it. Deriving the two
|
|
3618
|
-
// halves differently turned a seeded row into a permanent no-op.
|
|
3619
|
-
(documentId) => {
|
|
3620
|
-
const ownerAgentId = documentOwnerKeyFor(agentName);
|
|
3621
|
-
if (ownerAgentId === null) {
|
|
3622
|
-
return { ok: false, reason: "document_owner_key_unavailable" };
|
|
3623
|
-
}
|
|
3624
|
-
return documentLayer.controlHolders(ownerAgentId, documentId);
|
|
3625
|
-
});
|
|
3626
|
-
documentDeliveryWorkers.set(agentName, worker);
|
|
3627
|
-
return worker;
|
|
3628
|
-
};
|
|
3629
3757
|
// M14 / DOD-DOC-TOOLS-1 — the OPERATOR SURFACE. Registered last of the document wiring, because
|
|
3630
3758
|
// it is the only part that can create a document, and everything it creates has to have somewhere
|
|
3631
3759
|
// to go: without the inbound path a peer's answer is unroutable, and without the delivery worker a
|
|
3632
3760
|
// published update never leaves.
|
|
3633
3761
|
const documentPublish = new DocumentPublish({
|
|
3634
3762
|
holdersFor: (ownerAgentId, documentId) => documentLayer.holdersFor(ownerAgentId, documentId),
|
|
3763
|
+
governanceFrontierFor: (ownerAgentId, documentId) => documentLayer.governanceFrontierFor(ownerAgentId, documentId),
|
|
3635
3764
|
store: documentLayer.store,
|
|
3636
3765
|
engine: documentLayer.engine,
|
|
3637
3766
|
logger,
|
|
@@ -3651,6 +3780,30 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3651
3780
|
// different facts that happen to coincide — see DocumentStore.pendingDeliveries.
|
|
3652
3781
|
senderIdFor: (ownerAgentId) => ownerAgentId,
|
|
3653
3782
|
canPublish: (ownerAgentId, documentId) => documentLayer.lifecycle.canPublish(ownerAgentId, documentId),
|
|
3783
|
+
// SYNC-P4 (R39's first trigger): each seat gets an initiated reconcile exchange — the
|
|
3784
|
+
// envelope rides the exchange's own difference computation. Fire-and-forget by contract.
|
|
3785
|
+
nudgeSeats: (ownerAgentId, documentId, seats) => {
|
|
3786
|
+
for (const seat of seats) {
|
|
3787
|
+
void documentLayer
|
|
3788
|
+
.initiateReconcile(ownerAgentId, seat, [documentId])
|
|
3789
|
+
.then((sent) => {
|
|
3790
|
+
if (!sent.ok) {
|
|
3791
|
+
// NOT SILENT (review F3): a lost nudge is legal for correctness (any later
|
|
3792
|
+
// exchange recomputes the difference — R40), but until P5's sweeps exist this
|
|
3793
|
+
// line is the only trace an operator has for "my edit never arrived".
|
|
3794
|
+
logger.warn("document.reconcile.nudge_failed", {
|
|
3795
|
+
documentId, peerAgentId: seat, reason: sent.reason,
|
|
3796
|
+
});
|
|
3797
|
+
}
|
|
3798
|
+
})
|
|
3799
|
+
.catch((err) => {
|
|
3800
|
+
logger.warn("document.reconcile.nudge_failed", {
|
|
3801
|
+
documentId, peerAgentId: seat,
|
|
3802
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3803
|
+
});
|
|
3804
|
+
});
|
|
3805
|
+
}
|
|
3806
|
+
},
|
|
3654
3807
|
});
|
|
3655
3808
|
registerDocumentHandlers({
|
|
3656
3809
|
handlers,
|
|
@@ -3668,128 +3821,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3668
3821
|
},
|
|
3669
3822
|
now: () => Date.now(),
|
|
3670
3823
|
});
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
//
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
//
|
|
3694
|
-
//
|
|
3695
|
-
//
|
|
3696
|
-
//
|
|
3697
|
-
// bounded
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
documentDeliveryRunning = true;
|
|
3712
|
-
documentDeliveryInFlight = (async () => {
|
|
3824
|
+
// SYNC-P5 (R39–R43) — WHEN reconciling is attempted: the periodic sweep and the party-became-
|
|
3825
|
+
// reachable trigger. All state volatile (R41); a lost tick costs latency, never correctness.
|
|
3826
|
+
reconcileScheduler = new ReconcileScheduler({
|
|
3827
|
+
now: () => Date.now(),
|
|
3828
|
+
logger,
|
|
3829
|
+
sweepTargets: (ownerAgentId) => documentLayer.sweepTargets(ownerAgentId),
|
|
3830
|
+
partySync: (ownerAgentId, documentId, partyAgentId) => documentLayer.partySync(ownerAgentId, documentId, partyAgentId),
|
|
3831
|
+
initiateReconcile: (ownerAgentId, peerAgentId, documentIds) => documentLayer.initiateReconcile(ownerAgentId, peerAgentId, documentIds),
|
|
3832
|
+
...(Number.isFinite(Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"])) &&
|
|
3833
|
+
Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"]) >= 250
|
|
3834
|
+
? { backoffBaseMs: Number(process.env["CELLO_DOCUMENT_RECONCILE_BACKOFF_MS"]) }
|
|
3835
|
+
: {}),
|
|
3836
|
+
...(Number.isFinite(Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"])) &&
|
|
3837
|
+
Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"]) >= 250
|
|
3838
|
+
? { believedCurrentMs: Number(process.env["CELLO_DOCUMENT_RECONCILE_CURRENT_MS"]) }
|
|
3839
|
+
: {}),
|
|
3840
|
+
});
|
|
3841
|
+
// Overridable for tests (the delivery tick precedent); floored so a misread env cannot busy-loop.
|
|
3842
|
+
const reconcileTickOverride = Number(process.env["CELLO_DOCUMENT_RECONCILE_SWEEP_MS"]);
|
|
3843
|
+
const RECONCILE_SWEEP_MS = Number.isFinite(reconcileTickOverride) && reconcileTickOverride >= 250
|
|
3844
|
+
? reconcileTickOverride
|
|
3845
|
+
: 120_000;
|
|
3846
|
+
// Review F2: the guard is TIME-STAMPED, not a bare boolean — a pass that never settles (a
|
|
3847
|
+
// dial with no timeout) would otherwise end the sweep silently for the daemon's lifetime,
|
|
3848
|
+
// the exact R42 stall one level above the module that fixed it. Past the bound the wedge is
|
|
3849
|
+
// WARNED and the guard force-released; the abandoned pass's own scheduler marks are
|
|
3850
|
+
// themselves time-bounded, so double-attempts are latency, never correctness.
|
|
3851
|
+
const RECONCILE_PASS_BOUND_MS = 5 * 60_000;
|
|
3852
|
+
let reconcileSweepStartedAt = null;
|
|
3853
|
+
const reconcileSweepTimer = setInterval(() => {
|
|
3854
|
+
if (reconcileSweepStartedAt !== null) {
|
|
3855
|
+
if (Date.now() - reconcileSweepStartedAt < RECONCILE_PASS_BOUND_MS)
|
|
3856
|
+
return;
|
|
3857
|
+
logger.warn("document.reconcile.sweep_wedged", {
|
|
3858
|
+
heldMs: Date.now() - reconcileSweepStartedAt,
|
|
3859
|
+
impact: "a sweep pass never settled; the guard is force-released so sweeping continues",
|
|
3860
|
+
});
|
|
3861
|
+
}
|
|
3862
|
+
reconcileSweepStartedAt = Date.now();
|
|
3863
|
+
void (async () => {
|
|
3713
3864
|
try {
|
|
3714
|
-
let agentsSwept = 0;
|
|
3715
|
-
let stuck = 0;
|
|
3716
|
-
let attempted = 0;
|
|
3717
|
-
let delivered = 0;
|
|
3718
|
-
let failed = 0;
|
|
3719
3865
|
for (const agentName of perAgentSignaling.keys()) {
|
|
3720
|
-
// M2: a tick already running when the daemon stops must not keep dialling into a
|
|
3721
|
-
// half-torn-down transport. `clearInterval` stops the NEXT tick, not this one.
|
|
3722
|
-
if (documentDeliveryStopping)
|
|
3723
|
-
break;
|
|
3724
3866
|
const ownerAgentId = documentOwnerKeyFor(agentName);
|
|
3725
|
-
// M14-D5: the owner key is our own pubkey hex. It is what BOTH halves scope by — the
|
|
3726
|
-
// inbound router resolves the same function — and it is what the wire sender id is.
|
|
3727
|
-
// Scoping the two halves differently returns nothing pending, reports nothing attempted,
|
|
3728
|
-
// and leaves a fully synced document invisible with no error on any path.
|
|
3729
3867
|
if (ownerAgentId === null)
|
|
3730
3868
|
continue;
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
// config, never the genesis peer column (§7-1's silent-divergence hazard).
|
|
3735
|
-
const holdersFor = (documentId) => {
|
|
3736
|
-
const holders = documentLayer.holdersFor(ownerAgentId, documentId);
|
|
3737
|
-
return holders === null ? null : holders.filter((h) => h !== ownerAgentId);
|
|
3738
|
-
};
|
|
3739
|
-
// DOD-MP-SWEEP-ALIVE-1 — BOUNDED. The `finally` below releases the overlap guard only
|
|
3740
|
-
// when this body settles, so an await that never settles ends document delivery for the
|
|
3741
|
-
// life of the process, silently: the interval keeps firing, returns at the guard, and
|
|
3742
|
-
// logs nothing. Observed live 2026-08-13. One wedged agent now degrades alone and loudly
|
|
3743
|
-
// instead of taking every other agent's documents down with it.
|
|
3744
|
-
const bounded = await sweepAgentEvicting({
|
|
3745
|
-
logger,
|
|
3746
|
-
timeoutMs: DOCUMENT_DELIVERY_PASS_TIMEOUT_MS,
|
|
3747
|
-
agentName,
|
|
3748
|
-
// Evicting the wedged worker is what makes the recovery real: `DocumentDelivery.tick`
|
|
3749
|
-
// caches its pass and would otherwise hand back the same hung promise forever, so the
|
|
3750
|
-
// agent would never deliver again AND every later pass would pay the full bound
|
|
3751
|
-
// waiting for it — a 2x sweep-interval regression for every healthy agent.
|
|
3752
|
-
workers: documentDeliveryWorkers,
|
|
3753
|
-
run: () => documentDeliveryFor(agentName).tick(ownerAgentId, holdersFor, Date.now(), {
|
|
3754
|
-
senderAgentId: ownerAgentId,
|
|
3755
|
-
}),
|
|
3756
|
-
});
|
|
3757
|
-
if (!bounded.completed) {
|
|
3758
|
-
stuck += 1;
|
|
3759
|
-
continue;
|
|
3869
|
+
const result = await reconcileScheduler.sweep(ownerAgentId);
|
|
3870
|
+
if (result.attempted > 0 || result.failed > 0) {
|
|
3871
|
+
logger.debug("document.reconcile.sweep", { agentName, ...result });
|
|
3760
3872
|
}
|
|
3761
|
-
agentsSwept++;
|
|
3762
|
-
const result = bounded.value;
|
|
3763
|
-
attempted += result.attempted;
|
|
3764
|
-
delivered += result.delivered;
|
|
3765
|
-
failed += result.failed;
|
|
3766
|
-
}
|
|
3767
|
-
// H3: a sweep that delivers nothing was byte-for-byte identical to a healthy idle one, which
|
|
3768
|
-
// is what made every scoping bug above invisible. One line per tick, always.
|
|
3769
|
-
// `stuck` IS PART OF THE LINE. Counting a wedged agent as swept reported `agents: 3,
|
|
3770
|
-
// attempted: 0` — byte-for-byte a healthy idle sweep, which is the exact shape the comment
|
|
3771
|
-
// below exists to prevent, on the one occasion it matters most.
|
|
3772
|
-
logger.debug("document.delivery.sweep", { agents: agentsSwept, stuck, attempted, delivered, failed });
|
|
3773
|
-
if (agentsSwept === 0 && documentLayer.store.anyDocumentExists()) {
|
|
3774
|
-
logger.warn("document.delivery.sweep_empty", {
|
|
3775
|
-
reason: "documents exist but no agent was swept — nothing will ever be delivered",
|
|
3776
|
-
});
|
|
3777
3873
|
}
|
|
3778
3874
|
}
|
|
3779
3875
|
catch (err) {
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3876
|
+
logger.warn("document.reconcile.sweep_threw", {
|
|
3877
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
3878
|
+
});
|
|
3783
3879
|
}
|
|
3784
3880
|
finally {
|
|
3785
|
-
|
|
3786
|
-
documentDeliveryInFlight = null;
|
|
3881
|
+
reconcileSweepStartedAt = null;
|
|
3787
3882
|
}
|
|
3788
3883
|
})();
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3884
|
+
}, RECONCILE_SWEEP_MS);
|
|
3885
|
+
reconcileSweepTimer.unref?.();
|
|
3886
|
+
documentOwnerKeyForHook = documentOwnerKeyFor;
|
|
3887
|
+
// ── THE SOCKET OPENS ONLY NOW, and the ordering is load-bearing (2026-08-16). ──
|
|
3888
|
+
//
|
|
3889
|
+
// This used to run ~120 lines earlier, immediately after the server was created — before
|
|
3890
|
+
// `setOnDocumentFrame` above. That opened a window on every daemon start: a client connects,
|
|
3891
|
+
// calls `cello_start_agent`, the agent comes online, its standing receiver drains whatever the
|
|
3892
|
+
// relay parked — and any DOCUMENT frame in that drain found `#onDocumentFrame` still unset. The
|
|
3893
|
+
// routing fork in session-node-manager treats an unconsumed frame as conversation, so those
|
|
3894
|
+
// frames were appended as `msg` leaves, written to the durable transcript, rang the doorbell,
|
|
3895
|
+
// and were handed to the agent by `cello_receive` as raw canonical CBOR.
|
|
3896
|
+
//
|
|
3897
|
+
// Reported from the live fleet by a counterparty who pasted the bytes back
|
|
3898
|
+
// (`dtyperdocument_reconcile…`), reproduced in `j-stale-session.spine.test.ts`, and it is why
|
|
3899
|
+
// documents accumulated unaccepted: the frames were not lost in transit, they were EATEN as
|
|
3900
|
+
// messages, so the document layer never saw them while the sender was told they were delivered.
|
|
3901
|
+
//
|
|
3902
|
+
// The comment at that fork claimed an unwired hook "cannot change the conversation path". It
|
|
3903
|
+
// can, and did. Nothing between here and the server's creation needs a live socket, so the
|
|
3904
|
+
// honest fix is to finish wiring before anyone can knock.
|
|
3905
|
+
try {
|
|
3906
|
+
await ipcServer.start();
|
|
3907
|
+
}
|
|
3908
|
+
catch (err) {
|
|
3909
|
+
// As above: startDaemon's catch releases the singleton lock for us.
|
|
3910
|
+
await removeLockIfOwned(lockFilePath, process.pid, logger);
|
|
3911
|
+
throw err;
|
|
3912
|
+
}
|
|
3793
3913
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
3794
3914
|
// MCP-002: Also unregister from notification dispatcher
|
|
3795
3915
|
ipcServer.onDisconnect((connectionId) => {
|
|
@@ -3847,40 +3967,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3847
3967
|
// per daemon no matter how many times stop() is called.
|
|
3848
3968
|
let stoppedHookFired = false;
|
|
3849
3969
|
async function stop(reason) {
|
|
3850
|
-
|
|
3851
|
-
//
|
|
3852
|
-
|
|
3853
|
-
//
|
|
3854
|
-
|
|
3855
|
-
//
|
|
3856
|
-
//
|
|
3857
|
-
//
|
|
3858
|
-
//
|
|
3859
|
-
//
|
|
3860
|
-
//
|
|
3861
|
-
|
|
3862
|
-
//
|
|
3863
|
-
//
|
|
3864
|
-
//
|
|
3865
|
-
//
|
|
3866
|
-
|
|
3867
|
-
//
|
|
3868
|
-
// 1. a bare await blocked shutdown on the network — a sweep mid-dial against an unreachable
|
|
3869
|
-
// peer held `stop()` open past the point where the interrupt-marking writes had to happen;
|
|
3870
|
-
// 2. bounding it with `setTimeout(...).unref()` looked like the fix and could HANG FOREVER: an
|
|
3871
|
-
// unref'd timer does not hold the event loop open, so while the loop is draining during
|
|
3872
|
-
// shutdown it may never fire, the race never settles, and `stop()` never reaches the line
|
|
3873
|
-
// that logs `daemon.stopped` — which is exactly what CI showed, a daemon whose log ends at
|
|
3874
|
-
// `daemon.started` with no shutdown events at all.
|
|
3875
|
-
//
|
|
3876
|
-
// There is nothing to wait for. `documentDeliveryStopping` stops the loop at its next agent, and
|
|
3877
|
-
// whatever a half-finished sweep leaves behind is safe by construction: delivery state is
|
|
3878
|
-
// DERIVED from the envelope log, so an interrupted pass is simply re-derived on the next start —
|
|
3879
|
-
// the property the offline enforcer proves against two real daemons.
|
|
3880
|
-
//
|
|
3881
|
-
// The rule this cost two CI round trips to learn: shutdown may not await anything that can
|
|
3882
|
-
// block on I/O, and a timeout that can outlive the event loop is not a bound.
|
|
3883
|
-
void documentDeliveryInFlight;
|
|
3970
|
+
clearInterval(reconcileSweepTimer);
|
|
3971
|
+
// DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
|
|
3972
|
+
// walks every agent, and each step dials a peer and opens a session — which is why a daemon
|
|
3973
|
+
// reported down, with its socket already removed, was still logging `document.reconcile.sweep`
|
|
3974
|
+
// 30 seconds later and had to be signalled to exit. This is what stops the pass in flight, and
|
|
3975
|
+
// it also blocks `onReachable`, so a session tearing down during shutdown cannot hand the
|
|
3976
|
+
// sweeper a fresh reason to dial on the way out.
|
|
3977
|
+
// `?.` is a TYPE requirement, not a runtime one: TypeScript discards the narrowing of a
|
|
3978
|
+
// captured `let` inside a hoisted function declaration, though it keeps it in the arrow a few
|
|
3979
|
+
// lines up. The scheduler is in fact always wired by the time this can run — the IPC socket,
|
|
3980
|
+
// the only route to `stop` besides the returned handle, opens after it.
|
|
3981
|
+
reconcileScheduler?.stop();
|
|
3982
|
+
// DOD-M12B-SHUTDOWN-1: the scheduler is one of FOUR callers of initiateReconcile. `nudgeSeats`
|
|
3983
|
+
// and the two invite notices reach it directly, and every document verb is still served while
|
|
3984
|
+
// the rest of this function runs — the IPC server is the last thing stopped. Refusing at the
|
|
3985
|
+
// choke point is what actually closes "no new outbound work".
|
|
3986
|
+
documentLayer?.stopReconciling();
|
|
3884
3987
|
// M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
|
|
3885
3988
|
// the generation so the running loop's while-condition fails on its next check.
|
|
3886
3989
|
stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check
|