@cello-protocol/daemon 0.0.214 → 0.0.215
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 +12 -3
- 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/session-content-handlers.d.ts +0 -17
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +94 -284
- 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 +8 -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.map +1 -1
- package/dist/session-delivery-acks.js +18 -3
- 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-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +2 -0
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-seal.d.ts.map +1 -1
- package/dist/session-seal.js +36 -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/package.json +5 -5
|
@@ -178,7 +178,7 @@ export function sentAuthorship(r) {
|
|
|
178
178
|
return r.authorship;
|
|
179
179
|
}
|
|
180
180
|
export function registerSessionContentHandlers(deps) {
|
|
181
|
-
const { handlers, logger, sessionNodeManager, securityGateway, retryQueue, getConnState, resolveCurrentAgent, NO_CURRENT_AGENT_RESPONSE, getConnectionCursor, advanceConnectionCursor,
|
|
181
|
+
const { handlers, logger, sessionNodeManager, securityGateway, retryQueue, getConnState, resolveCurrentAgent, NO_CURRENT_AGENT_RESPONSE, getConnectionCursor, advanceConnectionCursor, clearTelegramRung, attendanceCount, } = deps;
|
|
182
182
|
/**
|
|
183
183
|
* DOD-COATTEND-SENDWINDOW-1 (review F1) — sessions with a send ON THE WIRE right now.
|
|
184
184
|
*
|
|
@@ -622,7 +622,7 @@ export function registerSessionContentHandlers(deps) {
|
|
|
622
622
|
const placed = sessionNodeManager.placeOwnLeaf(record.agent_name, sessionId, contentHashHex, sendBytes, sendResult.sequenceNumber, correlationId, "msg", sentAuthorship(sendResult));
|
|
623
623
|
// `sequence_number` is a LEAF INDEX or nothing. Reporting the relay's number when no leaf
|
|
624
624
|
// exists hands the caller a value that matches no leaf on this side, and anything that
|
|
625
|
-
// feeds it to a cursor
|
|
625
|
+
// feeds it to a cursor is then wrong.
|
|
626
626
|
const queuedLeaf = placed.placed ? placed.leafIndex : -1;
|
|
627
627
|
if (placed.placed) {
|
|
628
628
|
sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, queuedLeaf, "sent", sendBytes, correlationId, sentAuthorship(sendResult));
|
|
@@ -960,8 +960,8 @@ export function registerSessionContentHandlers(deps) {
|
|
|
960
960
|
// DOD-UNREAD-1 D4b: a TRANSCRIPT-ONLY session — received rows exist but no sessions row (the
|
|
961
961
|
// pre-D3/D4a phantom residue: the counterparty's reply landed while this side refused the
|
|
962
962
|
// 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
|
-
//
|
|
963
|
+
// to read them or the badge can never clear. Reading is a transcript operation: the read below
|
|
964
|
+
// works from the durable transcript alone. Only a session with NEITHER a row
|
|
965
965
|
// NOR transcript rows is truly not found.
|
|
966
966
|
let transcriptOnly = false;
|
|
967
967
|
if (!record) {
|
|
@@ -1041,139 +1041,103 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1041
1041
|
if (record && record.status === "interrupted") {
|
|
1042
1042
|
void sessionNodeManager.reviveIfNeededForRead(agentName, sessionId).catch(() => { });
|
|
1043
1043
|
}
|
|
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";
|
|
1044
|
+
// Catch-up mode is GONE (Andre, live test 2026-09-13): the ordinary read already returns
|
|
1045
|
+
// everything unread. Refused by name so an old caller learns that, rather than being ignored.
|
|
1046
|
+
if (params?.since_seq !== undefined) {
|
|
1047
|
+
return {
|
|
1048
|
+
ok: false,
|
|
1049
|
+
reason: "since_seq_removed",
|
|
1050
|
+
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
1051
|
};
|
|
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;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* ─── ONE READ, EVERY UNREAD MESSAGE, ONE BOOKMARK ───────────────────────────────────────────
|
|
1055
|
+
*
|
|
1056
|
+
* The bookmark is the agent's persisted watermark for this session. Everything received after it
|
|
1057
|
+
* is handed over together and the bookmark moves to the last of them.
|
|
1058
|
+
*
|
|
1059
|
+
* It replaced three bookmarks, and the live test on 2026-09-13 shows why: catch-up moved two of
|
|
1060
|
+
* them, the plain read consulted a third that belonged to the CONNECTION and started empty on
|
|
1061
|
+
* every new one — so after catching up the next read re-served the old message, and every `/mcp`
|
|
1062
|
+
* reconnect or `cello` CLI command started over at the oldest message in the conversation.
|
|
1063
|
+
*
|
|
1064
|
+
* The bookmark moves to the LAST message handed over, not through a gap-stopping walk. A hole —
|
|
1065
|
+
* a blocked message, a document frame, a row that could not be decrypted — would otherwise strand
|
|
1066
|
+
* every later message, re-served on every call. A row that could not be written is reported on
|
|
1067
|
+
* the empty answer (`content_undeliverable`) rather than by holding the bookmark behind it.
|
|
1068
|
+
*/
|
|
1069
|
+
const from = record ? record.counterparty_pubkey : null;
|
|
1070
|
+
const takeUnread = () => {
|
|
1071
|
+
const watermark = sessionNodeManager.getLastDeliveredSeq(agentName, sessionId);
|
|
1072
|
+
// Asked ~47x/second while blocked, so the cheap SQL probe runs first and the transcript is
|
|
1073
|
+
// decoded only when something is actually there.
|
|
1074
|
+
if (!sessionNodeManager.findNextReceivedAfter(agentName, sessionId, watermark))
|
|
1075
|
+
return null;
|
|
1076
|
+
const unread = sessionNodeManager.readTranscript(agentName, sessionId).messages
|
|
1077
|
+
.filter((m) => m.direction === "received" && m.sequence > watermark);
|
|
1078
|
+
if (unread.length === 0)
|
|
1079
|
+
return null;
|
|
1080
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, unread[unread.length - 1].sequence);
|
|
1081
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
1082
|
+
return Object.assign(unread, { previousWatermark: watermark });
|
|
1083
|
+
};
|
|
1084
|
+
const deliver = (unread, correlationId) => {
|
|
1085
|
+
logger.info("session.receive.delivered", {
|
|
1086
|
+
sessionId, agentName, connectionId, count: unread.length,
|
|
1087
|
+
firstSequence: unread[0].sequence, lastSequence: unread[unread.length - 1].sequence,
|
|
1088
|
+
attendance: attendanceCount(agentName), correlationId,
|
|
1089
|
+
});
|
|
1090
|
+
const lastText = unread[unread.length - 1].text.trimEnd();
|
|
1091
|
+
// The turn signal belongs to the LAST message: it is what says whose move it is now.
|
|
1092
|
+
const signalGuidance = lastText.endsWith("[[WRAP]]")
|
|
1093
|
+
? "Counterparty wrapped. Call cello_close_session now — do not reply."
|
|
1094
|
+
: lastText.endsWith("[[OVER]]")
|
|
1095
|
+
? "Counterparty's turn is done. Counterparty has indicated they are expecting a reply — use cello_send to reply."
|
|
1096
|
+
: /\[\[STANDBY EST:\d+m\]\]$/.test(lastText)
|
|
1097
|
+
? "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."
|
|
1098
|
+
: undefined;
|
|
1099
|
+
// DOD-M12B-AWAY-MARK-1: an away auto-reply is an ordinary message leaf, so without the mark a
|
|
1100
|
+
// read is positive evidence a person answered. The content is passed through WHOLE.
|
|
1101
|
+
const autoReplyCount = unread.filter((m) => isAutoReplyMarked(m.text)).length;
|
|
1102
|
+
// A message that arrived but could not be written to the transcript has no row, so the
|
|
1103
|
+
// bookmark just moved past it. Say so HERE, on the answer that crossed it — otherwise the
|
|
1104
|
+
// operator sees only a gap in the sequence numbers (review of 86ec7524).
|
|
1105
|
+
const lastSeq = unread[unread.length - 1].sequence;
|
|
1106
|
+
// Only the lost messages THIS read moved past — the in-memory set is never pruned, so without
|
|
1107
|
+
// the lower bound every later read would repeat the warning until a restart.
|
|
1108
|
+
const readFrom = unread.previousWatermark ?? -1;
|
|
1109
|
+
const crossed = sessionNodeManager.getUndeliverableSeqs(agentName, sessionId).filter((s) => s > readFrom && s <= lastSeq);
|
|
1143
1110
|
return {
|
|
1144
1111
|
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) => ({
|
|
1112
|
+
session_id: sessionId,
|
|
1113
|
+
count: unread.length,
|
|
1114
|
+
...(crossed.length > 0 ? {
|
|
1115
|
+
undeliverable_sequences: [...crossed],
|
|
1116
|
+
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.`,
|
|
1117
|
+
} : {}),
|
|
1118
|
+
messages: unread.map((m) => ({
|
|
1160
1119
|
sequence: m.sequence,
|
|
1161
1120
|
content: m.text,
|
|
1162
1121
|
from,
|
|
1163
1122
|
...(isAutoReplyMarked(m.text) ? { auto_reply: true } : {}),
|
|
1164
1123
|
})),
|
|
1124
|
+
...(autoReplyCount > 0 ? { auto_reply_guidance: AUTO_REPLY_GUIDANCE } : {}),
|
|
1125
|
+
...refusalsField(sessionNodeManager, agentName, sessionId, connectionId),
|
|
1126
|
+
attendance: attendingNow(agentName),
|
|
1127
|
+
...(signalGuidance !== undefined ? { guidance: signalGuidance } : {}),
|
|
1165
1128
|
};
|
|
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.
|
|
1129
|
+
};
|
|
1130
|
+
// D4b: a transcript-only session has no live node, so nothing new will ever arrive. Hand over
|
|
1131
|
+
// what is unread without waiting; if nothing is, say where the history is.
|
|
1171
1132
|
if (transcriptOnly) {
|
|
1133
|
+
const unread = takeUnread();
|
|
1134
|
+
if (unread)
|
|
1135
|
+
return deliver(unread, randomUUID());
|
|
1172
1136
|
return {
|
|
1173
1137
|
ok: false,
|
|
1174
1138
|
reason: "session_not_live",
|
|
1175
1139
|
attendance: attendingNow(agentName),
|
|
1176
|
-
guidance: "This session exists only as a
|
|
1140
|
+
guidance: "This session exists only as a stored transcript and nothing in it is unread. Read the whole conversation with cello_transcript.",
|
|
1177
1141
|
};
|
|
1178
1142
|
}
|
|
1179
1143
|
const rawTimeout = params?.timeout_ms;
|
|
@@ -1205,6 +1169,10 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1205
1169
|
const terminal = sessionNodeManager.peekTerminalMarker(agentName, sessionId);
|
|
1206
1170
|
if (terminal) {
|
|
1207
1171
|
const sealedRoot = sessionNodeManager.getSealedRootHex(agentName, sessionId);
|
|
1172
|
+
// ⚠️ Unread messages are deliberately NOT handed over here, although that is the one place
|
|
1173
|
+
// "every unread message" does not hold. A sealed conversation's leftover message is not live
|
|
1174
|
+
// work: on 2026-08-05 an agent obeyed a [[STANDBY]] from a session that had ended. The
|
|
1175
|
+
// operator is pointed at cello_transcript instead (DOD-TERMINAL-WAKE-1).
|
|
1208
1176
|
// AC3 covers "both outcomes" — got something / got nothing. This is the third exit from the
|
|
1209
1177
|
// same silent handler, and leaving it silent would reproduce the defect one branch over.
|
|
1210
1178
|
logger.info("session.receive.sealed", {
|
|
@@ -1230,102 +1198,9 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1230
1198
|
: "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
1199
|
};
|
|
1232
1200
|
}
|
|
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
|
-
}
|
|
1201
|
+
const unread = takeUnread();
|
|
1202
|
+
if (unread)
|
|
1203
|
+
return deliver(unread, receiveCorrelationId);
|
|
1329
1204
|
// 3) Out of time — non-blocking-equivalent empty answer.
|
|
1330
1205
|
const remaining = deadline - Date.now();
|
|
1331
1206
|
if (remaining <= 0) {
|
|
@@ -1357,71 +1232,6 @@ export function registerSessionContentHandlers(deps) {
|
|
|
1357
1232
|
}
|
|
1358
1233
|
const liveness = sessionNodeManager.getSessionLiveness(agentName, sessionId);
|
|
1359
1234
|
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
1235
|
// M8B F16: a dead session must not return the SAME null timeout as a
|
|
1426
1236
|
// quiet-but-healthy one. The liveness signal (session.liveness.changed → gone,
|
|
1427
1237
|
// tracked per session by the node manager) finally reaches the MCP surface here.
|