@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.
@@ -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
- #responderSealSubmitted = new Set();
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
- return { ok: false, reason: "responder_seal_already_submitted" };
1721
- }
1722
- this.#responderSealSubmitted.add(sealKey);
1723
- const finalRootHex = this.getSessionTreeRootHex(agentName, sessionId);
1724
- const sealPayload = encodeSealPayload({
1725
- session_id: entry.relaySessionIdBytes,
1726
- final_root: new Uint8Array(Buffer.from(finalRootHex, "hex")),
1727
- close_timestamp: Date.now(),
1728
- attestation: "PENDING",
1729
- });
1730
- // content_hash = SHA-256(0x02 || seal_payload) — the ctrl leaf kind byte is 0x02.
1731
- const contentHash = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(sealPayload).digest());
1732
- const result = await entry.relayClient.submitLeaf(entry.node, entry.relaySessionIdBytes, contentHash, LEAF_KIND_CTRL);
1733
- if (!result.ok) {
1734
- // Clear the idempotency mark so a genuine retry (agent close / reconnect) can proceed (DB-001).
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
- this.#logger.warn("session.seal.leaf.submit.failed", { sessionId, reason: result.reason, correlationId });
1737
- return { ok: false, reason: result.reason };
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. A
2539
- * create failure logs + leaves no entry; the next consume-site ensure() call retries on demand.
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
- const sessionId = `standing_receiver_${randomUUID()}`;
2551
- const gater = new SessionConnectionGater({
2552
- sessionId,
2553
- allowedPeerId: null, // open counterparty unknown at creation time
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
- catch { /* best-effort */ }
2577
- return;
2578
- }
2579
- // L1: the agent may have gone offline (cello_stop_agent → removeStandingReceiverForAgent)
2580
- // while this ensure was parked on start(). Removal found no map entry to delete, so the
2581
- // tombstone is how we learn of it — tear the fresh node down rather than install an SR for
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
- catch { /* best-effort */ }
2589
- return;
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
- // CELLO-M7-TRANSPORT-001: wrap in a NodeAutoNatService so its dialability drives session-
2592
- // address advertisement and the transport.autonat.* events fire.
2593
- const autoNat = new NodeAutoNatService({
2594
- node,
2595
- logger: this.#logger,
2596
- nodeType: "standing_receiver",
2597
- probers: this.#autoNatProbers(),
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
- autoNat.emitInitialResult();
2600
- this.#standingReceivers.set(agentName, { node, gater, autoNat });
2601
- this.#logger.info("session.node.created", {
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
- sessionPeerId: node.getPeerId(),
2712
+ error,
2605
2713
  correlationId,
2606
2714
  });
2715
+ return { outcome: "failed", error };
2607
2716
  }
2608
- finally {
2609
- this.#standingReceiverCreating.delete(agentName);
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