@cello-protocol/daemon 0.0.173 → 0.0.175

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.
@@ -118,6 +118,13 @@ const REDIAL_COOLDOWN_MS = 15_000;
118
118
  * Two hours: comfortably longer than any churn worth attacking with, comfortably shorter than
119
119
  * "yesterday's conversation still blocks me".
120
120
  */
121
+ /**
122
+ * DOD-M12B-RESERVATION-RETRY-1 — how many times to re-ask for a refused reservation before saying
123
+ * the agent is undialable and stopping. Bounded because a reservation is scarce and a fleet that
124
+ * retries forever is how a relay is exhausted. With the 5-minute base and doubling, five retries
125
+ * span about 2.5 hours.
126
+ */
127
+ export const SR_RESERVATION_MAX_RETRIES = 5;
121
128
  export const CAP_INTERRUPTED_TTL_MS = Number(process.env["CELLO_CAP_INTERRUPTED_TTL_MS"]) || 2 * 60 * 60 * 1000;
122
129
  /**
123
130
  * DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
@@ -293,6 +300,15 @@ export class SessionNodeManager {
293
300
  #srReservationTimeoutMs;
294
301
  /** DOD-NAT-REACHABILITY-1: watchdog for a SILENTLY lost reservation. */
295
302
  #srWatchdogIntervalMs;
303
+ #srReservationRetryMs;
304
+ /**
305
+ * DOD-M12B-RESERVATION-RETRY-1: per-agent re-attempt state for a receiver the relay refused a
306
+ * reservation to. `attempts` counts RETRIES, not the creation attempt.
307
+ */
308
+ /** The reason the last reservation attempt was refused, per agent — captured at the rejection so
309
+ * the retry and give-up can name a CAUSE instead of only their own exit point. */
310
+ #srLastRejectionReason = new Map();
311
+ #srReservationRetry = new Map();
296
312
  #reservationWatchdog = null;
297
313
  /** DOD-PARK-DRAIN-1: how often the backstop drain rides the watchdog grid — see #parkedDrainBackstopTick. */
298
314
  #parkedDrainBackstopMs;
@@ -540,6 +556,7 @@ export class SessionNodeManager {
540
556
  this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
541
557
  this.#srReservationTimeoutMs = opts.standingReceiverReservationTimeoutMs ?? 15_000;
542
558
  this.#srWatchdogIntervalMs = opts.standingReceiverWatchdogIntervalMs ?? 30_000;
559
+ this.#srReservationRetryMs = opts.standingReceiverReservationRetryMs ?? 5 * 60_000;
543
560
  this.#parkedDrainBackstopMs = opts.parkedDrainBackstopMs ?? 300_000;
544
561
  // REQUIRED, no fallback (INV-9, audit finding). This line used to read
545
562
  // `opts.securityGateway ?? new PassthroughGatewayClient()` — the identical shape as the defect
@@ -1944,6 +1961,31 @@ export class SessionNodeManager {
1944
1961
  return this.#standingReceivers.has(agentName);
1945
1962
  return this.#standingReceivers.size > 0;
1946
1963
  }
1964
+ /**
1965
+ * DOD-M12B-RESERVATION-RETRY-1 — whether a NAT'd peer can actually DIAL this agent.
1966
+ *
1967
+ * `standing_receiver_ready` answers "is there a receiver?", which is true for a plain TCP node
1968
+ * that no relay would give a circuit reservation to. Behind NAT that node is reachable by nobody,
1969
+ * and the difference was visible only in the log — where it was visible 481 times and nobody
1970
+ * acted. `"retrying"` and `"unreachable"` are the states an operator can do something about.
1971
+ *
1972
+ * reserved — holds a circuit reservation; a NAT'd peer can dial it.
1973
+ * retrying — no reservation yet, still re-asking on a backoff.
1974
+ * unreachable — no circuit reservation and the automatic re-attempts are spent, so only peers
1975
+ * that can connect DIRECTLY will get in. It is not permanent: a directory
1976
+ * reconnect carrying a DIFFERENT relay pool re-arms the budget, because a relay we
1977
+ * have never tried is new information.
1978
+ * absent — no receiver at all (the agent is not online).
1979
+ */
1980
+ getStandingReceiverReachability(agentName) {
1981
+ const sr = this.#standingReceivers.get(agentName);
1982
+ if (!sr)
1983
+ return "absent";
1984
+ if (sr.hasReservation && sr.relayPeerId !== undefined)
1985
+ return "reserved";
1986
+ const retry = this.#srReservationRetry.get(agentName);
1987
+ return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
1988
+ }
1947
1989
  /** First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use (gater-open). */
1948
1990
  #anyStandingReceiver() {
1949
1991
  for (const sr of this.#standingReceivers.values())
@@ -3127,6 +3169,8 @@ export class SessionNodeManager {
3127
3169
  }
3128
3170
  })), "standing_receivers", this.#standingReceivers.size);
