@cello-protocol/daemon 0.0.214 → 0.0.216
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/attendance-wiring.d.ts +0 -2
- package/dist/attendance-wiring.d.ts.map +1 -1
- package/dist/attendance-wiring.js +2 -6
- package/dist/attendance-wiring.js.map +1 -1
- package/dist/boot-connection-state.d.ts +0 -2
- package/dist/boot-connection-state.d.ts.map +1 -1
- package/dist/boot-connection-state.js +5 -46
- package/dist/boot-connection-state.js.map +1 -1
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +34 -9
- package/dist/close-session-handler.js.map +1 -1
- package/dist/co-attendance.d.ts +8 -72
- package/dist/co-attendance.d.ts.map +1 -1
- package/dist/co-attendance.js +8 -116
- package/dist/co-attendance.js.map +1 -1
- package/dist/daemon.js +2 -7
- package/dist/daemon.js.map +1 -1
- package/dist/disconnect-cleanup.d.ts +0 -1
- package/dist/disconnect-cleanup.d.ts.map +1 -1
- package/dist/disconnect-cleanup.js +1 -9
- package/dist/disconnect-cleanup.js.map +1 -1
- package/dist/frame-values.d.ts.map +1 -1
- package/dist/frame-values.js +4 -7
- package/dist/frame-values.js.map +1 -1
- package/dist/reply-lag.d.ts +11 -0
- package/dist/reply-lag.d.ts.map +1 -0
- package/dist/reply-lag.js +46 -0
- package/dist/reply-lag.js.map +1 -0
- package/dist/resume-last-seen.d.ts +15 -0
- package/dist/resume-last-seen.d.ts.map +1 -0
- package/dist/resume-last-seen.js +44 -0
- package/dist/resume-last-seen.js.map +1 -0
- package/dist/seal-coordinator.js +2 -2
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-legibility-tbs.d.ts.map +1 -1
- package/dist/seal-legibility-tbs.js +2 -3
- package/dist/seal-legibility-tbs.js.map +1 -1
- package/dist/sealed-conversation.d.ts +36 -0
- package/dist/sealed-conversation.d.ts.map +1 -0
- package/dist/sealed-conversation.js +142 -0
- package/dist/sealed-conversation.js.map +1 -0
- package/dist/session-content-handlers.d.ts +0 -17
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +122 -288
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-content-ingest.d.ts.map +1 -1
- package/dist/session-content-ingest.js +9 -3
- package/dist/session-content-ingest.js.map +1 -1
- package/dist/session-content-send.js +1 -1
- package/dist/session-content-send.js.map +1 -1
- package/dist/session-delivery-acks.d.ts +15 -41
- package/dist/session-delivery-acks.d.ts.map +1 -1
- package/dist/session-delivery-acks.js +26 -131
- package/dist/session-delivery-acks.js.map +1 -1
- package/dist/session-notify.js +1 -1
- package/dist/session-notify.js.map +1 -1
- package/dist/session-queries.d.ts +1 -1
- package/dist/session-queries.js +1 -1
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +58 -96
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-records.d.ts +4 -0
- package/dist/session-records.d.ts.map +1 -1
- package/dist/session-records.js +34 -1
- package/dist/session-records.js.map +1 -1
- package/dist/session-relay-client.d.ts +2 -0
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +15 -14
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-relay.d.ts.map +1 -1
- package/dist/session-relay.js +3 -2
- package/dist/session-relay.js.map +1 -1
- package/dist/session-schema.d.ts.map +1 -1
- package/dist/session-schema.js +31 -0
- package/dist/session-schema.js.map +1 -1
- package/dist/session-seal-leaf-store.d.ts +10 -0
- package/dist/session-seal-leaf-store.d.ts.map +1 -1
- package/dist/session-seal-leaf-store.js +18 -0
- package/dist/session-seal-leaf-store.js.map +1 -1
- package/dist/session-seal.d.ts.map +1 -1
- package/dist/session-seal.js +46 -11
- package/dist/session-seal.js.map +1 -1
- package/dist/session-terminal-refusal.d.ts +3 -0
- package/dist/session-terminal-refusal.d.ts.map +1 -1
- package/dist/session-terminal-refusal.js +21 -0
- package/dist/session-terminal-refusal.js.map +1 -1
- package/dist/telegram-doorbell.js +1 -1
- package/dist/telegram-doorbell.js.map +1 -1
- package/dist/withheld-content.d.ts +13 -0
- package/dist/withheld-content.d.ts.map +1 -0
- package/dist/withheld-content.js +46 -0
- package/dist/withheld-content.js.map +1 -0
- package/package.json +5 -5
|
@@ -22,6 +22,20 @@ import { isAutoReplyMarked } from "./away-detection.js";
|
|
|
22
22
|
import { REFUSAL_COUNT_GUIDANCE, REFUSAL_KIND_GUIDANCE } from "./refusal-reasons.js";
|
|
23
23
|
import { extractErrorMessage } from "./error-message.js";
|
|
24
24
|
import { SESSION_CLOSED_GUIDANCE, SESSION_CLOSED_REASON, SESSION_SEALING_IMPACT, closedSessionImpact, isClosedStatus } from "./session-closed.js";
|
|
25
|
+
import { replyLag } from "./reply-lag.js";
|
|
26
|
+
/** Whether the other side's latest replies were written before they saw this side's newest message. */
|
|
27
|
+
function replyLagFor(snm, agentName, sessionId) {
|
|
28
|
+
// Runs AFTER the read position moved, so a throw here would lose the messages being handed over.
|
|
29
|
+
// The warning is optional; the messages are not.
|
|
30
|
+
try {
|
|
31
|
+
const db = snm.getDb();
|
|
32
|
+
const row = db.prepare("SELECT k_local_pubkey FROM agents WHERE agent_id = ?").get(snm.resolveAgentId(agentName));
|
|
33
|
+
return row ? replyLag(db, row.k_local_pubkey, sessionId) : undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
25
39
|
/**
|
|
26
40
|
* DOD-M12B-AWAY-MARK-1 — what a reader needs to know the moment it sees a marked message.
|
|
27
41
|
*
|
|
@@ -178,7 +192,7 @@ export function sentAuthorship(r) {
|
|
|
178
192
|
return r.authorship;
|
|
179
193
|
}
|
|
180
194
|
export function registerSessionContentHandlers(deps) {
|
|
181
|
-
const { handlers, logger, sessionNodeManager, securityGateway, retryQueue, getConnState, resolveCurrentAgent, NO_CURRENT_AGENT_RESPONSE, getConnectionCursor, advanceConnectionCursor,
|
|
195
|
+
const { handlers, logger, sessionNodeManager, securityGateway, retryQueue, getConnState, resolveCurrentAgent, NO_CURRENT_AGENT_RESPONSE, getConnectionCursor, advanceConnectionCursor, clearTelegramRung, attendanceCount, } = deps;
|
|
182
196
|
/**
|
|
183
197
|
* DOD-COATTEND-SENDWINDOW-1 (review F1) — sessions with a send ON THE WIRE right now.
|
|
184
198
|
*
|
|
@@ -622,7 +636,7 @@ export function registerSessionContentHandlers(deps) {
|
|
|
622
636
|
const placed = sessionNodeManager.placeOwnLeaf(record.agent_name, sessionId, contentHashHex, sendBytes, sendResult.sequenceNumber, correlationId, "msg", sentAuthorship(sendResult));
|
|
623
637
|
// `sequence_number` is a LEAF INDEX or nothing. Reporting the relay's number when no leaf
|
|
624
638
|
// exists hands the caller a value that matches no leaf on this side, and anything that
|
|
625
|
-
// feeds it to a cursor
|
|
639
|
+
// feeds it to a cursor is then wrong.
|
|
626
640
|
const queuedLeaf = placed.placed ? placed.leafIndex : -1;
|
|
627
641
|
if (placed.placed) {
|
|
628
642
|
sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, queuedLeaf, "sent", sendBytes, correlationId, sentAuthorship(sendResult));
|
|
@@ -960,8 +974,8 @@ export function registerSessionContentHandlers(deps) {
|
|
|
960
974
|
// DOD-UNREAD-1 D4b: a TRANSCRIPT-ONLY session — received rows exist but no sessions row (the
|
|
961
975
|
// pre-D3/D4a phantom residue: the counterparty's reply landed while this side refused the
|
|
962
976
|
// session). Those rows are counted unread by getUnreadSummary, so cello_receive MUST be able
|
|
963
|
-
// to read them or the badge can never clear. Reading is a transcript operation: the
|
|
964
|
-
//
|
|
977
|
+
// to read them or the badge can never clear. Reading is a transcript operation: the read below
|
|
978
|
+
// works from the durable transcript alone. Only a session with NEITHER a row
|
|
965
979
|
// NOR transcript rows is truly not found.
|
|
966
980
|
let transcriptOnly = false;
|
|
967
981
|
if (!record) {
|
|
@@ -1041,139 +1055,108 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1041
1055
|
if (record && record.status === "interrupted") {
|
|
1042
1056
|
void sessionNodeManager.reviveIfNeededForRead(agentName, sessionId).catch(() => { });
|
|
1043
1057
|
}
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (typeof rawSince === "number" && Number.isFinite(rawSince)) {
|
|
1052
|
-
const sinceSeq = rawSince;
|
|
1053
|
-
// D4b AC2: a transcript-only session has no record to attribute from — `from` is null,
|
|
1054
|
-
// NEVER the string "unknown" (the transcript stores no counterparty; don't invent one).
|
|
1055
|
-
const from = record ? record.counterparty_pubkey : null;
|
|
1056
|
-
const { messages } = sessionNodeManager.readTranscript(agentName, sessionId);
|
|
1057
|
-
const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
|
|
1058
|
-
// Read ONCE, above both authorities below — the watermark walk and the connection cursor
|
|
1059
|
-
// both need it, and reading the tree twice invites the two to disagree about the same leaf.
|
|
1060
|
-
const leafKinds = sessionNodeManager.getSessionTree(agentName, sessionId).leaves();
|
|
1061
|
-
/** A `doc`/`reject` leaf occupies a sequence but is not a message: present, never unread. */
|
|
1062
|
-
const notAMessage = (seq) => {
|
|
1063
|
-
const kind = leafKinds[seq]?.kind;
|
|
1064
|
-
return kind === "doc" || kind === "reject";
|
|
1058
|
+
// Catch-up mode is GONE (Andre, live test 2026-09-13): the ordinary read already returns
|
|
1059
|
+
// everything unread. Refused by name so an old caller learns that, rather than being ignored.
|
|
1060
|
+
if (params?.since_seq !== undefined) {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
reason: "since_seq_removed",
|
|
1064
|
+
guidance: "since_seq no longer exists. Call cello_receive without it — it returns every unread message at once. For the whole conversation, use cello_transcript.",
|
|
1065
1065
|
};
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
// M8C-CURSOR-1 (reviewer HIGH fix): only advance through the CONTIGUOUS run this batch
|
|
1126
|
-
// actually delivered — if a sent leaf from another local connection sits in a gap, this
|
|
1127
|
-
// correctly refuses to advance past it (cello_get_transcript is still required to catch up).
|
|
1128
|
-
// The CONNECTION CURSOR needs the same treatment, and for the same reason. It is the second
|
|
1129
|
-
// authority the send gate consults — the gate passes if EITHER is satisfied — so leaving this
|
|
1130
|
-
// one keyed on received-rows-only means a document leaf still wedges it, and the caller is
|
|
1131
|
-
// still refused after reading everything there is to read.
|
|
1132
|
-
safeCursorAdvance(connectionId, sessionId, new Set([
|
|
1133
|
-
...received.map((m) => m.sequence),
|
|
1134
|
-
...leafKinds.flatMap((leaf, index) => (leaf.kind === "doc" || leaf.kind === "reject" ? [index] : [])),
|
|
1135
|
-
]));
|
|
1136
|
-
logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
|
|
1137
|
-
// DOD-M12B-AWAY-MARK-1: an away auto-reply arrives as an ordinary msg leaf at a real sequence,
|
|
1138
|
-
// so without this the batch read is positive evidence a person answered. Two agents spent the
|
|
1139
|
-
// morning of 2026-08-17 exchanging each other's away responders while both operators believed
|
|
1140
|
-
// a conversation was happening. Per-message boolean first (a caller can branch on it), one
|
|
1141
|
-
// guidance line second, and only when there is something to explain.
|
|
1142
|
-
const autoReplyCount = received.filter((m) => isAutoReplyMarked(m.text)).length;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* ─── ONE READ, EVERY UNREAD MESSAGE, ONE BOOKMARK ───────────────────────────────────────────
|
|
1069
|
+
*
|
|
1070
|
+
* The bookmark is the agent's persisted watermark for this session. Everything received after it
|
|
1071
|
+
* is handed over together and the bookmark moves to the last of them.
|
|
1072
|
+
*
|
|
1073
|
+
* It replaced three bookmarks, and the live test on 2026-09-13 shows why: catch-up moved two of
|
|
1074
|
+
* them, the plain read consulted a third that belonged to the CONNECTION and started empty on
|
|
1075
|
+
* every new one — so after catching up the next read re-served the old message, and every `/mcp`
|
|
1076
|
+
* reconnect or `cello` CLI command started over at the oldest message in the conversation.
|
|
1077
|
+
*
|
|
1078
|
+
* The bookmark moves to the LAST message handed over, not through a gap-stopping walk. A hole —
|
|
1079
|
+
* a blocked message, a document frame, a row that could not be decrypted — would otherwise strand
|
|
1080
|
+
* every later message, re-served on every call. A row that could not be written is reported on
|
|
1081
|
+
* the empty answer (`content_undeliverable`) rather than by holding the bookmark behind it.
|
|
1082
|
+
*/
|
|
1083
|
+
const from = record ? record.counterparty_pubkey : null;
|
|
1084
|
+
const takeUnread = () => {
|
|
1085
|
+
const watermark = sessionNodeManager.getLastDeliveredSeq(agentName, sessionId);
|
|
1086
|
+
// Asked ~47x/second while blocked, so the cheap SQL probe runs first and the transcript is
|
|
1087
|
+
// decoded only when something is actually there.
|
|
1088
|
+
if (!sessionNodeManager.findNextReceivedAfter(agentName, sessionId, watermark))
|
|
1089
|
+
return null;
|
|
1090
|
+
const unread = sessionNodeManager.readTranscript(agentName, sessionId).messages
|
|
1091
|
+
.filter((m) => m.direction === "received" && m.sequence > watermark);
|
|
1092
|
+
if (unread.length === 0)
|
|
1093
|
+
return null;
|
|
1094
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, unread[unread.length - 1].sequence);
|
|
1095
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
1096
|
+
return Object.assign(unread, { previousWatermark: watermark });
|
|
1097
|
+
};
|
|
1098
|
+
const deliver = (unread, correlationId) => {
|
|
1099
|
+
logger.info("session.receive.delivered", {
|
|
1100
|
+
sessionId, agentName, connectionId, count: unread.length,
|
|
1101
|
+
firstSequence: unread[0].sequence, lastSequence: unread[unread.length - 1].sequence,
|
|
1102
|
+
attendance: attendanceCount(agentName), correlationId,
|
|
1103
|
+
});
|
|
1104
|
+
const lastText = unread[unread.length - 1].text.trimEnd();
|
|
1105
|
+
// The turn signal belongs to the LAST message: it is what says whose move it is now.
|
|
1106
|
+
const signalGuidance = lastText.endsWith("[[WRAP]]")
|
|
1107
|
+
? "Counterparty wrapped. Call cello_close_session now — do not reply."
|
|
1108
|
+
: lastText.endsWith("[[OVER]]")
|
|
1109
|
+
? "Counterparty's turn is done. Counterparty has indicated they are expecting a reply — use cello_send to reply."
|
|
1110
|
+
: /\[\[STANDBY EST:\d+m\]\]$/.test(lastText)
|
|
1111
|
+
? "Counterparty is working and will follow up when done — no response expected. To block: call cello_receive with a longer timeout_ms. To check back later: schedule a cron and call cello_receive then."
|
|
1112
|
+
: undefined;
|
|
1113
|
+
// DOD-M12B-AWAY-MARK-1: an away auto-reply is an ordinary message leaf, so without the mark a
|
|
1114
|
+
// read is positive evidence a person answered. The content is passed through WHOLE.
|
|
1115
|
+
const autoReplyCount = unread.filter((m) => isAutoReplyMarked(m.text)).length;
|
|
1116
|
+
// A message that arrived but could not be written to the transcript has no row, so the
|
|
1117
|
+
// bookmark just moved past it. Say so HERE, on the answer that crossed it — otherwise the
|
|
1118
|
+
// operator sees only a gap in the sequence numbers (review of 86ec7524).
|
|
1119
|
+
const lastSeq = unread[unread.length - 1].sequence;
|
|
1120
|
+
// Only the lost messages THIS read moved past — the in-memory set is never pruned, so without
|
|
1121
|
+
// the lower bound every later read would repeat the warning until a restart.
|
|
1122
|
+
const readFrom = unread.previousWatermark ?? -1;
|
|
1123
|
+
const crossed = sessionNodeManager.getUndeliverableSeqs(agentName, sessionId).filter((s) => s > readFrom && s <= lastSeq);
|
|
1124
|
+
const lag = replyLagFor(sessionNodeManager, agentName, sessionId);
|
|
1143
1125
|
return {
|
|
1126
|
+
...(lag ? {
|
|
1127
|
+
reply_lag: lag,
|
|
1128
|
+
reply_lag_guidance: `Their last ${lag.replies} replies were written before they saw your message at position ${lag.your_unseen_seq}, so they may be answering something older. Check whether it reached them before assuming they disagree.`,
|
|
1129
|
+
} : {}),
|
|
1144
1130
|
ok: true,
|
|
1145
|
-
|
|
1146
|
-
count:
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
// AC6 (review F1): the catch-up batch is THE stateless-client door — `cello receive <id>
|
|
1153
|
-
// --since-seq -1` is a fresh connection every time, so it never saw a doorbell, and the
|
|
1154
|
-
// `session_not_live` refusal below points callers here BY NAME. Shipping attendance on the
|
|
1155
|
-
// live exits and not this one left the defect alive in the exact shape the AC exists for.
|
|
1156
|
-
attendance: attendingNow(agentName),
|
|
1157
|
-
// The content is passed through WHOLE — marker included, nothing stripped. The marker
|
|
1158
|
-
// labels; it never suppresses, and a reader that never sees it cannot judge it.
|
|
1159
|
-
messages: received.map((m) => ({
|
|
1131
|
+
session_id: sessionId,
|
|
1132
|
+
count: unread.length,
|
|
1133
|
+
...(crossed.length > 0 ? {
|
|
1134
|
+
undeliverable_sequences: [...crossed],
|
|
1135
|
+
undeliverable_guidance: `${crossed.length} message(s) arrived but could not be written to the local transcript, so they cannot be shown. This is a fault on THIS machine (check disk space and ~/.cello permissions), not the counterparty. Ask them to resend once it is fixed.`,
|
|
1136
|
+
} : {}),
|
|
1137
|
+
messages: unread.map((m) => ({
|
|
1160
1138
|
sequence: m.sequence,
|
|
1161
1139
|
content: m.text,
|
|
1162
1140
|
from,
|
|
1163
1141
|
...(isAutoReplyMarked(m.text) ? { auto_reply: true } : {}),
|
|
1164
1142
|
})),
|
|
1143
|
+
...(autoReplyCount > 0 ? { auto_reply_guidance: AUTO_REPLY_GUIDANCE } : {}),
|
|
1144
|
+
...refusalsField(sessionNodeManager, agentName, sessionId, connectionId),
|
|
1145
|
+
attendance: attendingNow(agentName),
|
|
1146
|
+
...(signalGuidance !== undefined ? { guidance: signalGuidance } : {}),
|
|
1165
1147
|
};
|
|
1166
|
-
}
|
|
1167
|
-
// D4b
|
|
1168
|
-
//
|
|
1169
|
-
// misleading and session_not_found would be a lie (the transcript exists). A distinct reason
|
|
1170
|
-
// points the caller at the read that works.
|
|
1148
|
+
};
|
|
1149
|
+
// D4b: a transcript-only session has no live node, so nothing new will ever arrive. Hand over
|
|
1150
|
+
// what is unread without waiting; if nothing is, say where the history is.
|
|
1171
1151
|
if (transcriptOnly) {
|
|
1152
|
+
const unread = takeUnread();
|
|
1153
|
+
if (unread)
|
|
1154
|
+
return deliver(unread, randomUUID());
|
|
1172
1155
|
return {
|
|
1173
1156
|
ok: false,
|
|
1174
1157
|
reason: "session_not_live",
|
|
1175
1158
|
attendance: attendingNow(agentName),
|
|
1176
|
-
guidance: "This session exists only as a
|
|
1159
|
+
guidance: "This session exists only as a stored transcript and nothing in it is unread. Read the whole conversation with cello_transcript.",
|
|
1177
1160
|
};
|
|
1178
1161
|
}
|
|
1179
1162
|
const rawTimeout = params?.timeout_ms;
|
|
@@ -1205,6 +1188,10 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1205
1188
|
const terminal = sessionNodeManager.peekTerminalMarker(agentName, sessionId);
|
|
1206
1189
|
if (terminal) {
|
|
1207
1190
|
const sealedRoot = sessionNodeManager.getSealedRootHex(agentName, sessionId);
|
|
1191
|
+
// ⚠️ Unread messages are deliberately NOT handed over here, although that is the one place
|
|
1192
|
+
// "every unread message" does not hold. A sealed conversation's leftover message is not live
|
|
1193
|
+
// work: on 2026-08-05 an agent obeyed a [[STANDBY]] from a session that had ended. The
|
|
1194
|
+
// operator is pointed at cello_transcript instead (DOD-TERMINAL-WAKE-1).
|
|
1208
1195
|
// AC3 covers "both outcomes" — got something / got nothing. This is the third exit from the
|
|
1209
1196
|
// same silent handler, and leaving it silent would reproduce the defect one branch over.
|
|
1210
1197
|
logger.info("session.receive.sealed", {
|
|
@@ -1230,102 +1217,9 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1230
1217
|
: "The session has been sealed by both parties. The full history is available via cello_transcript. No further actions are required on this session.",
|
|
1231
1218
|
};
|
|
1232
1219
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
// (agentName, sessionId) and NOT by connection. The doorbell is multicast, so two attached
|
|
1237
|
-
// sessions were both woken, both entered this loop, and whichever hit the next 20 ms tick
|
|
1238
|
-
// first REMOVED the message from the other's view — which was then told, word for word, what
|
|
1239
|
-
// a quiet counterparty produces.
|
|
1240
|
-
//
|
|
1241
|
-
// Now: the transcript is the source of truth and each connection has its own cursor, so
|
|
1242
|
-
// reading is non-destructive by construction. Nothing one consumer does mutates state another
|
|
1243
|
-
// consumer reads — which is exactly what `shift()` violated. The doorbell STAYS multicast
|
|
1244
|
-
// (AC 2); the queue was the defect, not the wake-up.
|
|
1245
|
-
//
|
|
1246
|
-
// Ordering is safe: `#appendVerifiedContent` writes the transcript row (`:3996`) BEFORE it
|
|
1247
|
-
// pushes to the buffer (`:4003`), so the record can never lag the queue this replaces.
|
|
1248
|
-
//
|
|
1249
|
-
// The bar is the DELIVERY BOOKMARK, not the gate's read cursor (review F1, BLOCKING). Using
|
|
1250
|
-
// the cursor here pinned this connection below the first gap in its received-only view — and
|
|
1251
|
-
// a sibling connection's SENT leaf is such a gap, as is a security-gateway block that leaves
|
|
1252
|
-
// a permanent hole. The same message was then re-served on every call, forever. The gate's
|
|
1253
|
-
// cursor stays exactly as it was; it simply is not the thing that answers this question.
|
|
1254
|
-
const deliveredThrough = getDeliveryBookmark(connectionId, sessionId);
|
|
1255
|
-
// Asked ~47x/second per blocked connection, so the predicate is in SQL and exactly one blob is
|
|
1256
|
-
// decoded (review F5). The obvious `readTranscript().messages.find(...)` decoded the entire
|
|
1257
|
-
// session on every tick and threw all of it away.
|
|
1258
|
-
//
|
|
1259
|
-
// Guarded on `record` (review F7 claimed this guard was dead — it is NOT, and the typecheck
|
|
1260
|
-
// proved it): a TRANSCRIPT-ONLY session has received rows and no `sessions` row, and it does
|
|
1261
|
-
// not return above, it falls through with `record === null`. Live delivery needs the
|
|
1262
|
-
// counterparty pubkey, which only the record carries, so those sessions are read through the
|
|
1263
|
-
// since_seq catch-up from the transcript alone — exactly as before this change. Dropping the
|
|
1264
|
-
// guard is a null dereference on the one session shape that reaches here without a record.
|
|
1265
|
-
const nextRow = record
|
|
1266
|
-
? sessionNodeManager.findNextReceivedAfter(agentName, sessionId, deliveredThrough)
|
|
1267
|
-
: null;
|
|
1268
|
-
const entry = nextRow
|
|
1269
|
-
? {
|
|
1270
|
-
contentHex: Buffer.from(nextRow.text, "utf8").toString("hex"),
|
|
1271
|
-
senderPubkey: record.counterparty_pubkey,
|
|
1272
|
-
sequenceNumber: nextRow.sequence,
|
|
1273
|
-
}
|
|
1274
|
-
: null;
|
|
1275
|
-
if (entry) {
|
|
1276
|
-
// M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
|
|
1277
|
-
// message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
|
|
1278
|
-
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
|
|
1279
|
-
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
1280
|
-
// M8C-CURSOR-1 (reviewer HIGH fix): a single delivered message only proves THIS sequence
|
|
1281
|
-
// was read — safeCursorAdvance refuses to vault past a gap (e.g. an unread sent leaf from
|
|
1282
|
-
// another local connection) even though this specific sequence number is now known.
|
|
1283
|
-
safeCursorAdvance(connectionId, sessionId, new Set([entry.sequenceNumber]));
|
|
1284
|
-
// ...and separately, this connection has now BEEN HANDED this leaf. Monotonic, no gap walk:
|
|
1285
|
-
// that is the whole distinction (review F1). Without this the read above re-finds the same
|
|
1286
|
-
// row on the next call.
|
|
1287
|
-
advanceDeliveryBookmark(connectionId, sessionId, entry.sequenceNumber);
|
|
1288
|
-
// DOD-COATTEND-VISIBLE-1: the ledger of which connection was handed which leaf.
|
|
1289
|
-
//
|
|
1290
|
-
// It was written when this WAS a destructive drain, and the comment here used to say so —
|
|
1291
|
-
// "the message has just been REMOVED from every co-attending session's view". That is no
|
|
1292
|
-
// longer true and had become the most misleading sentence in the file (review F6): it
|
|
1293
|
-
// asserted the very property this unit removed, at the one site a reader would come to
|
|
1294
|
-
// check it. Delivery is non-destructive now; the ledger survives because the `taken_by_
|
|
1295
|
-
// sibling_session` discriminator still reads it, and its deletion is its own unit.
|
|
1296
|
-
contentTakes.record(agentName, sessionId, connectionId, entry.sequenceNumber);
|
|
1297
|
-
logger.info("session.receive.delivered", {
|
|
1298
|
-
sessionId, agentName, connectionId, sequenceNumber: entry.sequenceNumber,
|
|
1299
|
-
attendance: attendanceCount(agentName), correlationId: receiveCorrelationId,
|
|
1300
|
-
});
|
|
1301
|
-
const contentText = Buffer.from(entry.contentHex, "hex").toString("utf8");
|
|
1302
|
-
const trimmed = contentText.trimEnd();
|
|
1303
|
-
const signalGuidance = trimmed.endsWith("[[WRAP]]")
|
|
1304
|
-
? "Counterparty wrapped. Call cello_close_session now — do not reply."
|
|
1305
|
-
: trimmed.endsWith("[[OVER]]")
|
|
1306
|
-
? "Counterparty's turn is done. Counterparty has indicated they are expecting a reply — use cello_send to reply."
|
|
1307
|
-
: /\[\[STANDBY EST:\d+m\]\]$/.test(trimmed)
|
|
1308
|
-
? "Counterparty is working and will follow up when done — no response expected. To block: call cello_receive with a longer timeout_ms. To check back later: schedule a cron and call cello_receive then."
|
|
1309
|
-
: undefined;
|
|
1310
|
-
// DOD-M12B-AWAY-MARK-1: same fact on the live exit as on the batch exit above. This is the
|
|
1311
|
-
// one an attended agent actually hits, so leaving it off here would have left the defect
|
|
1312
|
-
// alive in the shape it was measured in.
|
|
1313
|
-
const isAutoReply = isAutoReplyMarked(contentText);
|
|
1314
|
-
return {
|
|
1315
|
-
ok: true,
|
|
1316
|
-
content: contentText,
|
|
1317
|
-
...(isAutoReply ? { auto_reply: true, auto_reply_guidance: AUTO_REPLY_GUIDANCE } : {}),
|
|
1318
|
-
// AC6: every READ answer says whether this session is alone. The push already carried
|
|
1319
|
-
// this; the read surfaces did not, so a session that never saw a doorbell — a fresh MCP
|
|
1320
|
-
// connection, EVERY `cello` CLI invocation, anything that attached after the last
|
|
1321
|
-
// arrival — had no way to learn it was co-attended. Live finding, journal Entry 33.
|
|
1322
|
-
attendance: attendingNow(agentName),
|
|
1323
|
-
sessionId,
|
|
1324
|
-
sequence_number: entry.sequenceNumber,
|
|
1325
|
-
senderPubkey: entry.senderPubkey,
|
|
1326
|
-
...(signalGuidance !== undefined ? { guidance: signalGuidance } : {}),
|
|
1327
|
-
};
|
|
1328
|
-
}
|
|
1220
|
+
const unread = takeUnread();
|
|
1221
|
+
if (unread)
|
|
1222
|
+
return deliver(unread, receiveCorrelationId);
|
|
1329
1223
|
// 3) Out of time — non-blocking-equivalent empty answer.
|
|
1330
1224
|
const remaining = deadline - Date.now();
|
|
1331
1225
|
if (remaining <= 0) {
|
|
@@ -1345,7 +1239,8 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1345
1239
|
});
|
|
1346
1240
|
return {
|
|
1347
1241
|
ok: true,
|
|
1348
|
-
|
|
1242
|
+
count: 0,
|
|
1243
|
+
messages: [],
|
|
1349
1244
|
reason: "content_undeliverable",
|
|
1350
1245
|
attendance: attendingNow(agentName),
|
|
1351
1246
|
// A local write failure and a refused message look identical from the operator's chair —
|
|
@@ -1357,71 +1252,6 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1357
1252
|
}
|
|
1358
1253
|
const liveness = sessionNodeManager.getSessionLiveness(agentName, sessionId);
|
|
1359
1254
|
const attendance = attendanceCount(agentName);
|
|
1360
|
-
// ─── DOD-COATTEND-VISIBLE-1 AC1: the loser of the race gets a DIFFERENT answer ───
|
|
1361
|
-
//
|
|
1362
|
-
// The buffer is keyed (agentName, sessionId) and drained with `buf.shift()`, so when the
|
|
1363
|
-
// multicast doorbell wakes two attending sessions the faster one REMOVES the message. Until
|
|
1364
|
-
// this branch existed the slower one was told, word for word, what a quiet counterparty
|
|
1365
|
-
// produces — and nothing was logged either, so the theft left no trace anywhere.
|
|
1366
|
-
//
|
|
1367
|
-
// The bar is this connection's own read cursor, not a time window: it reports content this
|
|
1368
|
-
// connection genuinely has not seen, and it CLEARS itself once the caller catches up. The
|
|
1369
|
-
// discriminator is `reason` — the field this very return already uses for its other branch —
|
|
1370
|
-
// so a caller switching on it needs no new shape. `taken_by_sibling` carries the machine-
|
|
1371
|
-
// readable detail; the prose below is the presentation of that, never a substitute for it.
|
|
1372
|
-
const missed = contentTakes.missedBy(agentName, sessionId, connectionId, getConnectionCursor(connectionId, sessionId));
|
|
1373
|
-
if (missed) {
|
|
1374
|
-
// The message a sibling took is described WITHOUT claiming that sibling still attends
|
|
1375
|
-
// this agent (review MEDIUM). A connection may operate on the sole online agent through
|
|
1376
|
-
// `resolveCurrentAgent` WITHOUT attending it, and it drains the same buffer — so a real
|
|
1377
|
-
// thief need not be counted in `attendance`. The old wording produced sentences that
|
|
1378
|
-
// contradicted themselves ("another session attending alice — 1 sessions are attending").
|
|
1379
|
-
// `attendance` stays as its own field, meaning what it says: how many sessions SELECTED
|
|
1380
|
-
// this agent.
|
|
1381
|
-
const takenDetail = {
|
|
1382
|
-
count: missed.count,
|
|
1383
|
-
last_sequence: missed.lastSequence,
|
|
1384
|
-
connections: missed.connections,
|
|
1385
|
-
// `count` is a floor once the per-session cap has discarded older takes.
|
|
1386
|
-
...(missed.truncated ? { truncated: true } : {}),
|
|
1387
|
-
};
|
|
1388
|
-
logger.warn("session.receive.taken_by_sibling", {
|
|
1389
|
-
sessionId, agentName, connectionId, timeoutMs, attendance, liveness,
|
|
1390
|
-
takenCount: missed.count, lastTakenSeq: missed.lastSequence, takenBy: missed.connections,
|
|
1391
|
-
truncated: missed.truncated, correlationId: receiveCorrelationId,
|
|
1392
|
-
});
|
|
1393
|
-
const missedText = `Another session on this daemon already received ${missed.count} message(s) on this session that you have not read (up to sequence ${missed.lastSequence}). Nothing was lost: read what you missed with cello_transcript ${sessionId}.`;
|
|
1394
|
-
// BOTH conditions can be true, and `counterparty_gone` WINS the `reason` field when they
|
|
1395
|
-
// are (review MEDIUM). `reason` is the machine-readable discriminator, and a caller
|
|
1396
|
-
// switching on it must still see the TERMINAL, actionable condition: telling an operator
|
|
1397
|
-
// to "read what you missed, then reply" to a counterparty whose connection is dead sends
|
|
1398
|
-
// them to the wrong subsystem and the reply goes nowhere. The theft is additive
|
|
1399
|
-
// information, so it rides as a field and as the first half of the guidance — nothing is
|
|
1400
|
-
// hidden, but the answer names the condition that changes what the operator should DO.
|
|
1401
|
-
if (liveness === "gone") {
|
|
1402
|
-
return {
|
|
1403
|
-
ok: true,
|
|
1404
|
-
content: null,
|
|
1405
|
-
reason: "counterparty_gone",
|
|
1406
|
-
liveness: "gone",
|
|
1407
|
-
// Same reasoning as the standalone `counterparty_gone` exit below.
|
|
1408
|
-
...refusalsField(sessionNodeManager, agentName, sessionId, connectionId),
|
|
1409
|
-
taken_by_sibling: takenDetail,
|
|
1410
|
-
attendance,
|
|
1411
|
-
// Same wording rule as the standalone `counterparty_gone` exit below: name what was
|
|
1412
|
-
// observed (a dropped connection), never a crash, and do not lead with the seal.
|
|
1413
|
-
guidance: `${missedText} Note the direct connection to the counterparty's session peer has ALSO dropped (liveness: gone) — that is all that was observed, not that they crashed. No more content will arrive on the direct path and a reply cannot reach them there. Read the history first, and check \`refusals\` here and in cello_inbox before blaming the network — this side refusing their messages produces exactly this state. Sealing with cello_close_session ENDS the conversation permanently, so do it only once you are satisfied nothing is outstanding; if they never co-close, a unilateral seal becomes available after the directory's delivery-grace window.`,
|
|
1414
|
-
};
|
|
1415
|
-
}
|
|
1416
|
-
return {
|
|
1417
|
-
ok: true,
|
|
1418
|
-
content: null,
|
|
1419
|
-
reason: "taken_by_sibling_session",
|
|
1420
|
-
taken_by_sibling: takenDetail,
|
|
1421
|
-
attendance,
|
|
1422
|
-
guidance: `${missedText} Then reply — do not resend your last message.`,
|
|
1423
|
-
};
|
|
1424
|
-
}
|
|
1425
1255
|
// M8B F16: a dead session must not return the SAME null timeout as a
|
|
1426
1256
|
// quiet-but-healthy one. The liveness signal (session.liveness.changed → gone,
|
|
1427
1257
|
// tracked per session by the node manager) finally reaches the MCP surface here.
|
|
@@ -1432,7 +1262,8 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1432
1262
|
});
|
|
1433
1263
|
return {
|
|
1434
1264
|
ok: true,
|
|
1435
|
-
|
|
1265
|
+
count: 0,
|
|
1266
|
+
messages: [],
|
|
1436
1267
|
attendance: attendingNow(agentName),
|
|
1437
1268
|
// DOD-M15-NO-SILENT-REFUSAL-1 — the exit that does not merely stay silent, it MISLEADS.
|
|
1438
1269
|
// A version-skewed peer never gets an ACK, so it eventually drops the direct path and
|
|
@@ -1494,7 +1325,8 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1494
1325
|
: "";
|
|
1495
1326
|
return {
|
|
1496
1327
|
ok: true,
|
|
1497
|
-
|
|
1328
|
+
count: 0,
|
|
1329
|
+
messages: [],
|
|
1498
1330
|
attendance: attendingNow(agentName),
|
|
1499
1331
|
reason: "delivery_impaired",
|
|
1500
1332
|
liveness: "impaired",
|
|
@@ -1527,7 +1359,9 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1527
1359
|
const quietRefusals = refusalsField(sessionNodeManager, agentName, sessionId, connectionId);
|
|
1528
1360
|
return {
|
|
1529
1361
|
ok: true,
|
|
1530
|
-
|
|
1362
|
+
// The same shape as a read that found messages, so a caller reads `messages` either way.
|
|
1363
|
+
count: 0,
|
|
1364
|
+
messages: [],
|
|
1531
1365
|
attendance: attendingNow(agentName),
|
|
1532
1366
|
...quietRefusals,
|
|
1533
1367
|
// ONE source for the refusal advice (see `refusalsField`). It used to be written out
|