@mclawnet/agent 0.6.28 → 0.6.30

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=hub-connection-reconnect.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hub-connection-reconnect.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/hub-connection-reconnect.test.ts"],"names":[],"mappings":""}
@@ -559,13 +559,13 @@ var HubConnection = class {
559
559
  }
560
560
  connect() {
561
561
  if (this.destroyed) return;
562
- if (this.ws || this.reconnectTimer) return;
562
+ if (this.reconnectTimer) return;
563
+ if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
564
+ return;
565
+ }
563
566
  this.cleanupConnection();
564
567
  this.authState = "pending";
565
- this.ws = new WebSocket(this.hubUrl);
566
- this.ws.on("open", () => {
567
- this.reconnectDelay = DEFAULT_RECONNECT_MS;
568
- });
568
+ this.ws = new WebSocket(this.hubUrl, { handshakeTimeout: 1e4 });
569
569
  this.ws.on("message", (raw) => {
570
570
  let data;
571
571
  try {
@@ -589,6 +589,7 @@ var HubConnection = class {
589
589
  log2.info({ agentId: data.agentId }, "registered with hub");
590
590
  this.authState = "authenticated";
591
591
  this.agentId = data.agentId ?? null;
592
+ this.reconnectDelay = DEFAULT_RECONNECT_MS;
592
593
  this.startHeartbeat();
593
594
  this.onConnectCb?.(this.agentId);
594
595
  this.tryRecoverSwarms();
@@ -616,6 +617,18 @@ var HubConnection = class {
616
617
  this.ws.on("error", (err) => {
617
618
  log2.error({ err }, "ws connection error");
618
619
  this.onError?.(err);
620
+ if (this.ws) {
621
+ const dying = this.ws;
622
+ this.ws = null;
623
+ try {
624
+ dying.removeAllListeners();
625
+ dying.terminate();
626
+ } catch {
627
+ }
628
+ this.stopHeartbeat();
629
+ this.authState = "pending";
630
+ this.scheduleReconnect();
631
+ }
619
632
  });
620
633
  }
621
634
  send(data) {
@@ -1205,14 +1218,26 @@ var HubConnection = class {
1205
1218
  *
1206
1219
  * We `removeAllListeners()` before terminating so the close handler can't
1207
1220
  * also call `scheduleReconnect()` — single-entry reconnect is easier to
1208
- * reason about than relying on the timer-slot idempotency check.
1221
+ * reason about than relying on the timer-slot idempotency check. We then
1222
+ * re-attach a no-op `error` listener: terminating a socket still in
1223
+ * CONNECTING state (very common right after a wake — the post-sleep
1224
+ * reconnect attempt may not have completed the handshake yet) emits an
1225
+ * asynchronous `error` event, and an unhandled `error` on a WebSocket
1226
+ * (which extends EventEmitter) crashes the whole process.
1209
1227
  */
1210
1228
  terminateAndReconnect(reason) {
1211
1229
  this.stopHeartbeat();
1212
1230
  if (this.ws) {
1213
- this.ws.removeAllListeners();
1231
+ const ws = this.ws;
1232
+ ws.removeAllListeners();
1233
+ ws.on("error", () => {
1234
+ });
1214
1235
  try {
1215
- this.ws.terminate();
1236
+ if (ws.readyState === WebSocket.CONNECTING) {
1237
+ ws.close();
1238
+ } else {
1239
+ ws.terminate();
1240
+ }
1216
1241
  } catch {
1217
1242
  }
1218
1243
  this.ws = null;
@@ -1224,12 +1249,15 @@ var HubConnection = class {
1224
1249
  scheduleReconnect() {
1225
1250
  if (this.destroyed) return;
1226
1251
  if (this.reconnectTimer) return;
1227
- log2.warn({ delayMs: this.reconnectDelay }, "reconnecting to hub...");
1252
+ const cap = Math.min(this.reconnectDelay, this.maxReconnectDelay);
1253
+ const base = Math.min(DEFAULT_RECONNECT_MS, cap);
1254
+ const delay = Math.floor(base + Math.random() * Math.max(0, cap - base));
1255
+ log2.warn({ delayMs: delay, capMs: cap }, "reconnecting to hub...");
1228
1256
  this.reconnectTimer = setTimeout(() => {
1229
1257
  this.reconnectTimer = null;
1230
1258
  this.connect();
1231
- }, this.reconnectDelay);
1232
- this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
1259
+ }, delay);
1260
+ this.reconnectDelay = Math.min(cap * 2, this.maxReconnectDelay);
1233
1261
  }
1234
1262
  cleanup() {
1235
1263
  this.cleanupConnection();
@@ -2059,6 +2087,13 @@ var SessionManager = class {
2059
2087
  // correctly routed to the "spawn new + --resume" branch instead of trying
2060
2088
  // to write to a process we are about to kill.
2061
2089
  aborting = /* @__PURE__ */ new Set();
2090
+ // Sessions whose exit was triggered by an explicit close/abort/closeAll —
2091
+ // used by the onExit hook to label the resulting exit event as `expected`
2092
+ // (so SwarmCoordinator can skip flipping the role to `crashed`). The set is
2093
+ // populated *before* `adapter.stop()` runs and drained inside the onExit
2094
+ // handler. A leftover entry would only matter if a session id were reused,
2095
+ // which createSession already forbids (line 193 throws on duplicate).
2096
+ expectedExits = /* @__PURE__ */ new Set();
2062
2097
  idleSweepTimer = null;
2063
2098
  // PR-A: effective sweeper config. Initialized from env at construct time
2064
2099
  // and overridable per-instance via startIdleSweeper(overrides) — the
@@ -2094,6 +2129,14 @@ var SessionManager = class {
2094
2129
  onSessionError;
2095
2130
  onSessionStarted;
2096
2131
  onBeforeClose;
2132
+ /**
2133
+ * Fires whenever the underlying child process exits — both expected
2134
+ * (closeSession/abortSession) and unexpected (crash, OOM, external SIGKILL).
2135
+ * `expected` lets the listener distinguish so a swarm coordinator can flip
2136
+ * the role to `crashed` only on unplanned exits. Wired in start.ts to route
2137
+ * swarm-role exits to SwarmCoordinator.handleRoleCrashed.
2138
+ */
2139
+ onSessionExit;
2097
2140
  // PR-A: classifies a sessionId as 'chat' or 'swarm-role'. Injected by
2098
2141
  // start.ts via SwarmCoordinator.isSwarmSession to keep SessionManager
2099
2142
  // independent of the swarm package. Defaults to 'chat' if absent — safe
@@ -2107,6 +2150,7 @@ var SessionManager = class {
2107
2150
  this.onSessionError = options.onSessionError;
2108
2151
  this.onSessionStarted = options.onSessionStarted;
2109
2152
  this.onBeforeClose = options.onBeforeClose;
2153
+ this.onSessionExit = options.onSessionExit;
2110
2154
  this.classify = options.classify ?? (() => "chat");
2111
2155
  this.checkpointPath = options.checkpointPath ?? null;
2112
2156
  this.checkpointDebounceMs = options.checkpointDebounceMs ?? 5e3;
@@ -2245,13 +2289,37 @@ ${notice.text}`;
2245
2289
  }
2246
2290
  this.adapter.onExit?.(process2, (code) => {
2247
2291
  if (this.sessions.get(options.sessionId) === process2) {
2292
+ const expected = this.expectedExits.delete(options.sessionId);
2248
2293
  this.sessions.delete(options.sessionId);
2249
2294
  this.sessionMeta.delete(options.sessionId);
2250
2295
  this.activelyExecuting.delete(options.sessionId);
2251
2296
  this.conversationBuffer.delete(options.sessionId);
2252
2297
  this.scheduleCheckpoint();
2253
- log5.warn({ sessionId: options.sessionId, exitCode: code }, "backend process exited unexpectedly, evicted from session map");
2254
- this.onSessionError(options.sessionId, `backend process exited (code=${code ?? "null"})`);
2298
+ if (!expected) {
2299
+ log5.warn({ sessionId: options.sessionId, exitCode: code }, "backend process exited unexpectedly, evicted from session map");
2300
+ this.onSessionError(options.sessionId, `backend process exited (code=${code ?? "null"})`);
2301
+ } else {
2302
+ log5.debug({ sessionId: options.sessionId, exitCode: code }, "backend process exited as expected");
2303
+ }
2304
+ try {
2305
+ this.onSessionExit?.(options.sessionId, {
2306
+ code: code ?? null,
2307
+ expected,
2308
+ ...expected ? {} : { reason: `exit code=${code ?? "null"}` }
2309
+ });
2310
+ } catch (err) {
2311
+ log5.warn({ err, sessionId: options.sessionId }, "onSessionExit listener threw");
2312
+ }
2313
+ } else {
2314
+ const expected = this.expectedExits.delete(options.sessionId);
2315
+ try {
2316
+ this.onSessionExit?.(options.sessionId, {
2317
+ code: code ?? null,
2318
+ expected
2319
+ });
2320
+ } catch (err) {
2321
+ log5.warn({ err, sessionId: options.sessionId }, "onSessionExit listener threw");
2322
+ }
2255
2323
  }
2256
2324
  });
2257
2325
  return process2.id;
@@ -2280,6 +2348,7 @@ ${notice.text}`;
2280
2348
  const process2 = this.sessions.get(sessionId);
2281
2349
  if (!process2) return;
2282
2350
  this.aborting.add(sessionId);
2351
+ this.expectedExits.add(sessionId);
2283
2352
  this.conversationBuffer.delete(sessionId);
2284
2353
  this.sessions.delete(sessionId);
2285
2354
  this.sessionMeta.delete(sessionId);
@@ -2300,6 +2369,7 @@ ${notice.text}`;
2300
2369
  });
2301
2370
  }
2302
2371
  this.conversationBuffer.delete(sessionId);
2372
+ this.expectedExits.add(sessionId);
2303
2373
  this.sessions.delete(sessionId);
2304
2374
  this.sessionMeta.delete(sessionId);
2305
2375
  this.activelyExecuting.delete(sessionId);
@@ -2316,6 +2386,7 @@ ${notice.text}`;
2316
2386
  });
2317
2387
  }
2318
2388
  this.conversationBuffer.delete(sessionId);
2389
+ this.expectedExits.add(sessionId);
2319
2390
  this.sessions.delete(sessionId);
2320
2391
  this.sessionMeta.delete(sessionId);
2321
2392
  this.activelyExecuting.delete(sessionId);
@@ -3128,6 +3199,15 @@ async function startAgent(options) {
3128
3199
  error
3129
3200
  });
3130
3201
  },
3202
+ onSessionExit: (sessionId, info) => {
3203
+ if (info.expected) return;
3204
+ if (!swarmCoordinator?.isSwarmSession(sessionId)) return;
3205
+ try {
3206
+ swarmCoordinator.handleRoleCrashed(sessionId, info.reason ?? `exit code=${info.code ?? "null"}`);
3207
+ } catch (err) {
3208
+ log9.warn({ err, sessionId }, "swarmCoordinator.handleRoleCrashed threw");
3209
+ }
3210
+ },
3131
3211
  onBeforeClose,
3132
3212
  // PR-A: classify session kind for the idle sweeper. SessionManager stays
3133
3213
  // independent of the swarm package; we hand it a closure that defers to
@@ -3185,4 +3265,4 @@ export {
3185
3265
  FsBridge,
3186
3266
  startAgent
3187
3267
  };
3188
- //# sourceMappingURL=chunk-NQ3WFEXY.js.map
3268
+ //# sourceMappingURL=chunk-LXSWV3VS.js.map