@alfe.ai/gateway 0.7.2 → 0.7.4

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/health.js CHANGED
@@ -551,6 +551,18 @@ var AuthService = class {
551
551
  body: JSON.stringify(input)
552
552
  });
553
553
  }
554
+ /**
555
+ * Change a personal (individual-tier) subscriber's platform plan in place on
556
+ * their existing Stripe subscription — no second Checkout, no duplicate sub,
557
+ * no double charge. Upgrades prorate immediately; downgrades apply with no
558
+ * immediate charge/refund. Returns the updated subscription.
559
+ */
560
+ changePlatformPlan(input) {
561
+ return this.client.request(`${this.prefix}/subscription/change`, {
562
+ method: "POST",
563
+ body: JSON.stringify(input)
564
+ });
565
+ }
554
566
  updateSeats(seats) {
555
567
  return this.client.request(`${this.prefix}/subscription/seats`, {
556
568
  method: "POST",
@@ -5313,7 +5325,7 @@ function isIPCResponse(msg) {
5313
5325
  const PROTOCOL_VERSION = 1;
5314
5326
  //#endregion
5315
5327
  //#region src/runtime-gate.ts
5316
- const log$5 = logger$1.child({ component: "RuntimeGate" });
5328
+ const log$6 = logger$1.child({ component: "RuntimeGate" });
5317
5329
  var RuntimeGate = class {
5318
5330
  depth = 0;
5319
5331
  wasRunning = false;
@@ -5331,8 +5343,8 @@ var RuntimeGate = class {
5331
5343
  const rp = this.getRuntime();
5332
5344
  this.wasRunning = rp?.isRunning ?? false;
5333
5345
  if (rp && this.wasRunning) {
5334
- log$5.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
5335
- await rp.stop();
5346
+ log$6.info("Suspending runtime for mutating reconcile (avoid concurrent SQLite writers)");
5347
+ await rp.stopWhenIdle();
5336
5348
  }
5337
5349
  }
5338
5350
  /**
@@ -5346,7 +5358,7 @@ var RuntimeGate = class {
5346
5358
  if (this.depth < 0) this.depth = 0;
5347
5359
  const rp = this.getRuntime();
5348
5360
  if (rp && this.wasRunning) {
5349
- log$5.info("Resuming runtime after mutating reconcile");
5361
+ log$6.info("Resuming runtime after mutating reconcile");
5350
5362
  rp.resume();
5351
5363
  }
5352
5364
  this.wasRunning = false;
@@ -5361,12 +5373,12 @@ var RuntimeGate = class {
5361
5373
  */
5362
5374
  requestRestart() {
5363
5375
  if (this.depth > 0) {
5364
- log$5.info("Runtime restart requested while suspended — deferring to pending resume");
5376
+ log$6.info("Runtime restart requested while suspended — deferring to pending resume");
5365
5377
  return;
5366
5378
  }
5367
5379
  const rp = this.getRuntime();
5368
5380
  if (rp) rp.restart().catch((err) => {
5369
- log$5.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
5381
+ log$6.error({ err: err instanceof Error ? err.message : String(err) }, "Failed to restart runtime");
5370
5382
  });
5371
5383
  }
5372
5384
  };
@@ -5770,7 +5782,7 @@ function captureFatal(cause) {
5770
5782
  }
5771
5783
  //#endregion
5772
5784
  //#region src/reconciliation.ts
5773
- const log$4 = logger$1.child({ component: "Reconciliation" });
5785
+ const log$5 = logger$1.child({ component: "Reconciliation" });
5774
5786
  /**
5775
5787
  * How many times reconcile will re-attempt activation of an intact integration
5776
5788
  * stuck in `error` (with no reinstall requested) before giving up and waiting
@@ -5840,7 +5852,7 @@ var ReconciliationEngine = class {
5840
5852
  try {
5841
5853
  localIntegrations = await this.manager.getInstalledIntegrations();
5842
5854
  } catch (err) {
5843
- log$4.error({ err }, "Failed to get local integrations");
5855
+ log$5.error({ err }, "Failed to get local integrations");
5844
5856
  localIntegrations = [];
5845
5857
  }
5846
5858
  const localMap = new Map(localIntegrations.map((i) => [i.id, i]));
@@ -5859,10 +5871,10 @@ var ReconciliationEngine = class {
5859
5871
  try {
5860
5872
  if (!local) {
5861
5873
  await this.ensureSuspended();
5862
- log$4.info(`Installing ${id}@${desired.version}`);
5874
+ log$5.info(`Installing ${id}@${desired.version}`);
5863
5875
  await this.manager.install(id, desired.version, desired.config, desired.customSource);
5864
5876
  report.installed.push(id);
5865
- log$4.info(`Activating ${id}`);
5877
+ log$5.info(`Activating ${id}`);
5866
5878
  if ((await this.manager.activate(id)).configApplied) report.configApplied = true;
5867
5879
  report.activated.push(id);
5868
5880
  report.results.push({
@@ -5875,7 +5887,7 @@ var ReconciliationEngine = class {
5875
5887
  if (local.version !== desired.version && desired.version !== "" && local.version !== "unknown") {
5876
5888
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5877
5889
  await this.ensureSuspended();
5878
- log$4.info(`Reinstalling ${id} on version change (reinstall requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt}): ${local.version} → ${desired.version}`);
5890
+ log$5.info(`Reinstalling ${id} on version change (reinstall requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt}): ${local.version} → ${desired.version}`);
5879
5891
  this.manager.resetReinstallAttempts(id);
5880
5892
  this.activateAttempts.delete(id);
5881
5893
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5888,7 +5900,7 @@ var ReconciliationEngine = class {
5888
5900
  });
5889
5901
  return;
5890
5902
  }
5891
- log$4.info(`Upgrading ${id} in place: ${local.version} → ${desired.version}`);
5903
+ log$5.info(`Upgrading ${id} in place: ${local.version} → ${desired.version}`);
5892
5904
  try {
5893
5905
  if ((await this.manager.upgrade(id, desired.version, desired.config, desired.customSource, { onBeforeRuntimeMutation: () => this.ensureSuspended() })).configApplied) report.configApplied = true;
5894
5906
  this.manager.resetReinstallAttempts(id);
@@ -5902,7 +5914,7 @@ var ReconciliationEngine = class {
5902
5914
  });
5903
5915
  } catch (upgradeErr) {
5904
5916
  const upgradeMsg = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
5905
- log$4.error({ err: upgradeErr }, `Upgrade failed for ${id}`);
5917
+ log$5.error({ err: upgradeErr }, `Upgrade failed for ${id}`);
5906
5918
  captureIntegrationFailure(id, "upgrade", upgradeErr);
5907
5919
  report.errors.push({
5908
5920
  integrationId: id,
@@ -5920,7 +5932,7 @@ var ReconciliationEngine = class {
5920
5932
  if (local.status === "error") {
5921
5933
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
5922
5934
  await this.ensureSuspended();
5923
- log$4.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5935
+ log$5.info(`Reinstalling ${id} from error state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
5924
5936
  this.manager.resetReinstallAttempts(id);
5925
5937
  this.activateAttempts.delete(id);
5926
5938
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5936,7 +5948,7 @@ var ReconciliationEngine = class {
5936
5948
  if (!await this.manager.isInstallIntact(id)) {
5937
5949
  const attempts = this.manager.getReinstallAttempts(id);
5938
5950
  if (attempts >= 3) {
5939
- log$4.error(`Auto-reinstall blocked for ${id} — ${String(attempts)} consecutive failures. Manual reinstall required.`);
5951
+ log$5.error(`Auto-reinstall blocked for ${id} — ${String(attempts)} consecutive failures. Manual reinstall required.`);
5940
5952
  captureIntegrationFailure(id, "reinstall", `Max auto-reinstall attempts (${String(attempts)}) reached — manual reinstall required`);
5941
5953
  report.errors.push({
5942
5954
  integrationId: id,
@@ -5951,7 +5963,7 @@ var ReconciliationEngine = class {
5951
5963
  return;
5952
5964
  }
5953
5965
  await this.ensureSuspended();
5954
- log$4.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
5966
+ log$5.info(`Auto-reinstalling ${id} — install directory is corrupted or missing (attempt ${String(attempts + 1)}/3)`);
5955
5967
  this.manager.incrementReinstallAttempts(id);
5956
5968
  try {
5957
5969
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -5966,7 +5978,7 @@ var ReconciliationEngine = class {
5966
5978
  });
5967
5979
  } catch (reinstallErr) {
5968
5980
  const reinstallMsg = reinstallErr instanceof Error ? reinstallErr.message : String(reinstallErr);
5969
- log$4.error({ err: reinstallErr }, `Auto-reinstall failed for ${id}`);
5981
+ log$5.error({ err: reinstallErr }, `Auto-reinstall failed for ${id}`);
5970
5982
  captureIntegrationFailure(id, "reinstall", reinstallErr);
5971
5983
  report.errors.push({
5972
5984
  integrationId: id,
@@ -5985,7 +5997,7 @@ var ReconciliationEngine = class {
5985
5997
  if (activateAttempts < MAX_ERROR_ACTIVATE_ATTEMPTS) {
5986
5998
  this.activateAttempts.set(id, activateAttempts + 1);
5987
5999
  await this.ensureSuspended();
5988
- log$4.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
6000
+ log$5.info(`Re-activating ${id} from error state (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)})`);
5989
6001
  try {
5990
6002
  if ((await this.manager.activate(id, { forcePlugins: true })).configApplied) report.configApplied = true;
5991
6003
  this.activateAttempts.delete(id);
@@ -5998,7 +6010,7 @@ var ReconciliationEngine = class {
5998
6010
  return;
5999
6011
  } catch (reactivateErr) {
6000
6012
  const reactivateMsg = reactivateErr instanceof Error ? reactivateErr.message : String(reactivateErr);
6001
- log$4.warn(`Re-activation of ${id} from error state failed (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)}): ${reactivateMsg}`);
6013
+ log$5.warn(`Re-activation of ${id} from error state failed (attempt ${String(activateAttempts + 1)}/${String(MAX_ERROR_ACTIVATE_ATTEMPTS)}): ${reactivateMsg}`);
6002
6014
  captureIntegrationFailure(id, "reactivate", reactivateErr);
6003
6015
  report.errors.push({
6004
6016
  integrationId: id,
@@ -6013,7 +6025,7 @@ var ReconciliationEngine = class {
6013
6025
  return;
6014
6026
  }
6015
6027
  }
6016
- log$4.warn(`Integration ${id} is in error state — re-activation exhausted, waiting for reinstall request`);
6028
+ log$5.warn(`Integration ${id} is in error state — re-activation exhausted, waiting for reinstall request`);
6017
6029
  captureIntegrationFailure(id, "reactivate", `Integration ${id} stuck in error state — re-activation attempts exhausted`);
6018
6030
  report.errors.push({
6019
6031
  integrationId: id,
@@ -6030,7 +6042,7 @@ var ReconciliationEngine = class {
6030
6042
  if (local.status === "installing") {
6031
6043
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
6032
6044
  await this.ensureSuspended();
6033
- log$4.info(`Reinstalling ${id} from stale installing state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
6045
+ log$5.info(`Reinstalling ${id} from stale installing state (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
6034
6046
  this.manager.resetReinstallAttempts(id);
6035
6047
  this.activateAttempts.delete(id);
6036
6048
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -6046,7 +6058,7 @@ var ReconciliationEngine = class {
6046
6058
  const attempts = this.manager.getReinstallAttempts(id);
6047
6059
  if (attempts >= 3) {
6048
6060
  const errorMessage = `Stale installing state — max auto-reinstall attempts (${String(attempts)}) reached — manual reinstall required`;
6049
- log$4.error(`Auto-reinstall blocked for ${id} — stuck in "installing", ${String(attempts)} consecutive failures. Manual reinstall required.`);
6061
+ log$5.error(`Auto-reinstall blocked for ${id} — stuck in "installing", ${String(attempts)} consecutive failures. Manual reinstall required.`);
6050
6062
  captureIntegrationFailure(id, "reinstall", errorMessage);
6051
6063
  report.errors.push({
6052
6064
  integrationId: id,
@@ -6061,7 +6073,7 @@ var ReconciliationEngine = class {
6061
6073
  return;
6062
6074
  }
6063
6075
  await this.ensureSuspended();
6064
- log$4.info(`Recovering ${id} from stale installing state via reinstall (attempt ${String(attempts + 1)}/3)`);
6076
+ log$5.info(`Recovering ${id} from stale installing state via reinstall (attempt ${String(attempts + 1)}/3)`);
6065
6077
  this.manager.incrementReinstallAttempts(id);
6066
6078
  try {
6067
6079
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -6076,7 +6088,7 @@ var ReconciliationEngine = class {
6076
6088
  });
6077
6089
  } catch (reinstallErr) {
6078
6090
  const reinstallMsg = reinstallErr instanceof Error ? reinstallErr.message : String(reinstallErr);
6079
- log$4.error({ err: reinstallErr }, `Reinstall from stale installing state failed for ${id}`);
6091
+ log$5.error({ err: reinstallErr }, `Reinstall from stale installing state failed for ${id}`);
6080
6092
  captureIntegrationFailure(id, "reinstall", reinstallErr);
6081
6093
  report.errors.push({
6082
6094
  integrationId: id,
@@ -6093,7 +6105,7 @@ var ReconciliationEngine = class {
6093
6105
  }
6094
6106
  if (local.status !== "active") {
6095
6107
  await this.ensureSuspended();
6096
- log$4.info(`Activating ${id}`);
6108
+ log$5.info(`Activating ${id}`);
6097
6109
  if ((await this.manager.activate(id, { forcePlugins: true })).configApplied) report.configApplied = true;
6098
6110
  report.activated.push(id);
6099
6111
  report.results.push({
@@ -6105,7 +6117,7 @@ var ReconciliationEngine = class {
6105
6117
  }
6106
6118
  if (desired.reinstallRequestedAt && local.installedAt && desired.reinstallRequestedAt > local.installedAt) {
6107
6119
  await this.ensureSuspended();
6108
- log$4.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
6120
+ log$5.info(`Reinstall requested for ${id} (requested: ${desired.reinstallRequestedAt}, installed: ${local.installedAt})`);
6109
6121
  this.manager.resetReinstallAttempts(id);
6110
6122
  this.activateAttempts.delete(id);
6111
6123
  if ((await this.manager.reinstall(id, desired.version, desired.config, desired.customSource)).configApplied) report.configApplied = true;
@@ -6126,7 +6138,7 @@ var ReconciliationEngine = class {
6126
6138
  });
6127
6139
  } catch (err) {
6128
6140
  const message = err instanceof Error ? err.message : String(err);
6129
- log$4.error({ err }, `Error reconciling ${id}`);
6141
+ log$5.error({ err }, `Error reconciling ${id}`);
6130
6142
  captureIntegrationFailure(id, "reconcile_active", err);
6131
6143
  report.errors.push({
6132
6144
  integrationId: id,
@@ -6151,7 +6163,7 @@ var ReconciliationEngine = class {
6151
6163
  }
6152
6164
  try {
6153
6165
  await this.ensureSuspended();
6154
- log$4.info(`Removing ${id}`);
6166
+ log$5.info(`Removing ${id}`);
6155
6167
  if (local.status === "active") {
6156
6168
  await this.manager.deactivate(id);
6157
6169
  report.deactivated.push(id);
@@ -6165,7 +6177,7 @@ var ReconciliationEngine = class {
6165
6177
  });
6166
6178
  } catch (err) {
6167
6179
  const message = err instanceof Error ? err.message : String(err);
6168
- log$4.error({ err }, `Error removing ${id}`);
6180
+ log$5.error({ err }, `Error removing ${id}`);
6169
6181
  captureIntegrationFailure(id, "reconcile_removed", err);
6170
6182
  report.errors.push({
6171
6183
  integrationId: id,
@@ -6548,7 +6560,7 @@ var CloudClient = class {
6548
6560
  };
6549
6561
  //#endregion
6550
6562
  //#region src/config-reconciler.ts
6551
- const log$3 = logger$1.child({ component: "ConfigReconciler" });
6563
+ const log$4 = logger$1.child({ component: "ConfigReconciler" });
6552
6564
  const DEFAULT_VERIFY_RETRIES = 3;
6553
6565
  const DEFAULT_VERIFY_RETRY_DELAY_MS = 750;
6554
6566
  const delay = (ms) => new Promise((resolve) => {
@@ -6570,7 +6582,7 @@ var ConfigReconciler = class {
6570
6582
  async reconcile(desired) {
6571
6583
  const applier = this.getApplier();
6572
6584
  if (!applier?.setConfigRaw) {
6573
- log$3.debug("No applier with setConfigRaw — skipping config reconcile");
6585
+ log$4.debug("No applier with setConfigRaw — skipping config reconcile");
6574
6586
  return null;
6575
6587
  }
6576
6588
  const setConfigRaw = applier.setConfigRaw.bind(applier);
@@ -6584,7 +6596,7 @@ var ConfigReconciler = class {
6584
6596
  for (const [key, want] of entries) try {
6585
6597
  if (getConfigRaw) {
6586
6598
  if (await getConfigRaw(key) === want) {
6587
- log$3.debug({ key }, "Config already at desired value — no-op");
6599
+ log$4.debug({ key }, "Config already at desired value — no-op");
6588
6600
  continue;
6589
6601
  }
6590
6602
  }
@@ -6594,12 +6606,19 @@ var ConfigReconciler = class {
6594
6606
  }
6595
6607
  } catch (err) {
6596
6608
  const msg = err instanceof Error ? err.message : String(err);
6609
+ if (getConfigRaw && await this.verify(getConfigRaw, key, want)) {
6610
+ log$4.warn({
6611
+ key,
6612
+ err: msg
6613
+ }, "config set errored but value landed — treating as applied");
6614
+ continue;
6615
+ }
6597
6616
  failures.push(`${key}: ${msg}`);
6598
6617
  }
6599
6618
  if (failures.length > 0) {
6600
6619
  const joined = failures.join("; ");
6601
6620
  const reason = joined.length > 480 ? `${joined.slice(0, 480)}… (truncated)` : joined;
6602
- log$3.warn({
6621
+ log$4.warn({
6603
6622
  version: desired.version,
6604
6623
  reason
6605
6624
  }, "Config reconcile failed");
@@ -6609,7 +6628,7 @@ var ConfigReconciler = class {
6609
6628
  reason
6610
6629
  };
6611
6630
  }
6612
- log$3.info({
6631
+ log$4.info({
6613
6632
  version: desired.version,
6614
6633
  keys: entries.length
6615
6634
  }, "Config reconcile applied");
@@ -22585,13 +22604,21 @@ var CaptureThrottle = class {
22585
22604
  * Spawns the runtime binary, pipes stdout/stderr to the daemon logger,
22586
22605
  * and restarts on crash with exponential backoff.
22587
22606
  */
22588
- const log$2 = createLogger("RuntimeProcess");
22607
+ const log$3 = createLogger("RuntimeProcess");
22589
22608
  /** Quiet-period flush for a pending multi-line error block (e.g. a traceback
22590
22609
  * followed by silence — no closing line ever arrives to complete it). */
22591
22610
  const DETECTOR_FLUSH_DEBOUNCE_MS = 1500;
22592
22611
  const BACKOFF_INITIAL_MS = 1e3;
22593
22612
  const BACKOFF_MAX_MS = 3e4;
22594
22613
  const STABLE_UPTIME_MS = 6e4;
22614
+ /**
22615
+ * Max time a NON-crash restart waits for an in-flight chat turn to finish
22616
+ * before it forces the stop anyway. A turn that outlives this is almost
22617
+ * certainly wedged; a real reply streams in far less. Long enough to cover a
22618
+ * normal multi-tool turn, short enough that a stuck turn doesn't block a
22619
+ * platform-pushed `daemon.update` indefinitely.
22620
+ */
22621
+ const DRAIN_GRACE_MS = 45e3;
22595
22622
  var RuntimeProcess = class {
22596
22623
  child = null;
22597
22624
  stopped = false;
@@ -22602,10 +22629,20 @@ var RuntimeProcess = class {
22602
22629
  detector = new ErrorLineDetector();
22603
22630
  throttle = new CaptureThrottle(STABLE_UPTIME_MS);
22604
22631
  detectorFlushTimer = null;
22632
+ turnActivityProbe = null;
22605
22633
  constructor(options) {
22606
22634
  this.options = options;
22607
22635
  }
22608
22636
  /**
22637
+ * Attach the turn-activity probe used by drained restarts. The daemon calls
22638
+ * this after the IPC server is up (plugins register there). Without it,
22639
+ * {@link stopWhenIdle} and drained {@link restart} fall back to an immediate
22640
+ * stop — safe, just not turn-aware.
22641
+ */
22642
+ setTurnActivityProbe(probe) {
22643
+ this.turnActivityProbe = probe;
22644
+ }
22645
+ /**
22609
22646
  * Resolve the binary command and args for a given runtime type.
22610
22647
  */
22611
22648
  resolveCommand() {
@@ -22632,7 +22669,7 @@ var RuntimeProcess = class {
22632
22669
  if (this.stopped) return;
22633
22670
  const { command, args } = this.resolveCommand();
22634
22671
  this.lastStartTime = Date.now();
22635
- log$2.info({
22672
+ log$3.info({
22636
22673
  runtime: this.options.runtime,
22637
22674
  command,
22638
22675
  args,
@@ -22653,7 +22690,7 @@ var RuntimeProcess = class {
22653
22690
  this.child.stdout?.on("data", (data) => {
22654
22691
  const lines = data.toString().trim().split("\n");
22655
22692
  for (const line of lines) {
22656
- log$2.info({
22693
+ log$3.info({
22657
22694
  runtime: this.options.runtime,
22658
22695
  stream: "stdout"
22659
22696
  }, line);
@@ -22663,7 +22700,7 @@ var RuntimeProcess = class {
22663
22700
  this.child.stderr?.on("data", (data) => {
22664
22701
  const lines = data.toString().trim().split("\n");
22665
22702
  for (const line of lines) {
22666
- log$2.warn({
22703
+ log$3.warn({
22667
22704
  runtime: this.options.runtime,
22668
22705
  stream: "stderr"
22669
22706
  }, line);
@@ -22673,7 +22710,7 @@ var RuntimeProcess = class {
22673
22710
  this.child.on("exit", (code, signal) => {
22674
22711
  this.child = null;
22675
22712
  if (this.stopped) {
22676
- log$2.info({
22713
+ log$3.info({
22677
22714
  runtime: this.options.runtime,
22678
22715
  code,
22679
22716
  signal
@@ -22681,7 +22718,7 @@ var RuntimeProcess = class {
22681
22718
  return;
22682
22719
  }
22683
22720
  if (code === 0 && signal == null) {
22684
- log$2.info({
22721
+ log$3.info({
22685
22722
  runtime: this.options.runtime,
22686
22723
  code
22687
22724
  }, "Runtime exited gracefully — restarting");
@@ -22691,7 +22728,7 @@ var RuntimeProcess = class {
22691
22728
  }, 500);
22692
22729
  return;
22693
22730
  }
22694
- log$2.warn({
22731
+ log$3.warn({
22695
22732
  runtime: this.options.runtime,
22696
22733
  code,
22697
22734
  signal,
@@ -22712,7 +22749,7 @@ var RuntimeProcess = class {
22712
22749
  recentOutput: this.ringBuffer.snapshot(),
22713
22750
  crashesSuppressed: crash.suppressedCount
22714
22751
  });
22715
- else log$2.debug({
22752
+ else log$3.debug({
22716
22753
  runtime: this.options.runtime,
22717
22754
  suppressed: crash.suppressedCount
22718
22755
  }, "Crash capture suppressed by throttle");
@@ -22724,7 +22761,7 @@ var RuntimeProcess = class {
22724
22761
  this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
22725
22762
  });
22726
22763
  this.child.on("error", (err) => {
22727
- log$2.error({
22764
+ log$3.error({
22728
22765
  runtime: this.options.runtime,
22729
22766
  err: err.message
22730
22767
  }, "Runtime process error");
@@ -22763,7 +22800,7 @@ var RuntimeProcess = class {
22763
22800
  this.emitErrorBlock(this.detector.flush());
22764
22801
  }, DETECTOR_FLUSH_DEBOUNCE_MS);
22765
22802
  } catch (err) {
22766
- log$2.debug({
22803
+ log$3.debug({
22767
22804
  runtime: this.options.runtime,
22768
22805
  err: err instanceof Error ? err.message : String(err)
22769
22806
  }, "Runtime output observation failed");
@@ -22774,7 +22811,7 @@ var RuntimeProcess = class {
22774
22811
  if (!block) return;
22775
22812
  const { allow, suppressedCount } = this.throttle.allowErrorCapture(block.fingerprintKey);
22776
22813
  if (!allow) {
22777
- log$2.debug({
22814
+ log$3.debug({
22778
22815
  runtime: this.options.runtime,
22779
22816
  kind: block.kind,
22780
22817
  suppressed: suppressedCount
@@ -22808,7 +22845,7 @@ var RuntimeProcess = class {
22808
22845
  if (!child) return;
22809
22846
  return new Promise((resolve) => {
22810
22847
  const killTimer = setTimeout(() => {
22811
- log$2.warn({ runtime: this.options.runtime }, "Runtime did not exit in time — sending SIGKILL");
22848
+ log$3.warn({ runtime: this.options.runtime }, "Runtime did not exit in time — sending SIGKILL");
22812
22849
  child.kill("SIGKILL");
22813
22850
  }, 5e3);
22814
22851
  child.on("exit", () => {
@@ -22819,6 +22856,54 @@ var RuntimeProcess = class {
22819
22856
  });
22820
22857
  }
22821
22858
  /**
22859
+ * Stop the runtime, but first wait for any in-flight chat turn to finish
22860
+ * (up to a grace deadline). This is the entry point for NON-crash restarts —
22861
+ * a mid-turn SIGTERM turns a healthy reply into an "unknown error" for the
22862
+ * user, so a planned stop drains first.
22863
+ *
22864
+ * Semantics:
22865
+ * - No probe attached, or probe reports idle up-front → stops immediately
22866
+ * (no added latency for the common case).
22867
+ * - A turn is in flight → polls the probe every {@link DRAIN_POLL_INTERVAL_MS}
22868
+ * until it reports idle, then stops.
22869
+ * - The turn never ends within `graceMs` → logs and forces the stop anyway
22870
+ * (a wedged turn must not block a platform-pushed restart forever).
22871
+ *
22872
+ * Crash restarts must NOT call this — the child is already dead/broken and
22873
+ * the probe would just burn the grace window. They keep using {@link stop}.
22874
+ */
22875
+ async stopWhenIdle(opts) {
22876
+ const probe = opts?.probe ?? this.turnActivityProbe;
22877
+ const graceMs = opts?.graceMs ?? 45e3;
22878
+ if (!this.child || !probe) {
22879
+ await this.stop();
22880
+ return;
22881
+ }
22882
+ const deadline = Date.now() + graceMs;
22883
+ let deferred = false;
22884
+ for (;;) {
22885
+ if (!await probe.isTurnActive()) break;
22886
+ if (Date.now() >= deadline) {
22887
+ log$3.warn({
22888
+ runtime: this.options.runtime,
22889
+ graceMs
22890
+ }, "Runtime still mid-turn at drain grace deadline — forcing stop");
22891
+ break;
22892
+ }
22893
+ if (!deferred) {
22894
+ deferred = true;
22895
+ log$3.info({
22896
+ runtime: this.options.runtime,
22897
+ graceMs
22898
+ }, "Runtime is mid-turn — deferring planned stop until idle");
22899
+ }
22900
+ await new Promise((resolve) => setTimeout(resolve, 500));
22901
+ if (!this.isRunning) return;
22902
+ }
22903
+ if (deferred) log$3.info({ runtime: this.options.runtime }, "Runtime idle — proceeding with planned stop");
22904
+ await this.stop();
22905
+ }
22906
+ /**
22822
22907
  * Resume after a suspend (stop). `start()` alone is a no-op while stopped
22823
22908
  * because `stop()` latches `this.stopped = true`; resume clears that latch,
22824
22909
  * resets the crash backoff, and re-spawns. Used by the RuntimeGate to bring
@@ -22831,10 +22916,20 @@ var RuntimeProcess = class {
22831
22916
  }
22832
22917
  /**
22833
22918
  * Restart the runtime process (stop then start with fresh backoff).
22919
+ *
22920
+ * This is a PLANNED restart (`runtime.restart`, post-upgrade cycle, gate
22921
+ * request), so by default it drains any in-flight chat turn before stopping
22922
+ * — see {@link stopWhenIdle}. Pass `{ drain: false }` to force an immediate
22923
+ * stop (e.g. when the caller has already established the child is unhealthy).
22834
22924
  */
22835
- async restart() {
22836
- log$2.info({ runtime: this.options.runtime }, "Restarting runtime...");
22837
- await this.stop();
22925
+ async restart(opts) {
22926
+ const drain = opts?.drain ?? true;
22927
+ log$3.info({
22928
+ runtime: this.options.runtime,
22929
+ drain
22930
+ }, "Restarting runtime...");
22931
+ if (drain) await this.stopWhenIdle();
22932
+ else await this.stop();
22838
22933
  this.stopped = false;
22839
22934
  this.backoffMs = BACKOFF_INITIAL_MS;
22840
22935
  this.start();
@@ -22847,6 +22942,78 @@ var RuntimeProcess = class {
22847
22942
  }
22848
22943
  };
22849
22944
  //#endregion
22945
+ //#region src/turn-activity.ts
22946
+ /**
22947
+ * Turn-activity probe — lets a NON-crash runtime restart wait for the runtime
22948
+ * to finish any in-flight chat turn before the child is SIGTERM'd.
22949
+ *
22950
+ * ## Why this exists
22951
+ *
22952
+ * Every non-crash restart path (`runtime.restart`, `daemon.update`,
22953
+ * `daemon.restart`, the RuntimeGate resume after a mutating reconcile) used to
22954
+ * route straight into {@link RuntimeProcess.stop}, which is SIGTERM →
22955
+ * SIGKILL-after-5s with zero turn-awareness. A platform-pushed `daemon.update`
22956
+ * that landed mid-turn killed the runtime child while it was streaming a reply,
22957
+ * and the user saw an "unknown error" for a turn that was otherwise healthy.
22958
+ *
22959
+ * The fix is a small "is a turn active?" signal that the daemon can consult
22960
+ * before a *planned* stop, deferring the SIGTERM until the runtime reports idle
22961
+ * (or a grace deadline elapses). Crash restarts do NOT consult it — the child is
22962
+ * already dead or wedged, so there is nothing to drain.
22963
+ *
22964
+ * ## The runtime-side contract (the boundary)
22965
+ *
22966
+ * The runtime's activity is a property of the chat plugin's active-turn state,
22967
+ * which lives INSIDE the runtime child (e.g. `openclaw-chat`'s per-turn run
22968
+ * gate). The daemon cannot see it directly. The daemon reaches it over the same
22969
+ * IPC socket the plugin already uses to register:
22970
+ *
22971
+ * daemon → plugin request: { method: 'runtime.activity' }
22972
+ * plugin → daemon response: { ok: true, payload: { active: boolean } }
22973
+ *
22974
+ * `runtime.activity` is on the daemon→plugin method allowlist (see
22975
+ * protocol.ts). A plugin answers `active: true` while it is dispatching a turn
22976
+ * and `active: false` otherwise. This is the ONE thing the runtime must provide;
22977
+ * everything else in the drain lives on the gateway side.
22978
+ *
22979
+ * ## Fail-open to idle
22980
+ *
22981
+ * If NO connected plugin answers `runtime.activity` (older plugin build, the
22982
+ * chat plugin isn't loaded, the request times out), the probe reports **idle**.
22983
+ * A planned restart must never hang forever waiting on a signal that will never
22984
+ * arrive — the grace deadline is a backstop, but fail-open-to-idle means the
22985
+ * common "nothing to drain" case restarts immediately with no added latency.
22986
+ */
22987
+ const log$2 = logger$1.child({ component: "TurnActivity" });
22988
+ /** The IPC method the daemon calls on plugins to ask "is a turn active?". */
22989
+ const RUNTIME_ACTIVITY_METHOD = "runtime.activity";
22990
+ /**
22991
+ * IPC-backed probe. Broadcasts `runtime.activity` to every registered plugin
22992
+ * and reports active if ANY plugin claims an in-flight turn. Fails open to idle
22993
+ * on any error / timeout / non-answer so a planned restart is never wedged.
22994
+ */
22995
+ var IpcTurnActivityProbe = class {
22996
+ constructor(getIpcServer, probeTimeoutMs = 2e3) {
22997
+ this.getIpcServer = getIpcServer;
22998
+ this.probeTimeoutMs = probeTimeoutMs;
22999
+ }
23000
+ async isTurnActive() {
23001
+ const ipc = this.getIpcServer();
23002
+ if (!ipc || ipc.registeredCount === 0) return false;
23003
+ try {
23004
+ const responses = await ipc.broadcastRequest(RUNTIME_ACTIVITY_METHOD, {}, this.probeTimeoutMs);
23005
+ for (const [pluginId, res] of responses) if (res.ok && res.payload != null && typeof res.payload === "object" && res.payload.active === true) {
23006
+ log$2.debug({ pluginId }, "Plugin reports an in-flight turn");
23007
+ return true;
23008
+ }
23009
+ return false;
23010
+ } catch (err) {
23011
+ log$2.debug({ err: err instanceof Error ? err.message : String(err) }, "Turn-activity probe failed — assuming idle");
23012
+ return false;
23013
+ }
23014
+ }
23015
+ };
23016
+ //#endregion
22850
23017
  //#region src/command-registry.ts
22851
23018
  /**
22852
23019
  * Command Registry — maps integration command names to local handler files.
@@ -23477,6 +23644,7 @@ let mcpManagerRef = null;
23477
23644
  let hermesMcpSync = null;
23478
23645
  let aiProxyServer = null;
23479
23646
  let runtimeProcess = null;
23647
+ let turnActivityProbe = null;
23480
23648
  let aiProxyUrl = null;
23481
23649
  let aiProxyRunning = false;
23482
23650
  let cloudConnected = false;
@@ -23770,6 +23938,7 @@ async function startDaemon() {
23770
23938
  logger$1.debug({ socketPath: config.socketPath }, "Starting IPC server...");
23771
23939
  ipcServer = new IPCServer(config.socketPath);
23772
23940
  ipcServer.setRequestHandler(handlePluginRequest);
23941
+ turnActivityProbe = new IpcTurnActivityProbe(() => ipcServer);
23773
23942
  try {
23774
23943
  await ipcServer.start();
23775
23944
  logger$1.debug("IPC server started");
@@ -23933,6 +24102,7 @@ async function startDaemon() {
23933
24102
  workspace: runtimeCfg.workspace,
23934
24103
  env: {}
23935
24104
  });
24105
+ runtimeProcess.setTurnActivityProbe(turnActivityProbe);
23936
24106
  runtimeProcess.start();
23937
24107
  logger$1.debug("Runtime process started");
23938
24108
  if (config.runtime === "openclaw") {
@@ -24038,11 +24208,46 @@ async function handleCloudCommand(command) {
24038
24208
  }
24039
24209
  return ack;
24040
24210
  }
24211
+ /**
24212
+ * Wait for any in-flight chat turn to finish before a daemon PROCESS exit
24213
+ * (daemon.update / daemon.restart). Those paths don't stop the runtime via
24214
+ * `RuntimeProcess.stop` — they exit the daemon, and systemd/launchd cgroup-kill
24215
+ * the runtime child underneath. That kill is just as turn-destroying as a
24216
+ * SIGTERM, so we drain here too. Grace-deadline backstopped; fails open to
24217
+ * "proceed" so a wedged turn can't strand a platform-pushed update.
24218
+ */
24219
+ async function drainRuntimeBeforeExit(reason) {
24220
+ const probe = turnActivityProbe;
24221
+ if (!probe || !runtimeProcess?.isRunning) return;
24222
+ const deadline = Date.now() + DRAIN_GRACE_MS;
24223
+ let deferred = false;
24224
+ while (Date.now() < deadline) {
24225
+ let active;
24226
+ try {
24227
+ active = await probe.isTurnActive();
24228
+ } catch {
24229
+ return;
24230
+ }
24231
+ if (!active) {
24232
+ if (deferred) logger$1.info({ reason }, "Runtime idle — proceeding with planned daemon exit");
24233
+ return;
24234
+ }
24235
+ if (!deferred) {
24236
+ deferred = true;
24237
+ logger$1.info({ reason }, "Runtime is mid-turn — deferring planned daemon exit until idle");
24238
+ }
24239
+ await new Promise((resolve) => setTimeout(resolve, 500));
24240
+ }
24241
+ logger$1.warn({
24242
+ reason,
24243
+ graceMs: DRAIN_GRACE_MS
24244
+ }, "Runtime still mid-turn at drain grace deadline — proceeding with daemon exit");
24245
+ }
24041
24246
  async function executeCloudCommand(command) {
24042
24247
  if (command.command === "daemon.update") {
24043
24248
  const version = command.payload?.version ?? "latest";
24044
24249
  setTimeout(() => {
24045
- import("./upgrade.js").then(({ upgradeAndExit }) => upgradeAndExit(version)).catch((err) => {
24250
+ drainRuntimeBeforeExit("daemon.update").then(() => import("./upgrade.js")).then(({ upgradeAndExit }) => upgradeAndExit(version)).catch((err) => {
24046
24251
  logger$1.error({ err: err instanceof Error ? err.message : String(err) }, "Upgrade failed");
24047
24252
  process.exit(1);
24048
24253
  });
@@ -24060,7 +24265,9 @@ async function executeCloudCommand(command) {
24060
24265
  if (command.command === "daemon.restart") {
24061
24266
  logger$1.info("Restart requested — exiting for systemd restart");
24062
24267
  setTimeout(() => {
24063
- process.exit(0);
24268
+ drainRuntimeBeforeExit("daemon.restart").then(() => {
24269
+ process.exit(0);
24270
+ });
24064
24271
  }, 500);
24065
24272
  return {
24066
24273
  type: "COMMAND_ACK",
@@ -24437,17 +24644,41 @@ function handleMcpListServers(manager = mcpManagerRef) {
24437
24644
  message: "MCP manager not initialized"
24438
24645
  }
24439
24646
  };
24647
+ const statuses = manager.serverStatuses ? manager.serverStatuses() : [];
24648
+ const byName = new Map(statuses.map((s) => [s.name, s]));
24440
24649
  return {
24441
24650
  ok: true,
24442
- payload: { servers: manager.listServers() }
24651
+ payload: { servers: manager.listServers().map(({ id, entry }) => ({
24652
+ id,
24653
+ entry,
24654
+ status: byName.get(id) ?? {
24655
+ name: id,
24656
+ connected: false,
24657
+ toolCount: 0,
24658
+ consecutiveFailures: 0
24659
+ }
24660
+ })) }
24443
24661
  };
24444
24662
  }
24445
24663
  /**
24446
- * Register a new MCP server in the alfe bundler store on behalf of the
24447
- * agent. Owned as `'manual'` so the agent can later remove it without
24448
- * an expectedOwner conflict — matches what `alfe mcp add` does from the
24449
- * CLI. The daemon's bundler will pick the change up via the manager's
24450
- * store watcher and spawn the child on the next tool-factory call.
24664
+ * How long the add-confirm probe waits for the freshly-added server to
24665
+ * connect before replying. This is effectively the whole budget for
24666
+ * `bundler.warmServer`: the `Manager.warmServer` pre-reconcile is cheap here
24667
+ * because `addServer` already queued the Connection object into the store +
24668
+ * bundler before we probe, so `reconcileBundler()` finds no diff and returns
24669
+ * fast. Kept well under the plugin caller's 30s IPC timeout. A slower server
24670
+ * still connects in the background and surfaces on the next
24671
+ * `alfe_mcp_list_tools` — the probe just reports what it saw within the window.
24672
+ */
24673
+ const MCP_ADD_WARM_TIMEOUT_MS = 12e3;
24674
+ /**
24675
+ * Register a new MCP server in the alfe bundler store on behalf of the agent,
24676
+ * then CONFIRM the connect before replying so the agent learns whether the
24677
+ * server actually works. Owned as `'manual'` so the agent can later remove it
24678
+ * without an expectedOwner conflict — matches what `alfe mcp add` does from the
24679
+ * CLI. Registration is durable regardless of the probe outcome: a probe
24680
+ * failure (or a runtime with no daemon bundler, e.g. hermes) still returns
24681
+ * `ok` with `connected: false`; the store watcher / runtime picks the entry up.
24451
24682
  */
24452
24683
  async function handleMcpAddServer(params, manager = mcpManagerRef) {
24453
24684
  if (!manager) return {
@@ -24478,10 +24709,6 @@ async function handleMcpAddServer(params, manager = mcpManagerRef) {
24478
24709
  id: p.id,
24479
24710
  owner: "manual"
24480
24711
  });
24481
- return {
24482
- ok: true,
24483
- payload: { id: p.id }
24484
- };
24485
24712
  } catch (err) {
24486
24713
  const message = err instanceof Error ? err.message : String(err);
24487
24714
  logger$1.warn({
@@ -24496,6 +24723,24 @@ async function handleMcpAddServer(params, manager = mcpManagerRef) {
24496
24723
  }
24497
24724
  };
24498
24725
  }
24726
+ let status = null;
24727
+ if (manager.warmServer) try {
24728
+ status = await manager.warmServer(p.id, MCP_ADD_WARM_TIMEOUT_MS);
24729
+ } catch (err) {
24730
+ logger$1.warn({
24731
+ id: p.id,
24732
+ err: err instanceof Error ? err.message : String(err)
24733
+ }, "mcp.add_server warm probe threw");
24734
+ }
24735
+ return {
24736
+ ok: true,
24737
+ payload: {
24738
+ id: p.id,
24739
+ connected: status?.connected ?? false,
24740
+ toolCount: status?.toolCount ?? 0,
24741
+ ...status?.lastError !== void 0 ? { error: status.lastError } : {}
24742
+ }
24743
+ };
24499
24744
  }
24500
24745
  function buildServerConfig(p) {
24501
24746
  if (typeof p.command === "string" && p.command.length > 0) {
@@ -67,7 +67,7 @@ async function upgradeRuntime(runtime, version, runtimeProcess) {
67
67
  }, "Upgrading runtime...");
68
68
  if (runtimeProcess) {
69
69
  logger.info("Stopping runtime for upgrade...");
70
- await runtimeProcess.stop();
70
+ await runtimeProcess.stopWhenIdle();
71
71
  }
72
72
  try {
73
73
  const { stdout, stderr } = await execFileAsync(cmd.command, cmd.args, { timeout: 12e4 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/gateway",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Alfe local gateway daemon — persistent control plane for agent integrations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,12 +23,12 @@
23
23
  "pino-roll": "^1.2.0",
24
24
  "smol-toml": ">=1.6.1",
25
25
  "ws": "^8.18.0",
26
- "@alfe.ai/agent-api-client": "^0.8.0",
26
+ "@alfe.ai/agent-api-client": "^0.10.0",
27
27
  "@alfe.ai/ai-proxy-local": "^0.0.13",
28
28
  "@alfe.ai/config": "^0.3.0",
29
29
  "@alfe.ai/integration-manifest": "^0.3.1",
30
- "@alfe.ai/integrations": "^0.4.1",
31
- "@alfe.ai/mcp-bundler": "^0.3.0"
30
+ "@alfe.ai/integrations": "^0.4.2",
31
+ "@alfe.ai/mcp-bundler": "^0.3.1"
32
32
  },
33
33
  "license": "UNLICENSED",
34
34
  "homepage": "https://alfe.ai",