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

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);
@@ -1585,17 +1594,7 @@ export class FleetManager {
1585
1594
  await data.respond(t("not_authorized"));
1586
1595
  return;
1587
1596
  }
1588
- try {
1589
- const { execSync } = await import("node:child_process");
1590
- const backend = this.fleetConfig?.defaults?.backend || "claude-code";
1591
- const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" });
1592
- const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1593
- await data.respond(clean || "No output");
1594
- }
1595
- catch (err) {
1596
- const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1597
- await data.respond(output);
1598
- }
1597
+ await data.respond(await this.runBackendDoctor());
1599
1598
  }
1600
1599
  else if (data.command === "status") {
1601
1600
  const text = await this.topicCommands.getStatusText();
@@ -1867,17 +1866,7 @@ export class FleetManager {
1867
1866
  await data.respond(t("not_authorized"));
1868
1867
  return;
1869
1868
  }
1870
- try {
1871
- const { execSync } = await import("node:child_process");
1872
- const backend = this.fleetConfig?.defaults?.backend || "claude-code";
1873
- const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" });
1874
- const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1875
- await data.respond(clean || "No output");
1876
- }
1877
- catch (err) {
1878
- const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1879
- await data.respond(output);
1880
- }
1869
+ await data.respond(await this.runBackendDoctor());
1881
1870
  }
