@songsid/agend 2.1.4-beta.51 → 2.1.4-beta.53

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.
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { freemem } from "node:os";
4
+ import { freemem, totalmem } from "node:os";
5
5
  import { createServer } from "node:http";
6
6
  import { join, dirname, basename } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -567,7 +567,8 @@ export class FleetManager {
567
567
  getSysInfo() {
568
568
  const mem = process.memoryUsage();
569
569
  const toMB = (b) => Math.round(b / 1024 / 1024 * 10) / 10;
570
- const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
570
+ // Fleet instances (fleet.yaml)
571
+ const fleetInstances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
571
572
  name,
572
573
  status: this.getInstanceStatus(name),
573
574
  state: this.getInstanceExecutionState(name),
@@ -575,12 +576,46 @@ export class FleetManager {
575
576
  costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
576
577
  rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
577
578
  }));
579
+ // Classic instances (classicBot.yaml) — dedupe against fleet
580
+ const fleetNames = new Set(fleetInstances.map(i => i.name));
581
+ const classicInstances = (this.classicChannels?.getAll() ?? [])
582
+ .filter(ch => !fleetNames.has(ch.instanceName))
583
+ .map(ch => ({
584
+ name: ch.instanceName,
585
+ status: this.getInstanceStatus(ch.instanceName),
586
+ state: this.getInstanceExecutionState(ch.instanceName),
587
+ ipc: this.instanceIpcClients.has(ch.instanceName),
588
+ costCents: this.costGuard?.getDailyCostCents(ch.instanceName) ?? 0,
589
+ rateLimits: this.statuslineWatcher.getRateLimits(ch.instanceName) ?? null,
590
+ }));
591
+ // Combined roster (matches /api/fleet and agend ls)
592
+ const allInstances = [...fleetInstances, ...classicInstances];
593
+ // Fleet summary counts (fleet + Classic combined)
594
+ const running_count = allInstances.filter(i => i.status === "running").length;
595
+ const paused_count = allInstances.filter(i => i.status === "paused").length;
596
+ // System memory (GB, 1 decimal)
597
+ const totalGB = totalmem() / (1024 ** 3);
598
+ const usedGB = (totalmem() - freemem()) / (1024 ** 3);
599
+ const system_mem_gb = {
600
+ used: Math.round(usedGB * 10) / 10,
601
+ total: Math.round(totalGB * 10) / 10,
602
+ };
603
+ // Fleet memory: O(1) cgroup read (includes entire service tree: fleet + CLIs + MCP servers)
604
+ // This avoids blocking the event loop with per-instance tree scans.
605
+ const fleetMem = readFleetMemory();
606
+ const fleet_mem_mb = fleetMem.cgroupAnonBytes != null
607
+ ? Math.round(fleetMem.cgroupAnonBytes / (1024 * 1024) * 10) / 10
608
+ : null;
578
609
  return {
579
610
  uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1000),
580
611
  memory_mb: { rss: toMB(mem.rss), heapUsed: toMB(mem.heapUsed), heapTotal: toMB(mem.heapTotal) },
581
- instances,
612
+ instances: fleetInstances, // SysInfo.instances is fleet-only; /api/fleet enriches with Classic
582
613
  fleet_cost_cents: this.costGuard?.getFleetTotalCents() ?? 0,
583
614
  fleet_cost_limit_cents: this.costGuard?.getLimitCents() ?? 0,
615
+ running_count,
616
+ paused_count,
617
+ fleet_mem_mb,
618
+ system_mem_gb,
584
619
  };
585
620
  }
586
621
  /** Load fleet.yaml and build routing table */
