@songsid/agend 2.1.1-beta.13 → 2.1.1-beta.14

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.
@@ -177,6 +177,9 @@ export class FleetManager {
177
177
  healthServer = null;
178
178
  healthPortRetried = false;
179
179
  updateCheckTimer = null;
180
+ eventLogPruneTimer = null;
181
+ /** Days of event/activity history to keep. */
182
+ static EVENT_LOG_RETENTION_DAYS = 30;
180
183
  watchdogTimer = null;
181
184
  startedAt = 0;
182
185
  // Mirror topic: buffer cross-instance messages, flush every 3s
@@ -898,7 +901,7 @@ export class FleetManager {
898
901
  }
899
902
  const pidPath = join(this.dataDir, "fleet.pid");
900
903
  writeFileSync(pidPath, String(process.pid), "utf-8");
901
- this.eventLog = new EventLog(join(this.dataDir, "events.db"));
904
+ this.eventLog = this.openEventLog();
902
905
  // Initialize classic channel manager. The primary adapter (channels[0])
903
906
  // migrates legacy single-bot entries and names without a suffix. Classic
904
907
  // routing does NOT go through the routing engine (single-key, can't hold two
@@ -1134,6 +1137,12 @@ export class FleetManager {
1134
1137
  // Signal systemd: generals ready
1135
1138
  sdNotify("READY=1");
1136
1139
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
1140
+ // EventLog.prune() existed but was never called, so `events` and `activity`
1141
+ // grew without bound for the life of the install. Prune once at startup and
1142
+ // daily after that; the timer is unref'd so it never holds the loop open.
1143
+ this.pruneEventLog();
1144
+ this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
1145
+ this.eventLogPruneTimer.unref?.();
1137
1146
  // Phase 2: Start remaining instances with staggered concurrency
1138
1147
  if (others.length > 0) {
1139
1148
  await this.startInstancesWithConcurrency(others, topicMode);
@@ -3454,6 +3463,107 @@ export class FleetManager {
3454
3463
  this.collabInstances.add(instanceName);
3455
3464
  return true;
3456
3465
  }
3466
+ /**
3467
+ * Open the event log, tolerating a corrupt file.
3468
+ *
3469
+ * `events.db` holds history only — event rows and the activity feed. Nothing the
3470
+ * fleet needs to run depends on it, and every consumer already uses
3471
+ * `this.eventLog?.`. An unguarded `new EventLog(...)` here meant a corrupt or
3472
+ * unreadable history file (a truncated WAL after a hard kill, a full disk)
3473
+ * threw during startAll and the WHOLE FLEET FAILED TO BOOT — trading every
3474
+ * running agent for a file whose only job is reporting.
3475
+ *
3476
+ * So: try, move a bad file aside and retry once with a fresh one, and if even
3477
+ * that fails carry on without an event log.
3478
+ */
3479
+ /** Drop event/activity rows older than the retention window. Best-effort. */
3480
+ pruneEventLog() {
3481
+ try {
3482
+ this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
3483
+ }
3484
+ catch (err) {
3485
+ this.logger.warn({ err }, "Event log prune failed");
3486
+ }
3487
+ }
3488
+ openEventLog() {
3489
+ const dbPath = join(this.dataDir, "events.db");
3490
+ try {
3491
+ return new EventLog(dbPath);
3492
+ }
3493
+ catch (err) {
3494
+ this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
3495
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
3496
+ for (const suffix of ["", "-wal", "-shm"]) {
3497
+ try {
3498
+ renameSync(`${dbPath}${suffix}`, `${dbPath}${suffix}.corrupt-${stamp}`);
3499
+ }
3500
+ catch { /* may not exist */ }
3501
+ }
3502
+ try {
3503
+ return new EventLog(dbPath);
3504
+ }
3505
+ catch (retryErr) {
3506
+ // History is worth losing; a fleet that won't start is not.
3507
+ this.logger.error({ err: retryErr, dbPath }, "Could not open a fresh events.db — continuing without event logging");
3508
+ return null;
3509
+ }
3510
+ }
3511
+ }
3512
+ /**
3513
+ * Report a fleet-level fault (not attributable to one instance) to the General
3514
+ * topic, so the operator learns about it without reading daemon.log.
3515
+ *
3516
+ * Throttled per distinct message: an unhandled rejection typically comes from a
3517
+ * loop (a poller, a repeating timer), and one channel message per occurrence
3518
+ * would bury the topic — which is worse than silence. First occurrence goes out
3519
+ * immediately, repeats are suppressed for THROTTLE_MS and then re-sent with a
3520
+ * count.
3521
+ *
3522
+ * The log line is written by the caller regardless: if every adapter is down,
3523
+ * the only notification path is the one that is broken.
3524
+ */
3525
+ notifyFleetError(text) {
3526
+ const now = Date.now();
3527
+ const key = text.slice(0, 200);
3528
+ const seen = this.fleetErrorNotices.get(key);
3529
+ if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
3530
+ seen.suppressed++;
3531
+ return;
3532
+ }
3533
+ const suppressed = seen?.suppressed ?? 0;
3534
+ this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
3535
+ // Bound the map: it is keyed by message text, and a message with a varying
3536
+ // suffix (a path, an id) would otherwise grow it without limit.
3537
+ if (this.fleetErrorNotices.size > 100) {
3538
+ const oldest = this.fleetErrorNotices.keys().next().value;
3539
+ if (oldest !== undefined)
3540
+ this.fleetErrorNotices.delete(oldest);
3541
+ }
3542
+ const body = suppressed > 0
3543
+ ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
3544
+ : text;
3545
+ // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
3546
+ // and a fleet-level fault is exactly when the General may be down. The topic
3547
+ // itself still exists, and notifyInstanceTopic only needs adapter + group +
3548
+ // topic_id to post into it.
3549
+ const general = Object.entries(this.fleetConfig?.instances ?? {})
3550
+ .find(([, config]) => config.general_topic === true)?.[0];
3551
+ if (general) {
3552
+ this.notifyInstanceTopic(general, body);
3553
+ return;
3554
+ }
3555
+ // No General instance — fall back to the primary channel's group.
3556
+ const channelCfg = this.getChannelConfig();
3557
+ const groupId = channelCfg?.group_id;
3558
+ if (this.adapter && groupId) {
3559
+ this.adapter.sendText(String(groupId), body)
3560
+ .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
3561
+ return;
3562
+ }
3563
+ this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
3564
+ }
3565
+ static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
3566
+ fleetErrorNotices = new Map();
3457
3567
  notifyInstanceTopic(instanceName, text, extraOpts) {
3458
3568
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3459
3569
  if (!adapter)
@@ -4581,9 +4691,19 @@ When users create specialized instances, suggest these configurations:
4581
4691
  // DC path: respond immediately with progress text
4582
4692
  await pending.respond(progressText).catch(() => { });
4583
4693
  }
4584
- // Apply model in background — don't await here (keeps callback handler fast)
4694
+ // Apply model in background — don't await here (keeps callback handler fast).
4695
+ // Guarded: applyModel() restarts the instance, and an unguarded rejection here
4696
+ // meant a user picking from the /model menu could take the whole fleet down.
4697
+ // On failure the user gets told, rather than the click silently doing nothing.
4585
4698
  void (async () => {
4586
- const result = await this.applyModel(pending.instanceName, model);
4699
+ let result;
4700
+ try {
4701
+ result = await this.applyModel(pending.instanceName, model);
4702
+ }
4703
+ catch (err) {
4704
+ this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
4705
+ result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
4706
+ }
4587
4707
  if (pending.adapter && pending.adapterChatId) {
4588
4708
  if (progressMsgId) {
4589
4709
  pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
@@ -4863,7 +4983,20 @@ When users create specialized instances, suggest these configurations:
4863
4983
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4864
4984
  return t("classic.stopped");
4865
4985
  }
4866
- async stopAll() {
4986
+ /**
4987
+ * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
4988
+ * uncaughtException path calls this too, so overlapping runs were possible —
4989
+ * each snapshotting the daemon map and calling stop() on the same daemons
4990
+ * concurrently. Deliberately NOT `async`, so callers receive the same promise
4991
+ * object rather than a fresh wrapper around it. The latch clears when the run
4992
+ * settles, so a later genuine stop (after a restart) still does the work.
4993
+ */
4994
+ stopAll() {
4995
+ this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
4996
+ return this.stopAllInFlight;
4997
+ }
4998
+ stopAllInFlight = null;
4999
+ async doStopAll() {
4867
5000
  this.startupComplete = false;
4868
5001
  this.reloadPending = false;
4869
5002
  this.ipcStoppingInstances.add("__fleet_stopping__");
@@ -4887,6 +5020,10 @@ When users create specialized instances, suggest these configurations:
4887
5020
  clearInterval(this.updateCheckTimer);
4888
5021
  this.updateCheckTimer = null;
4889
5022
  }
5023
+ if (this.eventLogPruneTimer) {
5024
+ clearInterval(this.eventLogPruneTimer);
5025
+ this.eventLogPruneTimer = null;
5026
+ }
4890
5027
  if (this.topicCleanupTimer) {
4891
5028
  clearInterval(this.topicCleanupTimer);
4892
5029
  this.topicCleanupTimer = null;
@@ -5584,7 +5721,10 @@ When users create specialized instances, suggest these configurations:
5584
5721
  res.writeHead(500);
5585
5722
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
5586
5723
  }
5587
- })();
5724
+ // The inner catch can itself throw (writeHead after a successful
5725
+ // writeHead is ERR_HTTP_HEADERS_SENT), and that rejection escapes the
5726
+ // IIFE. Same for the two handlers below.
5727
+ })().catch(err => this.logger.error({ err, name }, "HTTP start handler failed"));
5588
5728
  return;
5589
5729
  }
5590
5730
  // Instance restart (immediate, no idle wait)
@@ -5605,7 +5745,7 @@ When users create specialized instances, suggest these configurations:
5605
5745
  res.writeHead(status);
5606
5746
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
5607
5747
  }
5608
- })();
5748
+ })().catch(err => this.logger.error({ err, name }, "HTTP restart handler failed"));
5609
5749
  return;
5610
5750
  }
5611
5751
  if (req.method === "POST" && req.url?.startsWith("/stop/")) {
@@ -5628,7 +5768,7 @@ When users create specialized instances, suggest these configurations:
5628
5768
  res.writeHead(500);
5629
5769
  res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
5630
5770
  }
5631
- })();
5771
+ })().catch(err => this.logger.error({ err, name }, "HTTP stop handler failed"));
5632
5772
  return;
5633
5773
  }
5634
5774
  // ── Agent CLI endpoint ─────