1882
1871
  else if (data.command === "status") {
1883
1872
  const text = await this.topicCommands.getStatusText();
@@ -2105,9 +2094,22 @@ export class FleetManager {
2105
2094
  if (existsSync(windowIdPath)) {
2106
2095
  const windowId = readFileSync(windowIdPath, "utf-8").trim();
2107
2096
  if (windowId) {
2097
+ // Async with an explicit timeout: this was execSync with NO timeout at
2098
+ // all, so a wedged tmux server blocked the whole fleet event loop
2099
+ // indefinitely — while we were here to diagnose a lost connection.
2100
+ // A timeout is also the correct signal: an unresponsive tmux server
2101
+ // means we cannot verify the pane, which is treated as dead (the same
2102
+ // conclusion the old code reached only by throwing).
2108
2103
  try {
2109
- const { execSync } = await import("node:child_process");
2110
- execSync(`tmux list-panes -t "${windowId}"`, { stdio: "ignore" });
2104
+ const { execFile } = await import("node:child_process");
2105
+ const { promisify } = await import("node:util");
2106
+ const { getTmuxSocketName } = await import("./paths.js");
2107
+ // Honour socket isolation: without -L this queried the user's default
2108
+ // tmux server instead of the fleet's, so under a custom AGEND_HOME the
2109
+ // check was meaningless (it reported every pane dead).
2110
+ const socket = getTmuxSocketName();
2111
+ const args = socket ? ["-L", socket, "list-panes", "-t", windowId] : ["list-panes", "-t", windowId];
2112
+ await promisify(execFile)("tmux", args, { timeout: 5_000 });
2111
2113
  }
2112
2114
  catch {
2113
2115
  // Pane dead — respawn
@@ -3454,6 +3456,136 @@ export class FleetManager {
3454
3456
  this.collabInstances.add(instanceName);
3455
3457
  return true;
3456
3458
  }
3459
+ /**
3460
+ * Open the event log, tolerating a corrupt file.
3461
+ *
3462
+ * `events.db` holds history only — event rows and the activity feed. Nothing the
3463
+ * fleet needs to run depends on it, and every consumer already uses
3464
+ * `this.eventLog?.`. An unguarded `new EventLog(...)` here meant a corrupt or
3465
+ * unreadable history file (a truncated WAL after a hard kill, a full disk)
3466
+ * threw during startAll and the WHOLE FLEET FAILED TO BOOT — trading every
3467
+ * running agent for a file whose only job is reporting.
3468
+ *
3469
+ * So: try, move a bad file aside and retry once with a fresh one, and if even
3470
+ * that fails carry on without an event log.
3471
+ */
3472
+ /**
3473
+ * Run `agend backend doctor` for the fleet's default backend and return its
3474
+ * cleaned output.
3475
+ *
3476
+ * Async on purpose: this was `execSync` with a 30s timeout, reachable by any
3477
+ * allowlisted user through `/doctor`. While it ran, the entire fleet event loop
3478
+ * was frozen — no IPC, no adapter, no message delivery, no health responses,
3479
+ * and critically no WATCHDOG ping, so a slow doctor could push past
3480
+ * WatchdogSec and have systemd SIGABRT the fleet.
3481
+ */
3482
+ async runBackendDoctor() {
3483
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
3484
+ const backend = this.fleetConfig?.defaults?.backend || "claude-code";
3485
+ try {
3486
+ const { execFile } = await import("node:child_process");
3487
+ const { promisify } = await import("node:util");
3488
+ // execFile with an argv array — no shell, so the backend name cannot be
3489
+ // interpreted as a command even if config is malformed.
3490
+ const { stdout } = await promisify(execFile)("agend", ["backend", "doctor", backend], {
3491
+ timeout: 30_000,
3492
+ encoding: "utf-8",
3493
+ });
3494
+ return stripAnsi(stdout) || "No output";
3495
+ }
3496
+ catch (err) {
3497
+ const e = err;
3498
+ return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
3499
+ }
3500
+ }
3501
+ /** Drop event/activity rows older than the retention window. Best-effort. */
3502
+ pruneEventLog() {
3503
+ try {
3504
+ this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
3505
+ }
3506
+ catch (err) {
3507
+ this.logger.warn({ err }, "Event log prune failed");
3508
+ }
3509
+ }
3510
+ openEventLog() {
3511
+ const dbPath = join(this.dataDir, "events.db");
3512
+ try {
3513
+ return new EventLog(dbPath);
3514
+ }
3515
+ catch (err) {
3516
+ this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
3517
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
3518
+ for (const suffix of ["", "-wal", "-shm"]) {
3519
+ try {
3520
+ renameSync(`${dbPath}${suffix}`, `${dbPath}${suffix}.corrupt-${stamp}`);
3521
+ }
3522
+ catch { /* may not exist */ }
3523
+ }
3524
+ try {
3525
+ return new EventLog(dbPath);
3526
+ }
3527
+ catch (retryErr) {
3528
+ // History is worth losing; a fleet that won't start is not.
3529
+ this.logger.error({ err: retryErr, dbPath }, "Could not open a fresh events.db — continuing without event logging");
3530
+ return null;
3531
+ }
3532
+ }
3533
+ }
3534
+ /**
3535
+ * Report a fleet-level fault (not attributable to one instance) to the General
3536
+ * topic, so the operator learns about it without reading daemon.log.
3537
+ *
3538
+ * Throttled per distinct message: an unhandled rejection typically comes from a
3539
+ * loop (a poller, a repeating timer), and one channel message per occurrence
3540
+ * would bury the topic — which is worse than silence. First occurrence goes out
3541
+ * immediately, repeats are suppressed for THROTTLE_MS and then re-sent with a
3542
+ * count.
3543
+ *
3544
+ * The log line is written by the caller regardless: if every adapter is down,
3545
+ * the only notification path is the one that is broken.
3546
+ */
3547
+ notifyFleetError(text) {
3548
+ const now = Date.now();
3549
+ const key = text.slice(0, 200);
3550
+ const seen = this.fleetErrorNotices.get(key);
3551
+ if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
3552
+ seen.suppressed++;
3553
+ return;
3554
+ }
3555
+ const suppressed = seen?.suppressed ?? 0;
3556
+ this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
3557
+ // Bound the map: it is keyed by message text, and a message with a varying
3558
+ // suffix (a path, an id) would otherwise grow it without limit.
3559
+ if (this.fleetErrorNotices.size > 100) {
3560
+ const oldest = this.fleetErrorNotices.keys().next().value;
3561
+ if (oldest !== undefined)
3562
+ this.fleetErrorNotices.delete(oldest);
3563
+ }
3564
+ const body = suppressed > 0
3565
+ ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
3566
+ : text;
3567
+ // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
3568
+ // and a fleet-level fault is exactly when the General may be down. The topic
3569
+ // itself still exists, and notifyInstanceTopic only needs adapter + group +
3570
+ // topic_id to post into it.
3571
+ const general = Object.entries(this.fleetConfig?.instances ?? {})
3572
+ .find(([, config]) => config.general_topic === true)?.[0];
3573
+ if (general) {
3574
+ this.notifyInstanceTopic(general, body);
3575
+ return;
3576
+ }
3577
+ // No General instance — fall back to the primary channel's group.
3578
+ const channelCfg = this.getChannelConfig();
3579
+ const groupId = channelCfg?.group_id;
3580
+ if (this.adapter && groupId) {
3581
+ this.adapter.sendText(String(groupId), body)
3582
+ .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
3583
+ return;
3584
+ }
3585
+ this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
3586
+ }
3587
+ static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
3588
+ fleetErrorNotices = new Map();
3457
3589
  notifyInstanceTopic(instanceName, text, extraOpts) {
3458
3590
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3459
3591
  if (!adapter)
@@ -4581,9 +4713,19 @@ When users create specialized instances, suggest these configurations:
4581
4713
  // DC path: respond immediately with progress text
4582
4714
  await pending.respond(progressText).catch(() => { });
4583
4715
  }
4584
- // Apply model in background — don't await here (keeps callback handler fast)
4716
+ // Apply model in background — don't await here (keeps callback handler fast).
4717
+ // Guarded: applyModel() restarts the instance, and an unguarded rejection here
4718
+ // meant a user picking from the /model menu could take the whole fleet down.
4719
+ // On failure the user gets told, rather than the click silently doing nothing.
4585
4720
  void (async () => {
4586
- const result = await this.applyModel(pending.instanceName, model);
4721
+ let result;
4722
+ try {
4723
+ result = await this.applyModel(pending.instanceName, model);
4724
+ }
4725
+ catch (err) {
4726
+ this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
4727
+ result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
4728
+ }
4587
4729
  if (pending.adapter && pending.adapterChatId) {
4588
4730
  if (progressMsgId) {
4589
4731
  pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
@@ -4863,7 +5005,20 @@ When users create specialized instances, suggest these configurations:
4863
5005
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4864
5006
  return t("classic.stopped");
4865
5007
  }
4866
- async stopAll() {
5008
+ /**
5009
+ * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
5010
+ * uncaughtException path calls this too, so overlapping runs were possible —
5011
+ * each snapshotting the daemon map and calling stop() on the same daemons
5012
+ * concurrently. Deliberately NOT `async`, so callers receive the same promise
5013
+ * object rather than a fresh wrapper around it. The latch clears when the run
5014
+ * settles, so a later genuine stop (after a restart) still does the work.
5015
+ */
5016
+ stopAll() {
5017
+ this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
5018
+ return this.stopAllInFlight;
5019
+ }
5020
+ stopAllInFlight = null;
5021
+ async doStopAll() {
4867
5022
  this.startupComplete = false;
4868
5023
  this.reloadPending = false;
4869
5024
  this.ipcStoppingInstances.add("__fleet_stopping__");
@@ -4887,6 +5042,10 @@ When users create specialized instances, suggest these configurations:
4887
5042
  clearInterval(this.updateCheckTimer);
4888
5043
  this.updateCheckTimer = null;
4889
5044
  }
5045
+ if (this.eventLogPruneTimer) {
5046
+ clearInterval(this.eventLogPruneTimer);
5047
+ this.eventLogPruneTimer = null;
5048
+ }
4890
5049
  if (this.topicCleanupTimer) {
4891
5050
  clearInterval(this.topicCleanupTimer);
4892
5051
  this.topicCleanupTimer = null;
@@ -5304,10 +5463,20 @@ When users create specialized instances, suggest these configurations:
5304
5463
  // ── Update check ────────────────────────────────────────────────────
5305
5464
  async checkForUpdates() {
5306
5465
  try {
5307
- const { execSync } = await import("node:child_process");
5466
+ // Both npm lookups are async: as execSync they froze the fleet event loop for
5467
+ // up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
5468
+ // past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
5469
+ // for a background version check.
5470
+ const { execFile } = await import("node:child_process");
5471
+ const { promisify } = await import("node:util");
5472
+ const execFileP = promisify(execFile);
5473
+ const npmVersion = async (spec) => {
5474
+ const { stdout } = await execFileP("npm", ["view", spec, "version"], { timeout: 15_000 });
5475
+ return stdout.toString().trim();
5476
+ };
5308
5477
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
5309
5478
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
5310
- const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
5479
+ const latest = await npmVersion("@songsid/agend");
5311
5480
  let target = latest;
5312
5481
  if (currentVersion.includes("-beta")) {
5313
5482
  // Beta users track the @beta channel (never fall back to @latest, which is
@@ -5315,7 +5484,7 @@ When users create specialized instances, suggest these configurations:
5315
5484
  // of beta/latest is the newest.
5316
5485
  let beta = "";
5317
5486
  try {
5318
- beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
5487
+ beta = await npmVersion("@songsid/agend@beta");
5319
5488
  }
5320
5489
  catch { /* no beta tag */ }
5321
5490
  target = beta || latest;
@@ -5584,7 +5753,10 @@ When users create specialized instances, suggest these configurations:
5584
5753
  res.writeHead(500);
5585
5754
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
5586
5755
  }
5587
- })();
5756
+ // The inner catch can itself throw (writeHead after a successful
5757
+ // writeHead is ERR_HTTP_HEADERS_SENT), and that rejection escapes the
5758
+ // IIFE. Same for the two handlers below.
5759
+ })().catch(err => this.logger.error({ err, name }, "HTTP start handler failed"));
5588
5760
  return;
5589
5761
  }
5590
5762
  // Instance restart (immediate, no idle wait)
@@ -5605,7 +5777,7 @@ When users create specialized instances, suggest these configurations:
5605
5777
  res.writeHead(status);
5606
5778
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
5607
5779
  }
5608
- })();
5780
+ })().catch(err => this.logger.error({ err, name }, "HTTP restart handler failed"));
5609
5781
  return;
5610
5782
  }
5611
5783
  if (req.method === "POST" && req.url?.startsWith("/stop/")) {
@@ -5628,7 +5800,7 @@ When users create specialized instances, suggest these configurations:
5628
5800
  res.writeHead(500);
5629
5801
  res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
5630
5802
  }
5631
- })();
5803
+ })().catch(err => this.logger.error({ err, name }, "HTTP stop handler failed"));
5632
5804
  return;
5633
5805
  }
5634
5806
  // ── Agent CLI endpoint ─────