@cello-protocol/daemon 0.0.19 → 0.0.21
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/daemon.d.ts.map +1 -1
- package/dist/daemon.js +180 -35
- package/dist/daemon.js.map +1 -1
- package/dist/session-ceremony.d.ts +26 -13
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +36 -18
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-node-manager.d.ts +25 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +236 -87
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -104,6 +104,13 @@ export class SessionNodeManager {
|
|
|
104
104
|
// prevents two concurrent ensure() calls from building two nodes for the same agent.
|
|
105
105
|
#standingReceivers = new Map();
|
|
106
106
|
#standingReceiverCreating = new Set();
|
|
107
|
+
// M8B F14: agents that SHOULD have a standing receiver — marked by
|
|
108
|
+
// ensureStandingReceiverForAgent (cello_start_agent / the inbound accept path) and
|
|
109
|
+
// unmarked by removeStandingReceiverForAgent (cello_stop_agent). Consulted by the
|
|
110
|
+
// teardown re-arm so a session-node teardown never re-arms an offline agent.
|
|
111
|
+
#agentsWantingReceiver = new Set();
|
|
112
|
+
// M8B F14: standing-receiver create retry schedule (see constructor opts).
|
|
113
|
+
#srRetryDelaysMs;
|
|
107
114
|
// Agents whose removeStandingReceiverForAgent ran while an #ensureStandingReceiver for them was
|
|
108
115
|
// in flight (parked on createNode/start, so the map had no entry to delete yet). The in-flight
|
|
109
116
|
// ensure checks this after start() and tears the fresh node down instead of installing an SR for
|
|
@@ -121,6 +128,14 @@ export class SessionNodeManager {
|
|
|
121
128
|
// DAEMON-004: per-session FIFO buffer of verified received content awaiting
|
|
122
129
|
// cello_receive. Populated by ingestReceivedContent / the content stream handler.
|
|
123
130
|
#receivedContent = new Map();
|
|
131
|
+
// F1-b: a terminal answer for a sealed session, set at seal teardown BEFORE the
|
|
132
|
+
// received-content buffer is evicted. A blocking cello_receive waiting when the seal
|
|
133
|
+
// fires returns this instead of hanging or 404ing; `unreadCount` tells the caller how
|
|
134
|
+
// many buffered messages were dropped (still durable — read via cello_get_transcript).
|
|
135
|
+
// This map is deliberately NOT cleared by #evictSessionCaches (it must outlive teardown);
|
|
136
|
+
// it holds one tiny entry per sealed session for the daemon's lifetime and is cleared on
|
|
137
|
+
// restart. Idempotent: a sealed session always answers "sealed" to a receive.
|
|
138
|
+
#sessionTerminal = new Map();
|
|
124
139
|
// CELLO-M7-TRANSPORT-001: the directory-node multiaddrs serving as AutoNAT
|
|
125
140
|
// probers (SI-002). Empty () => [] when the directory is in 'reconnecting'
|
|
126
141
|
// state — AutoNAT cannot run and dialability stays the conservative default.
|
|
@@ -156,7 +171,10 @@ export class SessionNodeManager {
|
|
|
156
171
|
// M7-UPGRADE-002: sessions for which B has already submitted its responder SEAL leaf (via
|
|
157
172
|
// auto-ack OR cello_close_session). Idempotency guard — A's SEAL ctrl leaf may be delivered
|
|
158
173
|
// more than once (and the relay echoes leaves), so auto-ack fires AT MOST ONCE per session.
|
|
159
|
-
|
|
174
|
+
// M8B FINDING-1: the value carries the first successful submit's reportedRootHex/sequenceNumber
|
|
175
|
+
// (null while the submit is still in flight), so a RETRY close can escalate to a unilateral
|
|
176
|
+
// seal with the original reported root instead of deadlocking on seal_pending_bilateral.
|
|
177
|
+
#responderSealSubmitted = new Map();
|
|
160
178
|
// M7-SESSION-001 (M-1 PUSH): optional callback fired when a session changes
|
|
161
179
|
// state, so the composition root can dispatch a session_state_changed
|
|
162
180
|
// notification to live MCP clients. Injected via a setter AFTER construction
|
|
@@ -195,6 +213,7 @@ export class SessionNodeManager {
|
|
|
195
213
|
this.#contentTtfMs = opts.contentTtfMs;
|
|
196
214
|
}
|
|
197
215
|
this.#autoNatProbers = opts.autoNatProbers ?? (() => []);
|
|
216
|
+
this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
|
|
198
217
|
}
|
|
199
218
|
/**
|
|
200
219
|
* CELLO-M7-MSG-001: wire the durable-backstop side effects of the awaiting-ACK
|
|
@@ -967,6 +986,18 @@ export class SessionNodeManager {
|
|
|
967
986
|
* Status written to SQLite.
|
|
968
987
|
*/
|
|
969
988
|
async destroySessionNode(agentName, sessionId, reason) {
|
|
989
|
+
// F1-b: record the terminal answer BEFORE the caches are evicted (and before the
|
|
990
|
+
// early-return below), so a blocking cello_receive that was waiting when the seal fired
|
|
991
|
+
// returns "session_sealed" (with how many buffered messages it never read) instead of
|
|
992
|
+
// hanging to timeout or 404ing. Set even if the node was already retired — a late receive
|
|
993
|
+
// on a sealed session should always learn it is sealed. The receiver (the party that races
|
|
994
|
+
// the seal on cello_receive) is torn down through THIS path; the closer goes through
|
|
995
|
+
// retireSessionNode and is not blocking on receive.
|
|
996
|
+
if (reason === "sealed") {
|
|
997
|
+
const tkey = this.#k(agentName, sessionId);
|
|
998
|
+
const unreadCount = this.#receivedContent.get(tkey)?.length ?? 0;
|
|
999
|
+
this.#sessionTerminal.set(tkey, { type: "sealed", unreadCount });
|
|
1000
|
+
}
|
|
970
1001
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
971
1002
|
if (!entry)
|
|
972
1003
|
return;
|
|
@@ -1004,6 +1035,26 @@ export class SessionNodeManager {
|
|
|
1004
1035
|
agentName: entry.agentName,
|
|
1005
1036
|
reason,
|
|
1006
1037
|
});
|
|
1038
|
+
// M8B F14 (fix 1): the torn-down node has just released its port — on a fixed-port
|
|
1039
|
+
// deployment this is the FIRST moment a previously-failed re-arm can succeed. Re-arm
|
|
1040
|
+
// the standing receiver for an online agent that has none (async, never awaited).
|
|
1041
|
+
this.#rearmAfterTeardown(agentName);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* M8B F14: re-arm an online agent's standing receiver after a session-node teardown
|
|
1045
|
+
* freed resources (notably the fixed port). No-op when the agent is offline, already
|
|
1046
|
+
* has a receiver, or one is being created. The re-arm is a NEW async flow — it mints
|
|
1047
|
+
* its own correlationId (via the ensure default) rather than inheriting the torn-down
|
|
1048
|
+
* session's.
|
|
1049
|
+
*/
|
|
1050
|
+
#rearmAfterTeardown(agentName) {
|
|
1051
|
+
if (this.#shuttingDown)
|
|
1052
|
+
return;
|
|
1053
|
+
if (!this.#agentsWantingReceiver.has(agentName))
|
|
1054
|
+
return;
|
|
1055
|
+
if (this.#standingReceivers.has(agentName) || this.#standingReceiverCreating.has(agentName))
|
|
1056
|
+
return;
|
|
1057
|
+
void this.#ensureStandingReceiver(agentName);
|
|
1007
1058
|
}
|
|
1008
1059
|
/**
|
|
1009
1060
|
* round-2 finding #5: retire a session's live libp2p node WITHOUT changing its
|
|
@@ -1038,10 +1089,19 @@ export class SessionNodeManager {
|
|
|
1038
1089
|
agentName: entry.agentName,
|
|
1039
1090
|
reason: "sealing",
|
|
1040
1091
|
});
|
|
1092
|
+
// M8B F14 (fix 1): same re-arm point as destroySessionNode — the retired node freed its port.
|
|
1093
|
+
this.#rearmAfterTeardown(agentName);
|
|
1041
1094
|
}
|
|
1042
1095
|
/** Drop the in-memory tree + received-content caches for a torn-down session (DOD-LOOP-1: per (agent, session)). */
|
|
1043
1096
|
#evictSessionCaches(agentName, sessionId) {
|
|
1044
1097
|
const key = this.#k(agentName, sessionId);
|
|
1098
|
+
// F1-c: dropping a NON-empty received-content buffer means deliverable plaintext the app
|
|
1099
|
+
// never read live is being discarded (still durable in the transcript). Make that silent
|
|
1100
|
+
// drop diagnosable — it fires on both the destroy (sealed) and retire (sealing) paths.
|
|
1101
|
+
const unreadCount = this.#receivedContent.get(key)?.length ?? 0;
|
|
1102
|
+
if (unreadCount > 0) {
|
|
1103
|
+
this.#logger.info("session.receive.buffer.evicted", { sessionId, agentName, unreadCount });
|
|
1104
|
+
}
|
|
1045
1105
|
this.#trees.delete(key);
|
|
1046
1106
|
this.#receivedContent.delete(key);
|
|
1047
1107
|
// CELLO-M7-MSG-001: cancel any armed TTF timers so a torn-down session never
|
|
@@ -1318,6 +1378,10 @@ export class SessionNodeManager {
|
|
|
1318
1378
|
agentName,
|
|
1319
1379
|
reason: "interrupted",
|
|
1320
1380
|
});
|
|
1381
|
+
// M8B F14 (fix 1): the relay-detected interruption is the THIRD teardown path that
|
|
1382
|
+
// frees the fixed port — it must re-arm too, or a session ending on a network blip
|
|
1383
|
+
// leaves the agent deaf again (review finding on the F14 fix).
|
|
1384
|
+
this.#rearmAfterTeardown(agentName);
|
|
1321
1385
|
}
|
|
1322
1386
|
this.#logger.warn("session.interrupted.detected", {
|
|
1323
1387
|
sessionId,
|
|
@@ -1717,40 +1781,63 @@ export class SessionNodeManager {
|
|
|
1717
1781
|
// two near-simultaneous triggers (e.g. B's own close racing A's delivered SEAL ctrl leaf) cannot
|
|
1718
1782
|
// both submit. Cleared below on a relay submit failure so a genuine retry can proceed.
|
|
1719
1783
|
if (this.#responderSealSubmitted.has(sealKey)) {
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1784
|
+
// M8B FINDING-1: carry the FIRST submit's reported root/sequence so a retry close can
|
|
1785
|
+
// still escalate to a unilateral seal. A null value means that submit is still in
|
|
1786
|
+
// flight — return the bare reason and let the caller fall back to the pending path.
|
|
1787
|
+
const prior = this.#responderSealSubmitted.get(sealKey);
|
|
1788
|
+
return prior
|
|
1789
|
+
? {
|
|
1790
|
+
ok: false,
|
|
1791
|
+
reason: "responder_seal_already_submitted",
|
|
1792
|
+
reportedRootHex: prior.reportedRootHex,
|
|
1793
|
+
sequenceNumber: prior.sequenceNumber,
|
|
1794
|
+
}
|
|
1795
|
+
: { ok: false, reason: "responder_seal_already_submitted" };
|
|
1796
|
+
}
|
|
1797
|
+
this.#responderSealSubmitted.set(sealKey, null);
|
|
1798
|
+
// A throw anywhere before the mark is finalized would strand the null in-flight marker
|
|
1799
|
+
// and lock every future close out of escalation (a FINDING-1-shaped deadlock via a
|
|
1800
|
+
// different trigger) — clear the mark on any unexpected exception.
|
|
1801
|
+
try {
|
|
1802
|
+
const finalRootHex = this.getSessionTreeRootHex(agentName, sessionId);
|
|
1803
|
+
const sealPayload = encodeSealPayload({
|
|
1804
|
+
session_id: entry.relaySessionIdBytes,
|
|
1805
|
+
final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
|
|
1806
|
+
close_timestamp: Date.now(),
|
|
1807
|
+
attestation: "PENDING",
|
|
1808
|
+
});
|
|
1809
|
+
// content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
|
|
1810
|
+
const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
|
|
1811
|
+
const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL);
|
|
1812
|
+
if (!result.ok) {
|
|
1813
|
+
// Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
|
|
1814
|
+
this.#responderSealSubmitted.delete(sealKey);
|
|
1815
|
+
this.#logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
|
|
1816
|
+
return { ok: false, reason: result.reason };
|
|
1817
|
+
}
|
|
1818
|
+
// SESSION-002: the reported_root for a unilateral seal is the content-hash root the
|
|
1819
|
+
// local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
|
|
1820
|
+
// rebuilds from the relay's content-hash chain (the relay records the identical
|
|
1821
|
+
// content_hash for this ctrl leaf). Computed without mutating the durable tree /
|
|
1822
|
+
// message_count, so the bilateral + interrupted seal paths are unaffected.
|
|
1823
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
1824
|
+
const reportedRootHex = this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
|
|
1825
|
+
// M8B FINDING-1: durably associate the submit's escalation values with the idempotency
|
|
1826
|
+
// mark, so any LATER close call can retrieve them via the already-submitted result.
|
|
1827
|
+
this.#responderSealSubmitted.set(sealKey, { reportedRootHex, sequenceNumber: result.sequence_number });
|
|
1828
|
+
this.#logger.info("session.seal.leaf.submitted", {
|
|
1829
|
+
sessionId,
|
|
1830
|
+
sequenceNumber: result.sequence_number,
|
|
1831
|
+
correlationId,
|
|
1832
|
+
});
|
|
1833
|
+
// M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
|
|
1834
|
+
// the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
|
|
1835
|
+
return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
|
|
1836
|
+
}
|
|
1837
|
+
catch (err) {
|
|
1735
1838
|
this.#responderSealSubmitted.delete(sealKey);
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
}
|
|
1739
|
-
// SESSION-002: the reported_root for a unilateral seal is the content-hash root the
|
|
1740
|
-
// local tree WOULD have with this SEAL ctrl leaf appended — the same root the directory
|
|
1741
|
-
// rebuilds from the relay's content-hash chain (the relay records the identical
|
|
1742
|
-
// content_hash for this ctrl leaf). Computed without mutating the durable tree /
|
|
1743
|
-
// message_count, so the bilateral + interrupted seal paths are unaffected.
|
|
1744
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
1745
|
-
const reportedRootHex = this.getSessionTree(agentName, sessionId).rootWithAppendedHex(contentHashHex);
|
|
1746
|
-
this.#logger.info("session.seal.leaf.submitted", {
|
|
1747
|
-
sessionId,
|
|
1748
|
-
sequenceNumber: result.sequence_number,
|
|
1749
|
-
correlationId,
|
|
1750
|
-
});
|
|
1751
|
-
// M7-UPGRADE-002: #responderSealSubmitted was set synchronously at the top of this method —
|
|
1752
|
-
// the guard now blocks any second submit (auto-ack OR a redelivered counterparty SEAL ctrl leaf).
|
|
1753
|
-
return { ok: true, sequenceNumber: result.sequence_number, reportedRootHex };
|
|
1839
|
+
throw err;
|
|
1840
|
+
}
|
|
1754
1841
|
}
|
|
1755
1842
|
/**
|
|
1756
1843
|
* CELLO-M7-UPGRADE-001 (DOD-UP-1): readiness of a session for B to RATIFY a unilateral seal
|
|
@@ -2087,6 +2174,26 @@ export class SessionNodeManager {
|
|
|
2087
2174
|
return null;
|
|
2088
2175
|
return buf.shift() ?? null;
|
|
2089
2176
|
}
|
|
2177
|
+
/**
|
|
2178
|
+
* F1-b: the terminal answer for a session that sealed while a blocking receive was (or could be)
|
|
2179
|
+
* waiting. Idempotent — a sealed session always answers "sealed" to a receive. Null while active.
|
|
2180
|
+
*/
|
|
2181
|
+
peekTerminalMarker(agentName, sessionId) {
|
|
2182
|
+
return this.#sessionTerminal.get(this.#k(agentName, sessionId)) ?? null;
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* F1-b: the durable sealed root hex for a session (written by recordSealCertificate on the
|
|
2186
|
+
* bilateral path), or null if not recorded. Lets cello_receive echo the sealed root in its
|
|
2187
|
+
* terminal answer without threading it through destroySessionNode.
|
|
2188
|
+
*/
|
|
2189
|
+
getSealedRootHex(agentName, sessionId) {
|
|
2190
|
+
if (!this.#db)
|
|
2191
|
+
return null;
|
|
2192
|
+
const row = this.#db
|
|
2193
|
+
.prepare("SELECT sealed_root_hex FROM sessions WHERE agent_name = ? AND session_id = ?")
|
|
2194
|
+
.get(agentName, sessionId);
|
|
2195
|
+
return row?.sealed_root_hex ?? null;
|
|
2196
|
+
}
|
|
2090
2197
|
// ─── CELLO-M7-MSG-001: delivery ACK / TTF tracking (send side) ──────────────
|
|
2091
2198
|
/**
|
|
2092
2199
|
* Arm awaiting-ACK tracking for a just-sent content frame (AC-001/AC-003). Records
|
|
@@ -2535,8 +2642,15 @@ export class SessionNodeManager {
|
|
|
2535
2642
|
* DOD-LOOP-1: ensure the given agent has a standing receiver node (idempotent). Created when an
|
|
2536
2643
|
* agent comes online (cello_start_agent) and replaced after it is handed off to a session. The
|
|
2537
2644
|
* `#standingReceiverCreating` guard prevents two concurrent ensure() calls (e.g. the
|
|
2538
|
-
* cello_start_agent hook racing a consume-site retry) from building two nodes for one agent.
|
|
2539
|
-
*
|
|
2645
|
+
* cello_start_agent hook racing a consume-site retry) from building two nodes for one agent.
|
|
2646
|
+
*
|
|
2647
|
+
* M8B F14: a create failure no longer strands the agent deaf. Each ensure runs a BOUNDED
|
|
2648
|
+
* retry loop (`standingReceiverRetryDelaysMs`, default 1s/5s/15s) — covering the fixed-port
|
|
2649
|
+
* race where the consumed receiver still holds the port until its session node is torn down —
|
|
2650
|
+
* and when every attempt fails, fires the alarm-worthy `session.standing_receiver.dead`
|
|
2651
|
+
* (error level), distinct from the per-attempt `session.node.create.failed`. Re-arm is also
|
|
2652
|
+
* kicked from destroySessionNode/retireSessionNode (the moment the port frees) and from the
|
|
2653
|
+
* inbound accept path (ensure on demand), so one failure can never leave the agent deaf forever.
|
|
2540
2654
|
*/
|
|
2541
2655
|
async #ensureStandingReceiver(agentName, correlationId = randomUUID()) {
|
|
2542
2656
|
if (this.#standingReceivers.has(agentName) || this.#standingReceiverCreating.has(agentName))
|
|
@@ -2547,76 +2661,111 @@ export class SessionNodeManager {
|
|
|
2547
2661
|
this.#standingReceiverRemoving.delete(agentName);
|
|
2548
2662
|
this.#standingReceiverCreating.add(agentName);
|
|
2549
2663
|
try {
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
logger: this.#logger,
|
|
2555
|
-
});
|
|
2556
|
-
let node;
|
|
2557
|
-
try {
|
|
2558
|
-
node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "standing_receiver" });
|
|
2559
|
-
await node.start();
|
|
2560
|
-
}
|
|
2561
|
-
catch (err) {
|
|
2562
|
-
this.#logger.error("session.node.create.failed", {
|
|
2563
|
-
sessionId,
|
|
2564
|
-
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
2565
|
-
error: err instanceof Error ? err.message : String(err),
|
|
2566
|
-
correlationId,
|
|
2567
|
-
});
|
|
2568
|
-
return; // not ready — callers check getStandingReceiverReady / retry via ensure on demand
|
|
2569
|
-
}
|
|
2570
|
-
// M2: gracefulShutdown may have begun while this node was starting (ensure runs un-awaited).
|
|
2571
|
-
// Don't install an orphan bound to a TCP port — stop it and bail.
|
|
2572
|
-
if (this.#shuttingDown) {
|
|
2573
|
-
try {
|
|
2574
|
-
await node.stop();
|
|
2664
|
+
let lastError = "";
|
|
2665
|
+
for (let attempt = 0; attempt <= this.#srRetryDelaysMs.length; attempt++) {
|
|
2666
|
+
if (attempt > 0) {
|
|
2667
|
+
await new Promise((r) => setTimeout(r, this.#srRetryDelaysMs[attempt - 1]));
|
|
2575
2668
|
}
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
// an offline agent.
|
|
2583
|
-
if (this.#standingReceiverRemoving.has(agentName)) {
|
|
2584
|
-
this.#standingReceiverRemoving.delete(agentName);
|
|
2585
|
-
try {
|
|
2586
|
-
await node.stop();
|
|
2669
|
+
if (this.#shuttingDown)
|
|
2670
|
+
return;
|
|
2671
|
+
// L1 tombstone: the agent went offline while we were creating / backing off.
|
|
2672
|
+
if (this.#standingReceiverRemoving.has(agentName)) {
|
|
2673
|
+
this.#standingReceiverRemoving.delete(agentName);
|
|
2674
|
+
return;
|
|
2587
2675
|
}
|
|
2588
|
-
|
|
2589
|
-
|
|
2676
|
+
const result = await this.#tryCreateStandingReceiver(agentName, correlationId);
|
|
2677
|
+
if (result.outcome !== "failed")
|
|
2678
|
+
return; // installed, or cleanly aborted (shutdown/offline)
|
|
2679
|
+
lastError = result.error;
|
|
2590
2680
|
}
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2681
|
+
// M8B F14 (fix 4): an agent that WANTS a receiver has none after every attempt — the
|
|
2682
|
+
// deaf-agent state. Fail LOUD so it is alarm-visible instead of a quiet degradation.
|
|
2683
|
+
this.#logger.error("session.standing_receiver.dead", {
|
|
2684
|
+
agentName,
|
|
2685
|
+
reason: lastError,
|
|
2686
|
+
attempts: this.#srRetryDelaysMs.length + 1,
|
|
2687
|
+
correlationId,
|
|
2598
2688
|
});
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
this.#
|
|
2689
|
+
}
|
|
2690
|
+
finally {
|
|
2691
|
+
this.#standingReceiverCreating.delete(agentName);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
/** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
|
|
2695
|
+
async #tryCreateStandingReceiver(agentName, correlationId) {
|
|
2696
|
+
const sessionId = `standing_receiver_${randomUUID()}`;
|
|
2697
|
+
const gater = new SessionConnectionGater({
|
|
2698
|
+
sessionId,
|
|
2699
|
+
allowedPeerId: null, // open — counterparty unknown at creation time
|
|
2700
|
+
logger: this.#logger,
|
|
2701
|
+
});
|
|
2702
|
+
let node;
|
|
2703
|
+
try {
|
|
2704
|
+
node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "standing_receiver" });
|
|
2705
|
+
await node.start();
|
|
2706
|
+
}
|
|
2707
|
+
catch (err) {
|
|
2708
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2709
|
+
this.#logger.error("session.node.create.failed", {
|
|
2602
2710
|
sessionId,
|
|
2603
2711
|
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
2604
|
-
|
|
2712
|
+
error,
|
|
2605
2713
|
correlationId,
|
|
2606
2714
|
});
|
|
2715
|
+
return { outcome: "failed", error };
|
|
2607
2716
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2717
|
+
// M2: gracefulShutdown may have begun while this node was starting (ensure runs un-awaited).
|
|
2718
|
+
// Don't install an orphan bound to a TCP port — stop it and bail.
|
|
2719
|
+
if (this.#shuttingDown) {
|
|
2720
|
+
try {
|
|
2721
|
+
await node.stop();
|
|
2722
|
+
}
|
|
2723
|
+
catch { /* best-effort */ }
|
|
2724
|
+
return { outcome: "aborted" };
|
|
2725
|
+
}
|
|
2726
|
+
// L1: the agent may have gone offline (cello_stop_agent → removeStandingReceiverForAgent)
|
|
2727
|
+
// while this ensure was parked on start(). Removal found no map entry to delete, so the
|
|
2728
|
+
// tombstone is how we learn of it — tear the fresh node down rather than install an SR for
|
|
2729
|
+
// an offline agent.
|
|
2730
|
+
if (this.#standingReceiverRemoving.has(agentName)) {
|
|
2731
|
+
this.#standingReceiverRemoving.delete(agentName);
|
|
2732
|
+
try {
|
|
2733
|
+
await node.stop();
|
|
2734
|
+
}
|
|
2735
|
+
catch { /* best-effort */ }
|
|
2736
|
+
return { outcome: "aborted" };
|
|
2610
2737
|
}
|
|
2738
|
+
// CELLO-M7-TRANSPORT-001: wrap in a NodeAutoNatService so its dialability drives session-
|
|
2739
|
+
// address advertisement and the transport.autonat.* events fire.
|
|
2740
|
+
const autoNat = new NodeAutoNatService({
|
|
2741
|
+
node,
|
|
2742
|
+
logger: this.#logger,
|
|
2743
|
+
nodeType: "standing_receiver",
|
|
2744
|
+
probers: this.#autoNatProbers(),
|
|
2745
|
+
});
|
|
2746
|
+
autoNat.emitInitialResult();
|
|
2747
|
+
this.#standingReceivers.set(agentName, { node, gater, autoNat });
|
|
2748
|
+
this.#logger.info("session.node.created", {
|
|
2749
|
+
sessionId,
|
|
2750
|
+
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
2751
|
+
sessionPeerId: node.getPeerId(),
|
|
2752
|
+
correlationId,
|
|
2753
|
+
});
|
|
2754
|
+
return { outcome: "installed" };
|
|
2611
2755
|
}
|
|
2612
2756
|
/**
|
|
2613
2757
|
* DOD-LOOP-1: public hook for the composition root to create an agent's standing receiver when
|
|
2614
2758
|
* the agent comes online (cello_start_agent), and to tear it down when it goes offline.
|
|
2759
|
+
* M8B F14: also called from the inbound accept path (ensure on demand). Marks the agent as
|
|
2760
|
+
* WANTING a receiver, which arms the teardown re-arm in destroySessionNode/retireSessionNode.
|
|
2615
2761
|
*/
|
|
2616
2762
|
async ensureStandingReceiverForAgent(agentName) {
|
|
2763
|
+
this.#agentsWantingReceiver.add(agentName);
|
|
2617
2764
|
await this.#ensureStandingReceiver(agentName);
|
|
2618
2765
|
}
|
|
2619
2766
|
async removeStandingReceiverForAgent(agentName) {
|
|
2767
|
+
// M8B F14: the agent no longer wants a receiver — disarm the teardown re-arm.
|
|
2768
|
+
this.#agentsWantingReceiver.delete(agentName);
|
|
2620
2769
|
const sr = this.#standingReceivers.get(agentName);
|
|
2621
2770
|
if (!sr) {
|
|
2622
2771
|
// L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no
|