@cello-protocol/daemon 0.0.170 → 0.0.172

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.js CHANGED
@@ -29,6 +29,7 @@ import { mkdir } from "node:fs/promises";
29
29
  import { randomUUID } from "node:crypto";
30
30
  import { dirname, join } from "node:path";
31
31
  import { loadAgents } from "./agent-loader.js";
32
+ import { RestartSealResolver } from "./restart-seal-resolver.js";
32
33
  import { acquireLock, removeLockIfOwned } from "./lock-file.js";
33
34
  import { acquireSingletonLock } from "./singleton-lock.js";
34
35
  import { createIpcServer } from "./ipc-server.js";
@@ -276,6 +277,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
276
277
  // classification (INV-TYPE-CARRY). Absent pubkey = polling disabled, all types unclassified.
277
278
  const typeRegistry = new TypeRegistry();
278
279
  let stopRegistryPoll;
280
+ // DOD-M12B-RESTART-SEAL-1: assigned as the last act of boot, stopped in stop().
281
+ let restartSealResolver;
279
282
  if (config.registryPubkey && config.registryPollScheduler) {
280
283
  const registryVersionStore = new DbRegistryVersionStore(sessionNodeManager.getDb(), logger);
281
284
  stopRegistryPoll = startRegistryPoll({
@@ -1904,6 +1907,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
1904
1907
  liveness: sessionNodeManager.getSessionLiveness(row.agent_name, row.session_id),
1905
1908
  sessionName: row.session_name ?? null, // DOD-SESSION-NAME-1 (AC-A11)
1906
1909
  sealReadiness: probeSealReadiness(row.agent_name, row.session_id),
1910
+ // DOD-M12B-CLOSE-SILENT-WAIT-1: both flows, because either can be the one blocking the
1911
+ // operator's command — `pendingSealWaiters` is the active close, `sealInterruptedInProgress`
1912
+ // the interrupted one.
1913
+ sealing: pendingSealWaiters.has(sealKey(row.agent_name, row.session_id)) ||
1914
+ sealInterruptedInProgress.has(sealKey(row.agent_name, row.session_id)),
1907
1915
  };
1908
1916
  });
1909
1917
  }
@@ -3998,6 +4006,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
3998
4006
  if (config.registryPollScheduler) {
3999
4007
  config.registryPollScheduler.cancel();
4000
4008
  }
4009
+ // DOD-M12B-RESTART-SEAL-1: stop opening directory ceremonies. A seal is the most outbound thing
4010
+ // this daemon does, and DOD-M12B-SHUTDOWN-1's rule is that a shutdown which keeps starting new
4011
+ // outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
4012
+ // this is "stop making new work", not "tear down transports".
4013
+ await restartSealResolver?.stop();
4001
4014
  logger.info("daemon.stopped", { pid: process.pid, reason });
4002
4015
  // DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
4003
4016
  // exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never
