@songsid/agend 2.1.2-beta.46 → 2.1.2-beta.48

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.
@@ -51,6 +51,7 @@ import { readLastInboundAt } from "./daemon.js";
51
51
  import { clearPausedMarker } from "./pause-marker.js";
52
52
  import { releaseProcessFleetLock } from "./fleet-lock.js";
53
53
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
54
+ import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
54
55
  import { getTmuxSession } from "./config.js";
55
56
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
56
57
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -285,6 +286,7 @@ export class FleetManager {
285
286
  sseClients = new Set();
286
287
  webToken = null;
287
288
  viewToken = null;
289
+ healthServerListening = false;
288
290
  constructor(dataDir) {
289
291
  this.dataDir = dataDir;
290
292
  FleetManager.signalTarget = this;
@@ -1060,6 +1062,17 @@ export class FleetManager {
1060
1062
  * TODO: per-instance startup timeout (existing issue, not introduced here)
1061
1063
  */
1062
1064
  async startInstancesWithConcurrency(entries, topicMode) {
1065
+ // Persisted pauses are intentionally preserved across fleet restarts. Filter
1066
+ // them before grouping/staggering: startInstance() retains its own guard as
1067
+ // a final backstop, but putting a no-op entry in this queue still consumes a
1068
+ // full stagger slot for every distinct working directory.
1069
+ const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
1070
+ const pausedCount = entries.length - runnableEntries.length;
1071
+ if (pausedCount > 0) {
1072
+ this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
1073
+ }
1074
+ if (runnableEntries.length === 0)
1075
+ return;
1063
1076
  const raw = this.fleetConfig?.defaults?.startup;
1064
1077
  const explicitConcurrency = raw?.concurrency;
1065
1078
  const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
@@ -1074,10 +1087,10 @@ export class FleetManager {
1074
1087
  else {
1075
1088
  const freeMemMB = Math.round(freemem() / (1024 * 1024));
1076
1089
  concurrency = Math.max(2, Math.min(10, Math.floor(freeMemMB / ESTIMATED_MB_PER_INSTANCE)));
1077
- this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: entries.length }, "Adaptive startup concurrency");
1090
+ this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: runnableEntries.length }, "Adaptive startup concurrency");
1078
1091
  }
1079
1092
  const byWorkDir = new Map();
1080
- for (const [name, config] of entries) {
1093
+ for (const [name, config] of runnableEntries) {
1081
1094
  const dir = config.working_directory;
1082
1095
  if (!byWorkDir.has(dir))
1083
1096
  byWorkDir.set(dir, []);
@@ -1225,6 +1238,21 @@ export class FleetManager {
1225
1238
  process.env[key] = value;
1226
1239
  }
1227
1240
  }
1241
+ /** Initialize auth before any adapter can answer /dashboard. */
1242
+ initializeWebAuthTokens() {
1243
+ this.webToken = loadOrCreateWebToken(this.dataDir);
1244
+ this.viewToken = randomBytes(24).toString("hex");
1245
+ const viewTokenPath = join(this.dataDir, "view.token");
1246
+ writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
1247
+ try {
1248
+ chmodSync(viewTokenPath, 0o600);
1249
+ }
1250
+ catch { /* best effort */ }
1251
+ this.healthServerListening = false;
1252
+ }
1253
+ getDashboardAccess() {
1254
+ return { ready: this.healthServerListening, token: this.webToken };
1255
+ }
1228
1256
  /** Start all instances from fleet config */
1229
1257
  async startAll(configPath) {
1230
1258
  FleetManager.signalTarget = this;
@@ -1238,6 +1266,7 @@ export class FleetManager {
1238
1266
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1239
1267
  const fleet = this.loadConfig(configPath);
1240
1268
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1269
+ this.initializeWebAuthTokens();
1241
1270
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
1242
1271
  // Set tmux socket isolation for custom AGEND_HOME
1243
1272
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
@@ -6331,6 +6360,7 @@ When users create specialized instances, suggest these configurations:
6331
6360
  this.controlClient?.stop();
6332
6361
  this.controlClient = null;
6333
6362
  if (this.healthServer) {
6363
+ this.healthServerListening = false;
6334
6364
  this.healthServer.close();
6335
6365
  this.healthServer = null;
6336
6366
  }
@@ -6799,26 +6829,11 @@ When users create specialized instances, suggest these configurations:
6799
6829
  // ── Health HTTP endpoint ─────────────────────────────────────────────
6800
6830
  startHealthServer(port) {
6801
6831
  this.startedAt = Date.now();
6802
- // Generate web token before server starts so auth is enforced from the first request.
6803
- this.webToken = randomBytes(24).toString("hex");
6804
- const tokenPath = join(this.dataDir, "web.token");
6805
- writeFileSync(tokenPath, this.webToken, { mode: 0o600 });
6806
- // Defensive: if file existed previously with looser perms, tighten it.
6807
- try {
6808
- chmodSync(tokenPath, 0o600);
6809
- }
6810
- catch {
6811
- // best-effort
6812
- }
6813
- // Separate read-only token for the /view page: grants terminal-view + profile
6814
- // read, but never write (POSTs still require the full web token).
6815
- this.viewToken = randomBytes(24).toString("hex");
6816
- const viewTokenPath = join(this.dataDir, "view.token");
6817
- writeFileSync(viewTokenPath, this.viewToken, { mode: 0o600 });
6818
- try {
6819
- chmodSync(viewTokenPath, 0o600);
6820
- }
6821
- catch { /* best-effort */ }
6832
+ this.healthServerListening = false;
6833
+ this.healthPortRetried = false;
6834
+ // Defensive for direct/unit callers; normal startup initializes these before adapters.
6835
+ if (!this.webToken || !this.viewToken)
6836
+ this.initializeWebAuthTokens();
6822
6837
  this.healthServer = createServer((req, res) => {
6823
6838
  res.setHeader("Content-Type", "application/json");
6824
6839
  // Public health probe — no auth required.
@@ -6845,7 +6860,7 @@ When users create specialized instances, suggest these configurations:
6845
6860
  ?? (typeof headerToken === "string" ? headerToken : null);
6846
6861
  if (!this.webToken || providedToken !== this.webToken) {
6847
6862
  res.writeHead(401);
6848
- res.end(JSON.stringify({ error: "Unauthorized" }));
6863
+ res.end(JSON.stringify({ error: WEB_TOKEN_INVALID_MESSAGE }));
6849
6864
  return;
6850
6865
  }
6851
6866
  }
@@ -7060,10 +7075,20 @@ When users create specialized instances, suggest these configurations:
7060
7075
  res.writeHead(404);
7061
7076
  res.end(JSON.stringify({ error: "not found" }));
7062
7077
  });
7078
+ const markListening = (afterTakeover = false) => {
7079
+ this.healthServerListening = true;
7080
+ this.logger.info({ port }, afterTakeover
7081
+ ? "Health endpoint listening (after takeover)"
7082
+ : "Health endpoint listening");
7083
+ this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
7084
+ this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
7085
+ };
7063
7086
  this.healthServer.on("error", (err) => {
7087
+ this.healthServerListening = false;
7064
7088
  if (err.code === "EADDRINUSE") {
7065
7089
  if (this.healthPortRetried) {
7066
- this.logger.debug({ port }, "Health port still in use after takeover — skipping health endpoint");
7090
+ this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
7091
+ this.notifyFleetError(`⚠️ Dashboard unavailable — health port ${port} is already in use. Stop the conflicting process or configure a different health_port.`);
7067
7092
  return;
7068
7093
  }
7069
7094
  this.healthPortRetried = true;
@@ -7084,19 +7109,14 @@ When users create specialized instances, suggest these configurations:
7084
7109
  setTimeout(() => {
7085
7110
  if (!this.healthServer)
7086
7111
  return;
7087
- this.healthServer.listen(port, "127.0.0.1", () => {
7088
- this.logger.info({ port }, "Health endpoint listening (after takeover)");
7089
- });
7112
+ this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
7090
7113
  }, 1500);
7091
7114
  return;
7092
7115
  }
7093
7116
  this.logger.error({ err, port }, "Health server error");
7117
+ this.notifyFleetError(`⚠️ Dashboard unavailable — health server failed: ${err.message}`);
7094
7118
  });
7095
- this.healthServer.listen(port, "127.0.0.1", () => {
7096
- this.logger.info({ port }, "Health endpoint listening");
7097
- });
7098
- this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
7099
- this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
7119
+ this.healthServer.listen(port, "127.0.0.1", () => markListening());
7100
7120
  }
7101
7121
  getUiStatus() {
7102
7122
  const fleetNames = Object.keys(this.fleetConfig?.instances ?? {});