@cello-protocol/daemon 0.0.169 → 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 +291 -38
- 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-delivery-transport.d.ts +10 -1
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +10 -1
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-layer.d.ts +21 -0
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js +34 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/document-reconcile-scheduler.d.ts +33 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -1
- package/dist/document-reconcile-scheduler.js +73 -0
- package/dist/document-reconcile-scheduler.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/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/daemon.js
CHANGED
|
@@ -91,7 +91,8 @@ import { startRegistryPoll } from "./registry-poll.js";
|
|
|
91
91
|
import { CONSENT_ACCEPTED } from "./consent-migration.js";
|
|
92
92
|
import { TrustSignalStore } from "./trust-signal-store.js";
|
|
93
93
|
import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
|
|
94
|
-
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";
|
|
95
96
|
import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
|
|
96
97
|
import { decodeCbor } from "@cello-protocol/protocol-types";
|
|
97
98
|
// Minimal no-op KeyProvider stub for session nodes.
|
|
@@ -930,6 +931,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
930
931
|
logger.info("session.away.mutual.skipped", {
|
|
931
932
|
agentName,
|
|
932
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",
|
|
933
940
|
impact: "no away reply and no one-shot seal — two away agents must not notarize a conversation nobody had",
|
|
934
941
|
});
|
|
935
942
|
return;
|
|
@@ -953,7 +960,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
953
960
|
// Reviewer F2: token at the END — the daemon's own output must honor the
|
|
954
961
|
// DOD-SIGNAL-TOKEN-1 append-at-end contract that DOD-WRAP-SUBSTRING-1 detection
|
|
955
962
|
// is anchored on (a counterparty daemon's end-anchored detector must see this close).
|
|
956
|
-
|
|
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]]");
|
|
957
966
|
const rejectBytes = new TextEncoder().encode(rejectText);
|
|
958
967
|
const rejectHash = wireContentHash(rejectBytes);
|
|
959
968
|
// Best-effort: a send failure still triggers the seal — we are closing regardless.
|
|
@@ -966,9 +975,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
966
975
|
// session is unsealable for good.
|
|
967
976
|
if (sendResult.ok || sendResult.durable) {
|
|
968
977
|
const rejectHashHex = Buffer.from(rejectHash).toString("hex");
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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 });
|
|
972
987
|
}
|
|
973
988
|
else {
|
|
974
989
|
// Not queued anywhere — the rejection is gone. We still seal (we are closing regardless),
|
|
@@ -1102,6 +1117,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1102
1117
|
try {
|
|
1103
1118
|
// DOD-AWAY-TIER-1: resolve most-specific-first — per-contact away_message → per-tier away
|
|
1104
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.
|
|
1105
1126
|
const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
|
|
1106
1127
|
?? (isKnown ? systemDefault : STRANGER_TEXT);
|
|
1107
1128
|
const draftBytes = new TextEncoder().encode(awayText);
|
|
@@ -1120,9 +1141,16 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1120
1141
|
logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
|
|
1121
1142
|
return;
|
|
1122
1143
|
}
|
|
1123
|
-
|
|
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
|
|
1124
1151
|
? new Uint8Array(awayVerdict.content)
|
|
1125
1152
|
: draftBytes;
|
|
1153
|
+
const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
|
|
1126
1154
|
const contentHash = wireContentHash(contentBytes);
|
|
1127
1155
|
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
|
|
1128
1156
|
if (!sendResult.ok && !sendResult.durable) {
|
|
@@ -1148,17 +1176,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1148
1176
|
//
|
|
1149
1177
|
// The dedup guard deliberately STAYS SET: a queued reply is coming, and re-sending on the
|
|
1150
1178
|
// next arrival would mint a second greeting at a second sequence.
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
+
}
|
|
1153
1185
|
logger.info("session.away.response.deferred", {
|
|
1154
|
-
agentName, sessionId, kind, isKnown,
|
|
1186
|
+
agentName, sessionId, kind, isKnown,
|
|
1187
|
+
sequenceNumber: placedQueued.placed ? placedQueued.leafIndex : placedQueued.heldAt,
|
|
1188
|
+
committed: placedQueued.placed,
|
|
1155
1189
|
reason: sendResult.reason, cause: sendResult.cause,
|
|
1156
1190
|
});
|
|
1157
1191
|
return;
|
|
1158
1192
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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
|
+
});
|
|
1162
1204
|
}
|
|
1163
1205
|
catch (err) {
|
|
1164
1206
|
// Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
|
|
@@ -1215,15 +1257,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1215
1257
|
// agent restoration can dispatch state events before the scheduler exists.
|
|
1216
1258
|
let reconcileScheduler;
|
|
1217
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));
|
|
1218
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
|
+
}
|
|
1219
1311
|
// MONIKER-4 AC2: stamp who/whoKnown on the counterparty-bearing frame. Resolved BEFORE the
|
|
1220
|
-
// 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.
|
|
1221
1314
|
const who = counterpartyPubkey ? resolveWho(agentName, counterpartyPubkey, sessionId) : undefined;
|
|
1222
1315
|
// SYNC-P5 (R39): a session coming up IS the party-became-reachable signal — every shared
|
|
1223
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.
|
|
1224
1318
|
if (reconcileScheduler && documentOwnerKeyForHook && counterpartyPubkey && state === "created") {
|
|
1225
1319
|
const ownerAgentId = documentOwnerKeyForHook(agentName);
|
|
1226
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
|
+
});
|
|
1227
1328
|
void reconcileScheduler
|
|
1228
1329
|
.onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
|
|
1229
1330
|
.catch((err) => {
|
|
@@ -1624,7 +1725,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1624
1725
|
// down below the module. Construction is synchronous and its deps are all ready — the handlers
|
|
1625
1726
|
// it registers are fine this early because the handler map already exists.
|
|
1626
1727
|
// Seam 2: inbound session establishment — the counterparty side (inbound-sessions.ts).
|
|
1627
|
-
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({
|
|
1628
1729
|
logger,
|
|
1629
1730
|
sessionNodeManager,
|
|
1630
1731
|
agents,
|
|
@@ -1641,6 +1742,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1641
1742
|
sendAwayResponse,
|
|
1642
1743
|
dispatchSessionStateChangedWithTelegram,
|
|
1643
1744
|
sendTelegramDoorbell,
|
|
1745
|
+
isDeliveryOpenToAgent,
|
|
1644
1746
|
});
|
|
1645
1747
|
if (!sharedSignaling) {
|
|
1646
1748
|
for (const agent of loadedAgents) {
|
|
@@ -1687,6 +1789,29 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1687
1789
|
// unbounded and confuse. The list is also capped; the full, queryable history is `cello sessions`
|
|
1688
1790
|
// / cello_list_sessions (with filter + limit flags).
|
|
1689
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
|
+
}
|
|
1690
1815
|
function buildInterruptedSessions() {
|
|
1691
1816
|
return sessionNodeManager
|
|
1692
1817
|
.getSessionsByStatus("interrupted")
|
|
@@ -1701,6 +1826,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1701
1826
|
// DOD-SESSION-NAME-1 (AC-A11): an interrupted session is one you may want to resume or seal
|
|
1702
1827
|
// — the name is how you tell which one it was.
|
|
1703
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),
|
|
1704
1835
|
// DOD-FRONTIER-STRAND-1 AC3: if a seal exchange has already proved the two sides disagree on
|
|
1705
1836
|
// how many messages this session holds, SAY SO HERE. Otherwise a stranded session is listed
|
|
1706
1837
|
// exactly like a healthy paused one, and the only way to learn it can never seal is to
|
|
@@ -1732,7 +1863,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1732
1863
|
continue;
|
|
1733
1864
|
if (now - row.created_at <= HALF_OPEN_TTL_MS)
|
|
1734
1865
|
continue; // too young — may just be setting up
|
|
1735
|
-
|
|
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")
|
|
1736
1872
|
continue; // live
|
|
1737
1873
|
if (sessionNodeManager.countReceivedMessages(row.agent_name, row.session_id) > 0)
|
|
1738
1874
|
continue; // counterparty spoke
|
|
@@ -1757,13 +1893,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1757
1893
|
// was invisible to the operator.
|
|
1758
1894
|
function buildActiveSessions() {
|
|
1759
1895
|
reapDeadHalfOpenSessions(); // CC-5/F21: drop provably-dead half-open sessions before surfacing active ones
|
|
1760
|
-
return sessionNodeManager.getSessionsByStatus("active").map((row) =>
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
+
});
|
|
1767
1909
|
}
|
|
1768
1910
|
/**
|
|
1769
1911
|
* The state an operator sees, for one agent — used by BOTH status surfaces.
|
|
@@ -2949,9 +3091,24 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2949
3091
|
onSessionOpened: (agentName, counterpartyPubkey) => {
|
|
2950
3092
|
if (!reconcileScheduler || !documentOwnerKeyForHook)
|
|
2951
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
|
+
}
|
|
2952
3106
|
const ownerAgentId = documentOwnerKeyForHook(agentName);
|
|
2953
3107
|
if (ownerAgentId === null)
|
|
2954
3108
|
return;
|
|
3109
|
+
logger.info("document.reconcile.reachable_trigger_fired", {
|
|
3110
|
+
agentName, trigger: "outbound_session_opened", peer: counterpartyPubkey.slice(0, 16),
|
|
3111
|
+
});
|
|
2955
3112
|
void reconcileScheduler
|
|
2956
3113
|
.onReachable(ownerAgentId, counterpartyPubkey.toLowerCase())
|
|
2957
3114
|
.catch((err) => {
|
|
@@ -3178,6 +3335,45 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3178
3335
|
}
|
|
3179
3336
|
return { ok: true };
|
|
3180
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
|
+
});
|
|
3181
3377
|
// M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
|
|
3182
3378
|
// flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
|
|
3183
3379
|
// without standing up the full libp2p inbound path.
|
|
@@ -3343,14 +3539,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3343
3539
|
};
|
|
3344
3540
|
// Create and start IPC server
|
|
3345
3541
|
const ipcServer = createIpcServer({ socketPath, maxConnections, logger }, renderedHandlers);
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
catch (err) {
|
|
3350
|
-
// As above: startDaemon's catch releases the singleton lock for us.
|
|
3351
|
-
await removeLockIfOwned(lockFilePath, process.pid, logger);
|
|
3352
|
-
throw err;
|
|
3353
|
-
}
|
|
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.
|
|
3354
3545
|
// MCP-002: Instantiate NotificationDispatcher (wired to IPC server)
|
|
3355
3546
|
const notificationDispatcher = new NotificationDispatcher({
|
|
3356
3547
|
logger,
|
|
@@ -3420,6 +3611,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3420
3611
|
});
|
|
3421
3612
|
return sent.ok ? { ok: true } : { ok: false, reason: sent.reason };
|
|
3422
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),
|
|
3423
3617
|
// ONE implementation, shared with the two-party test. It was a closure here, and that is exactly
|
|
3424
3618
|
// how the surface tests passed while the feature did nothing: the test wired this seam to
|
|
3425
3619
|
// `async () => ({ ok: true })`, which reported success, sent nothing, and agreed with whatever
|
|
@@ -3499,13 +3693,25 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3499
3693
|
.map((row) => row.session_id)
|
|
3500
3694
|
.reverse(),
|
|
3501
3695
|
openSession: async (agent, peerAgentId, correlationId) => {
|
|
3502
|
-
//
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
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();
|
|
3506
3714
|
}
|
|
3507
|
-
logger.info("document.delivery.session_opened", { agent, peerAgentId, sessionId: res.sessionId, correlationId });
|
|
3508
|
-
return { ok: true, sessionId: res.sessionId };
|
|
3509
3715
|
},
|
|
3510
3716
|
sealSession: async (agent, sessionId, correlationId) => {
|
|
3511
3717
|
// §16.4: the autonomous session still carries the seal — the ceremony goes to zero, the
|
|
@@ -3536,8 +3742,13 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3536
3742
|
// The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
|
|
3537
3743
|
// successful send. See the comment at the call site for why this is delivery-critical and
|
|
3538
3744
|
// not audit bookkeeping.
|
|
3539
|
-
appendLeaf: (agent, sessionId, contentHash, correlationId) => {
|
|
3540
|
-
|
|
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 };
|
|
3541
3752
|
},
|
|
3542
3753
|
});
|
|
3543
3754
|
documentTransports.set(agentName, transport);
|
|
@@ -3673,6 +3884,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3673
3884
|
}, RECONCILE_SWEEP_MS);
|
|
3674
3885
|
reconcileSweepTimer.unref?.();
|
|
3675
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
|
+
}
|
|
3676
3913
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
3677
3914
|
// MCP-002: Also unregister from notification dispatcher
|
|
3678
3915
|
ipcServer.onDisconnect((connectionId) => {
|
|
@@ -3731,6 +3968,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3731
3968
|
let stoppedHookFired = false;
|
|
3732
3969
|
async function stop(reason) {
|
|
3733
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();
|
|
3734
3987
|
// M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
|
|
3735
3988
|
// the generation so the running loop's while-condition fails on its next check.
|
|
3736
3989
|
stopTelegramPoller(); // M8C-TGDOOR-1: invalidate the poll loop; it exits on its next generation check
|