@@ -4096,6 +4109,57 @@ async function startDaemonHoldingLock(config, singletonLock) {
4096
4109
  // M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
4097
4110
  // prior run, without waiting for any agent to come online or any client to attach.
4098
4111
  startTelegramPollerIfConfigured();
4112
+ // DOD-M12B-RESTART-SEAL-1: seal the sessions the LAST shutdown orphaned.
4113
+ //
4114
+ // 114 of 118 interrupted sessions on one operator's machine were flipped by our own shutdown
4115
+ // sweep and then sat there, because nothing has ever moved a session out of `interrupted`. Their
4116
+ // only exit was a force-abandon, which forfeits the notarized receipt.
4117
+ //
4118
+ // Started last, like the Telegram poller, and resolved LAZILY out of the live `handlers` map: the
4119
+ // close handler is registered far earlier, but reading it inside the callback is what lets a test
4120
+ // swap it after boot and prove this wiring exists rather than assume it.
4121
+ restartSealResolver = new RestartSealResolver({
4122
+ logger,
4123
+ listRestartOrphans: () => sessionNodeManager.listRestartOrphanedSessions(),
4124
+ ...(config.restartSealInitialDelayMs !== undefined
4125
+ ? { initialDelayMs: config.restartSealInitialDelayMs }
4126
+ : {}),
4127
+ ...(config.restartSealStaggerMs !== undefined ? { staggerMs: config.restartSealStaggerMs } : {}),
4128
+ sealSession: async (agentName, sessionId) => {
4129
+ const close = handlers.get("cello_close_session");
4130
+ if (!close)
4131
+ return { ok: false, reason: "close_handler_missing" };
4132
+ const res = (await close({ session_id: sessionId, agent: agentName }, `restart-seal-${sessionId}`));
4133
+ // SUCCESS IS A RECEIPT, NOT AN `ok`. The interrupted close answers `ok: true` for a bilateral
4134
+ // COMMITMENT that nobody has notarized — `seal_interrupted_pending`, the status this resolver
4135
+ // exists to stop producing. Reading `ok` as success logged "resolved" for 137 sessions that
4136
+ // got no receipt at all. `sealed_root` is present only when a notarization actually happened.
4137
+ if (typeof res?.["sealed_root"] === "string" && res["sealed_root"].length > 0) {
4138
+ return { ok: true };
4139
+ }
4140
+ // Everything the close computed about WHY, carried instead of collapsed. `reason` is an exit
4141
+ // point — `seal_interrupted_rejected_by_counterparty` alone stands for six distinct causes,
4142
+ // and the close already put the discriminating detail on the response.
4143
+ const detail = {};
4144
+ for (const k of ["rejection_reason", "your_leaf_count", "their_leaf_count", "diverging_leaf_index",
4145
+ "seal_pending_reason", "seal_receipt", "missing_leaves", "held_messages", "status"]) {
4146
+ if (res?.[k] !== undefined)
4147
+ detail[k] = res[k];
4148
+ }
4149
+ const retry = res?.["retry_after_seconds"];
4150
+ return {
4151
+ ok: false,
4152
+ reason: (typeof res?.["seal_pending_reason"] === "string" ? res["seal_pending_reason"] : undefined)
4153
+ ?? (typeof res?.["reason"] === "string" ? res["reason"] : undefined)
4154
+ ?? "close_returned_no_receipt",
4155
+ ...(typeof retry === "number" ? { retryAfterSeconds: retry } : {}),
4156
+ ...(typeof res?.["guidance"] === "string" ? { guidance: res["guidance"] } : {}),
4157
+ ...(Object.keys(detail).length > 0 ? { detail } : {}),
4158
+ };
4159
+ },
4160
+ markGaveUp: (agentName, sessionId, reason) => sessionNodeManager.markRestartSealGaveUp(agentName, sessionId, reason),
4161
+ });
4162
+ restartSealResolver.start();
4099
4163
  /**
4100
4164
  * The live handler map.
4101
4165
  *
@@ -4109,6 +4173,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
4109
4173
  */
4110
4174
  const getHandlers = () => handlers;
4111
4175
  return {
4176
+ /**
4177
+ * DOD-M12B-CLOSE-SILENT-WAIT-1 test seam: put a session into the state a normal close sits in
4178
+ * for up to eleven minutes, and emit the same start-of-wait line the close emits. Marks the
4179
+ * REAL waiter map the status surface reads, so a test cannot pass against a flag production
4180
+ * never sets.
4181
+ */
4182
+ markSealInFlightForTest(agentName, sessionId) {
4183
+ const deadlineMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
4184
+ pendingSealWaiters.set(sealKey(agentName, sessionId), () => { });
4185
+ logger.warn("session.seal.awaiting_counterparty", {
4186
+ sessionId, agentName, deadlineMs,
4187
+ impact: `this close will not answer for up to ${Math.round(deadlineMs / 60_000)} minutes while it waits for the counterparty, then it escalates to a unilateral seal and produces a real receipt. It is working. Do NOT force-abandon it — that forfeits the receipt this wait is earning.`,
4188
+ });
4189
+ },
4112
4190
  stop, getStatus, getSessionNodeManager, getTransportSelector, getAutoNatService, getTypeRegistry,
4113
4191
  getHandlers,
4114
4192
  };