3129
3171
  this.#standingReceivers.clear();
3172
+ this.#srReservationRetry.clear();
3173
+ this.#srLastRejectionReason.clear();
3130
3174
  // Release the SQLite handle so the DB file is no longer held open after shutdown
3131
3175
  // (review L5). Queries guard on `#db === null` and degrade to empty/null.
3132
3176
  if (this.#db) {
@@ -6794,7 +6838,21 @@ export class SessionNodeManager {
6794
6838
  * for a session handoff that (being unreachable) would never come.
6795
6839
  */
6796
6840
  setDirectoryRelayEndpoints(agentName, endpoints) {
6841
+ // DOD-M12B-RESERVATION-RETRY-1: a DIFFERENT relay pool re-arms the retry budget; the same one
6842
+ // does not. This fires on every signaling connect AND reconnect, so clearing unconditionally
6843
+ // would reset the bound on a short grid and defeat the whole point of having one. But once the
6844
+ // budget is spent, `getStandingReceiverReachability` reports `unreachable` for the rest of the
6845
+ // online episode — including while this path is actively re-attempting against relays we have
6846
+ // never tried. A new relay is new information; a repeat of the same list is not.
6847
+ const previous = this.#directoryRelayEndpoints.get(agentName);
6848
+ const poolChanged = previous === undefined ||
6849
+ previous.length !== endpoints.length ||
6850
+ endpoints.some((e, i) => e.relayPeerId !== previous[i]?.relayPeerId);
6797
6851
  this.#directoryRelayEndpoints.set(agentName, endpoints);
6852
+ if (poolChanged) {
6853
+ this.#srReservationRetry.delete(agentName);
6854
+ this.#srLastRejectionReason.delete(agentName);
6855
+ }
6798
6856
  if (endpoints.length === 0 || this.#shuttingDown)
6799
6857
  return;
6800
6858
  const sr = this.#standingReceivers.get(agentName);
@@ -6919,14 +6977,97 @@ export class SessionNodeManager {
6919
6977
  * already degraded and already loud (reservation.none / reservation.timeout);
6920
6978
  * rebuilding it on a timer would just thrash against relays we know are refusing.
6921
6979
  */
6980
+ /**
6981
+ * DOD-M12B-RESERVATION-RETRY-1 — ask again for a reservation the relay refused.
6982
+ *
6983
+ * The rebuild is the re-attempt: a circuit listener is fixed at node creation, so the only way to
6984
+ * obtain a reservation is to build a new node asking for one.
6985
+ */
6986
+ #retryReservationIfDue(agentName) {
6987
+ const now = Date.now();
6988
+ const state = this.#srReservationRetry.get(agentName)
6989
+ ?? { attempts: 0, nextAt: now + this.#srReservationRetryMs, correlationId: randomUUID() };
6990
+ // The reason the LAST attempt was refused, captured where it is actually known.
6991
+ const lastReason = this.#srLastRejectionReason.get(agentName);
6992
+ if (lastReason !== undefined)
6993
+ state.lastReason = lastReason;
6994
+ if (state.attempts === 0 && !this.#srReservationRetry.has(agentName)) {
6995
+ // First sighting — schedule, do not fire. The creation attempt just happened.
6996
+ this.#srReservationRetry.set(agentName, state);
6997
+ return;
6998
+ }
6999
+ if (now < state.nextAt)
7000
+ return;
7001
+ if (state.attempts >= SR_RESERVATION_MAX_RETRIES) {
7002
+ if (state.attempts === SR_RESERVATION_MAX_RETRIES) {
7003
+ state.attempts += 1; // mark as reported, so this fires exactly once
7004
+ this.#srReservationRetry.set(agentName, state);
7005
+ this.#logger.error("session.standing_receiver.reservation.gave_up", {
7006
+ agentName,
7007
+ attempts: SR_RESERVATION_MAX_RETRIES,
7008
+ correlationId: state.correlationId,
7009
+ // WHY, not just the consequence. Three different problems reach this one message and they
7010
+ // need three different responses: `relay_granted_no_reservation` is relay CAPACITY (and a
7011
+ // trustless-cello problem), `relay_unreachable` is the NETWORK, and
7012
+ // `reservation_did_not_complete_in_time` is LATENCY — and the only one of the three that
7013
+ // can pin a slot it never uses, so its appearance is also the signal that this retry
7014
+ // budget needs tightening.
7015
+ ...(state.lastReason !== undefined ? { lastRejectionReason: state.lastReason } : {}),
7016
+ // "No relay would grant" and "there was no relay to ask" are different facts and lead to
7017
+ // different places — the first at relay capacity, the second at this agent's directory
7018
+ // connection. Without this they are the same sentence.
7019
+ reservationsRequested: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
7020
+ impact: "no relay would grant this agent a circuit reservation, so anyone behind NAT cannot reach or dial it — inbound sessions will only arrive from peers that can connect directly, and everything else falls back to the relay's store-and-forward",
7021
+ });
7022
+ }
7023
+ return;
7024
+ }
7025
+ state.attempts += 1;
7026
+ // The FINAL attempt gets a fixed settle window rather than another doubled wait: at the top of
7027
+ // the ladder that would be 80 minutes of silence after the last thing we did, which is a long
7028
+ // time to tell an operator nothing. Every earlier attempt doubles, which is what keeps a fleet
7029
+ // off a scarce relay.
7030
+ state.nextAt = now + (state.attempts >= SR_RESERVATION_MAX_RETRIES
7031
+ ? this.#srReservationRetryMs
7032
+ : this.#srReservationRetryMs * 2 ** (state.attempts - 1));
7033
+ this.#srReservationRetry.set(agentName, state);
7034
+ this.#logger.warn("session.standing_receiver.reservation.retry", {
7035
+ agentName,
7036
+ attempt: state.attempts,
7037
+ maxAttempts: SR_RESERVATION_MAX_RETRIES,
7038
+ correlationId: state.correlationId,
7039
+ ...(state.lastReason !== undefined ? { lastRejectionReason: state.lastReason } : {}),
7040
+ impact: "this agent currently holds no circuit reservation, so a NAT'd peer cannot dial it",
7041
+ });
7042
+ void this.#rebuildStandingReceiver(agentName);
7043
+ }
6922
7044
  #reservationWatchdogTick() {
6923
7045
  if (this.#shuttingDown)
6924
7046
  return;
6925
7047
  for (const [agentName, sr] of this.#standingReceivers) {
6926
- if (!sr.hasReservation || sr.relayPeerId === undefined)
6927
- continue; // never had one — not a LOSS
6928
7048
  if (!this.#agentsWantingReceiver.has(agentName))
6929
7049
  continue; // agent went offline
7050
+ // DOD-M12B-RESERVATION-RETRY-1 — NEVER HAD ONE IS NOT "NOTHING TO DO".
7051
+ //
7052
+ // This used to `continue` unconditionally, on the grounds that a receiver with no reservation
7053
+ // is "already degraded and already loud". Measured over 17 days: `reservation.none` fired 481
7054
+ // times and `relay.rejected` 2,215 — every one of the latter `relay_granted_no_reservation`,
7055
+ // a relay out of slots completing the handshake and granting nothing. Nothing ever acted on
7056
+ // the noise, and each of those receivers is a plain TCP node with no circuit address: behind
7057
+ // NAT, dialable by NOBODY, for its whole life. That is the silent loss of inbound this file
7058
+ // says three lines below it exists to kill.
7059
+ //
7060
+ // A relay out of slots at boot may have one minutes later, so re-attempt — on a BACKOFF and
7061
+ // BOUNDED, never on this 30-second grid. A reservation is scarce: the relay holds it for its
7062
+ // full TTL even after the client disconnects, and churning attempts across a fleet is how a
7063
+ // relay is exhausted (`#startReceiverNode` records that hazard).
7064
+ if (!sr.hasReservation || sr.relayPeerId === undefined) {
7065
+ this.#retryReservationIfDue(agentName);
7066
+ continue;
7067
+ }
7068
+ // It has one — any earlier retry budget, and the reason the last attempt failed, are stale.
7069
+ this.#srReservationRetry.delete(agentName);
7070
+ this.#srLastRejectionReason.delete(agentName);
6930
7071
  // Watch the CONNECTION to the relay, not the circuit address.
6931
7072
  //
6932
7073
  // Killing the relay does NOT make the /p2p-circuit address disappear: libp2p
@@ -7057,14 +7198,16 @@ export class SessionNodeManager {
7057
7198
  if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
7058
7199
  return candidate;
7059
7200
  }
7201
+ const rejectionReason = outcome === "started"
7202
+ ? "relay_granted_no_reservation"
7203
+ : outcome === "failed"
7204
+ ? "relay_unreachable"
7205
+ : "reservation_did_not_complete_in_time";
7206
+ this.#srLastRejectionReason.set(agentName, rejectionReason);
7060
7207
  this.#logger.warn("session.standing_receiver.relay.rejected", {
7061
7208
  agentName,
7062
7209
  circuitAddr,
7063
- reason: outcome === "started"
7064
- ? "relay_granted_no_reservation"
7065
- : outcome === "failed"
7066
- ? "relay_unreachable"
7067
- : "reservation_did_not_complete_in_time",
7210
+ reason: rejectionReason,
7068
7211
  ...(error !== "" ? { error } : {}),
7069
7212
  correlationId,
7070
7213
  });
@@ -7146,8 +7289,27 @@ export class SessionNodeManager {
7146
7289
  });
7147
7290
  autoNat.emitInitialResult();
7148
7291
  const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
7149
- // The relay we actually reserved with the watchdog watches our connection to it.
7150
- const reservedRelayPeerId = circuitAddrs > 0 ? reservations.addrs[0]?.match(/\/p2p\/([^/]+)\/p2p-circuit/)?.[1] : undefined;
7292
+ // FROM THE ADDRESS THE NODE ACTUALLY HOLDS, not from `reservations.addrs[0]`.
7293
+ //
7294
+ // `#startReceiverNode` tries candidates in order and returns the FIRST that actually grants —
7295
+ // so when candidate 0 refuses (the measured `relay_granted_no_reservation` case) and candidate 1
7296
+ // grants, reading candidate 0's address records a relay we are not connected to. The watchdog
7297
+ // then evaluates `getConnections().some(c => c.peerId === relayPeerId)` against that wrong peer,
7298
+ // finds it false on every tick forever, and rebuilds on the 30-second grid — churning the very
7299
+ // reservations this unit exists to conserve. Dormant while the pool is size 1; the pool is
7300
+ // designed to be larger.
7301
+ // PREFER the held address, FALL BACK to the candidate — strictly better than either alone.
7302
+ // The held address is authoritative about which relay actually granted, but it is libp2p's
7303
+ // string, not ours: if a transport ever reports the circuit address without the relay's peer id
7304
+ // in `/p2p/<id>/p2p-circuit` form, reading only it would yield UNDEFINED, and an undefined
7305
+ // relayPeerId makes the watchdog treat a perfectly healthy reservation as absent and rebuild it.
7306
+ // That would be a regression on the single-relay case that works today. The candidate string is
7307
+ // ours and always carries the id, so it is the safe floor.
7308
+ const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
7309
+ const CIRCUIT_RELAY_ID = /\/p2p\/([^/]+)\/p2p-circuit/;
7310
+ const reservedRelayPeerId = circuitAddrs > 0
7311
+ ? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
7312
+ : undefined;
7151
7313
  this.#standingReceivers.set(agentName, {
7152
7314
  node,
7153
7315
  gater,
@@ -7205,6 +7367,14 @@ export class SessionNodeManager {
7205
7367
  // gets a fresh set on its next connect — holding the old ones would keep a
7206
7368
  // retired agent's relay list alive for the daemon's lifetime.
7207
7369
  this.#directoryRelayEndpoints.delete(agentName);
7370
+ // DOD-M12B-RESERVATION-RETRY-1: and the retry budget with them. A spent budget that survives an
7371
+ // offline→online cycle is a LATCH: the new receiver gets no reservation, the watchdog finds
7372
+ // `attempts` already past the cap, and returns having done nothing — no retry and not even a
7373
+ // second give-up. The agent is undialable and the machinery is inert and mute until a daemon
7374
+ // restart. Same reason the line above exists ("holding the old ones would keep a retired agent's
7375
+ // relay list alive for the daemon's lifetime").
7376
+ this.#srReservationRetry.delete(agentName);
7377
+ this.#srLastRejectionReason.delete(agentName);
7208
7378
  const sr = this.#standingReceivers.get(agentName);
7209
7379
  if (!sr) {
7210
7380
  // L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no