@@ -1334,6 +1369,7 @@ export class FleetManager {
1334
1369
  throw new Error("Classic channel manager not initialized");
1335
1370
  const wasRunning = this.daemons.has(instanceName);
1336
1371
  this.classicChannels.reloadFromDisk();
1372
+ this.reportClassicUnrecoverableIds();
1337
1373
  this.reregisterClassicChannels();
1338
1374
  const channel = this.classicChannels.getAll().find(item => item.instanceName === instanceName);
1339
1375
  if (!channel)
@@ -2000,6 +2036,11 @@ export class FleetManager {
2000
2036
  this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
2001
2037
  const classicAdapters = fleet.channels?.length ? fleet.channels : (fleet.channel ? [fleet.channel] : []);
2002
2038
  this.classicChannels.configureAdapters(classicAdapters);
2039
+ // The unrecoverable-id report is deliberately NOT sent here: no adapter
2040
+ // exists yet at this point in startAll(), so notifyFleetError would find
2041
+ // nothing to deliver through, drop the message silently, AND burn its
2042
+ // 10-minute throttle key on the way out. It is sent once the shared adapter
2043
+ // is up — see the adapterStartup continuation below.
2003
2044
  // Restore the persisted bot binding so replies/cancel go through the right
2004
2045
  // bot after a restart (before this, inbound would re-bind lazily).
2005
2046
  for (const ch of this.classicChannels.getAll()) {
@@ -2023,6 +2064,9 @@ export class FleetManager {
2023
2064
  }
2024
2065
  if (!this.classicChannels.checkReload())
2025
2066
  return;
2067
+ // A reload can introduce a bad id (hand edit) or clear one; the
2068
+ // throttle keeps a repeated report from flooding the topic.
2069
+ this.reportClassicUnrecoverableIds();
2026
2070
  this.reregisterClassicChannels();
2027
2071
  for (const ch of this.classicChannels.getAll()) {
2028
2072
  const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
@@ -2257,6 +2301,10 @@ export class FleetManager {
2257
2301
  catch (err) {
2258
2302
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
2259
2303
  }
2304
+ // Now that there is somewhere to deliver: a classicBot.yaml that already
2305
+ // holds an unmatchable id at boot is the COMMON case, and reporting it
2306
+ // before the adapter existed meant the operator heard nothing at all.
2307
+ this.reportClassicUnrecoverableIds();
2260
2308
  })();
2261
2309
  progressStart = adapterStartup.then(() => {
2262
2310
  if (pendingUpdateProgress) {
@@ -2913,7 +2961,8 @@ export class FleetManager {
2913
2961
  await data.respond(text);
2914
2962
  }
2915
2963
  else if (data.command === "sysinfo") {
2916
- await data.respond(this.topicCommands.getSysInfoText());
2964
+ // Slash commands are Discord-only; use plain lines (no markdown table)
2965
+ await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
2917
2966
  }
2918
2967
  else if (data.command === "dashboard") {
2919
2968
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -3259,7 +3308,8 @@ export class FleetManager {
3259
3308
  await data.respond(text);
3260
3309
  }
3261
3310
  else if (data.command === "sysinfo") {
3262
- await data.respond(this.topicCommands.getSysInfoText());
3311
+ // Slash commands are Discord-only; use plain lines (no markdown table)
3312
+ await data.respond(this.topicCommands.getSysInfoText({ platform: "discord" }));
3263
3313
  }
3264
3314
  else if (data.command === "dashboard") {
3265
3315
  // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
@@ -5357,6 +5407,31 @@ export class FleetManager {
5357
5407
  * The log line is written by the caller regardless: if every adapter is down,
5358
5408
  * the only notification path is the one that is broken.
5359
5409
  */
5410
+ /**
5411
+ * Surface ids that can never match into the operator's General topic.
5412
+ *
5413
+ * The load-time log is still silence for anyone not reading daemon.log, and a
5414
+ * chat that never gets in with nothing said anywhere is exactly the failure
5415
+ * this line of work exists to remove. notifyFleetError throttles by message
5416
+ * text for 10 minutes and resolves its target from config, so the 30s reload
5417
+ * poll cannot flood the topic and a down General does not swallow it.
5418
+ */
5419
+ reportClassicUnrecoverableIds() {
5420
+ const bad = this.classicChannels?.getUnrecoverableIds?.() ?? [];
5421
+ if (bad.length === 0)
5422
+ return;
5423
+ // notifyFleetError claims its throttle key BEFORE attempting delivery, so
5424
+ // calling it with no adapter would silence this message for ten minutes
5425
+ // without anyone having seen it. Log and leave the key unspent; a later
5426
+ // call, once an adapter exists, still gets through.
5427
+ if (!this.adapter && this.adapters.size === 0) {
5428
+ this.logger.error({ ids: bad }, "classicBot.yaml holds ids that can never match — deferring the operator notice until an adapter is up");
5429
+ return;
5430
+ }
5431
+ const list = bad.map(e => `${e.field}: ${e.value}`).join(", ");
5432
+ this.logger.error({ ids: bad }, "classicBot.yaml holds ids that can never match");
5433
+ this.notifyFleetError(t("classic.unrecoverable_ids", list));
5434
+ }
5360
5435
  notifyFleetError(text) {
5361
5436
  const now = Date.now();
5362
5437
  const key = text.slice(0, 200);
@@ -9764,72 +9839,81 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9764
9839
  }
9765
9840
  // Fleet API (enriched for agent board)
9766
9841
  if (req.method === "GET" && req.url === "/api/fleet") {
9767
- const sysInfo = this.getSysInfo();
9768
- const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
9769
- const fleetNames = new Set(fleetInstances.map(inst => inst.name));
9770
- const classicInstances = (this.classicChannels?.getAll() ?? [])
9771
- .filter(channel => !fleetNames.has(channel.instanceName))
9772
- .map(channel => ({
9773
- name: channel.instanceName,
9774
- status: this.getInstanceStatus(channel.instanceName),
9775
- state: this.getInstanceExecutionState(channel.instanceName),
9776
- ipc: this.instanceIpcClients.has(channel.instanceName),
9777
- costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
9778
- rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
9779
- classic: true,
9780
- classicName: channel.name,
9781
- channelId: channel.channelId,
9782
- adapterId: channel.adapterId ?? null,
9783
- }));
9784
- const enriched = [...fleetInstances, ...classicInstances].map(inst => {
9785
- const config = this.fleetConfig?.instances[inst.name];
9786
- const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
9787
- const lastActivity = inst.classic
9788
- ? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
9789
- : (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
9790
- const backend = this.backendNameForInstance(inst.name);
9791
- const resolvedModel = this.resolveInstanceModel(inst.name);
9792
- const effortStrategy = this.effortStrategyFor(inst.name);
9793
- const resolvedEffort = this.resolveInstanceEffort(inst.name);
9794
- // Find claimed tasks for this instance
9795
- let currentTask = null;
9796
- try {
9797
- const tasks = this.scheduler?.db.listTasks({ assignee: inst.name, status: "claimed" });
9798
- if (tasks?.length)
9799
- currentTask = tasks[0].title;
9800
- }
9801
- catch (err) {
9802
- this.logger.debug({ err, name: inst.name }, "Scheduler listTasks failed (/api/fleet)");
9842
+ try {
9843
+ const sysInfo = this.getSysInfo();
9844
+ const fleetInstances = sysInfo.instances.map(inst => ({ ...inst, classic: false }));
9845
+ const fleetNames = new Set(fleetInstances.map(inst => inst.name));
9846
+ const classicInstances = (this.classicChannels?.getAll() ?? [])
9847
+ .filter(channel => !fleetNames.has(channel.instanceName))
9848
+ .map(channel => ({
9849
+ name: channel.instanceName,
9850
+ status: this.getInstanceStatus(channel.instanceName),
9851
+ state: this.getInstanceExecutionState(channel.instanceName),
9852
+ ipc: this.instanceIpcClients.has(channel.instanceName),
9853
+ costCents: this.costGuard?.getDailyCostCents(channel.instanceName) ?? 0,
9854
+ rateLimits: this.statuslineWatcher.getRateLimits(channel.instanceName) ?? null,
9855
+ classic: true,
9856
+ classicName: channel.name,
9857
+ channelId: channel.channelId,
9858
+ adapterId: channel.adapterId ?? null,
9859
+ }));
9860
+ const enriched = [...fleetInstances, ...classicInstances].map(inst => {
9861
+ const config = this.fleetConfig?.instances[inst.name];
9862
+ const persistedInboundAt = readLastInboundAt(this.getInstanceDir(inst.name));
9863
+ const lastActivity = inst.classic
9864
+ ? Math.max(persistedInboundAt ?? 0, readClassicLastActivityAt(this.dataDir, inst.name) ?? 0) || null
9865
+ : (persistedInboundAt ?? this.lastActivityMs(inst.name)) || null;
9866
+ const backend = this.backendNameForInstance(inst.name);
9867
+ const resolvedModel = this.resolveInstanceModel(inst.name);
9868
+ const effortStrategy = this.effortStrategyFor(inst.name);
9869
+ const resolvedEffort = this.resolveInstanceEffort(inst.name);
9870
+ // Find claimed tasks for this instance
9871
+ let currentTask = null;
9872
+ try {
9873
+ const tasks = this.scheduler?.db.listTasks({ assignee: inst.name, status: "claimed" });
9874
+ if (tasks?.length)
9875
+ currentTask = tasks[0].title;
9876
+ }
9877
+ catch (err) {
9878
+ this.logger.debug({ err, name: inst.name }, "Scheduler listTasks failed (/api/fleet)");
9879
+ }
9880
+ return {
9881
+ ...inst,
9882
+ description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
9883
+ backend,
9884
+ // Settings renders these runtime-effective values rather than the
9885
+ // sparse user-authored YAML. `auto` means the supported CLI is
9886
+ // using its own effort default; null is reserved for unsupported.
9887
+ model: resolvedModel.model,
9888
+ model_display: resolvedModel.display,
9889
+ model_source: resolvedModel.source,
9890
+ effort: effortStrategy === "unsupported" ? null : (resolvedEffort.effort ?? "auto"),
9891
+ effort_supported: effortStrategy !== "unsupported",
9892
+ tool_set: config?.tool_set ?? "full",
9893
+ general_topic: config?.general_topic ?? false,
9894
+ // User activity is persisted by the daemon, so both the board and
9895
+ // auto-pause retain an accurate age across fleet restarts.
9896
+ lastActivity,
9897
+ currentTask,
9898
+ idle: this.getInstanceIdle(inst.name),
9899
+ state: this.getInstanceExecutionState(inst.name),
9900
+ };
9901
+ });
9902
+ res.setHeader("Access-Control-Allow-Origin", "*");
9903
+ res.writeHead(200);
9904
+ res.end(JSON.stringify({
9905
+ ...sysInfo,
9906
+ version: this.currentVersion,
9907
+ instances: enriched,
9908
+ }));
9909
+ }
9910
+ catch (err) {
9911
+ this.logger.error({ err }, "/api/fleet failed");
9912
+ if (!res.headersSent) {
9913
+ res.writeHead(500);
9914
+ res.end("Internal Server Error");
9803
9915
  }
9804
- return {
9805
- ...inst,
9806
- description: config?.description ?? ("classicName" in inst ? inst.classicName : null),
9807
- backend,
9808
- // Settings renders these runtime-effective values rather than the
9809
- // sparse user-authored YAML. `auto` means the supported CLI is
9810
- // using its own effort default; null is reserved for unsupported.
9811
- model: resolvedModel.model,
9812
- model_display: resolvedModel.display,
9813
- model_source: resolvedModel.source,
9814
- effort: effortStrategy === "unsupported" ? null : (resolvedEffort.effort ?? "auto"),
9815
- effort_supported: effortStrategy !== "unsupported",
9816
- tool_set: config?.tool_set ?? "full",
9817
- general_topic: config?.general_topic ?? false,
9818
- // User activity is persisted by the daemon, so both the board and
9819
- // auto-pause retain an accurate age across fleet restarts.
9820
- lastActivity,
9821
- currentTask,
9822
- idle: this.getInstanceIdle(inst.name),
9823
- state: this.getInstanceExecutionState(inst.name),
9824
- };
9825
- });
9826
- res.setHeader("Access-Control-Allow-Origin", "*");
9827
- res.writeHead(200);
9828
- res.end(JSON.stringify({
9829
- ...sysInfo,
9830
- version: this.currentVersion,
9831
- instances: enriched,
9832
- }));
9916
+ }
9833
9917
  return;
9834
9918
  }
9835
9919
  // Activity API