@songsid/agend 2.1.5-beta.3 → 2.1.5-beta.5

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.
@@ -60,7 +60,7 @@ import { clearPausedMarker } from "./pause-marker.js";
60
60
  import { releaseProcessFleetLock } from "./fleet-lock.js";
61
61
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
62
62
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
63
- import { RestartProgress } from "./restart-progress.js";
63
+ import { RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
64
64
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
65
65
  import { StormWindow } from "./storm-window.js";
66
66
  import { SpawnGate } from "./spawn-gate.js";
@@ -228,7 +228,39 @@ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
228
228
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
229
229
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
230
230
  const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
231
- const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
231
+ const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // hard validity bound for the cached CLI env
232
+ /**
233
+ * How old the cached CLI env may be before `/model` re-probes it live.
234
+ *
235
+ * The cache is a file under AGEND_HOME, so it outlives the process, and the only
236
+ * thing that refreshed it was the startup probe. That is why a newly released
237
+ * model showed up only after `agend stop` + `agend start`: a bare `agend restart`
238
+ * signals SIGUSR2 and restarts the instances inside the *same* manager process,
239
+ * so nothing re-probed and `/model` kept serving a list up to 24h old.
240
+ */
241
+ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
242
+ /**
243
+ * Upper bound on a live probe driven by `/model`.
244
+ *
245
+ * A probe chains bounded leaves but is not itself bounded: claude-code runs
246
+ * `--version` (5s) then listModels then listApiModels (an 8s AbortController
247
+ * against api.anthropic.com), and several backends' probes carry no explicit
248
+ * timeout at all. Awaiting that inline would make `/model` the next thing to
249
+ * hang, so it races this deadline and falls back to the cached list.
250
+ *
251
+ * Must stay above CLI_PROBE_LONGEST_CHAIN_MS — the probe's bounded steps run
252
+ * back to back, so clearing only the longest single leaf would be false
253
+ * confidence: a deadline above 8s but below the 13s chain still truncates a
254
+ * probe that would have succeeded. Bounding the chain rather than the leaf is
255
+ * the lesson from the usage hang, and the tests assert against the derived
256
+ * chain constant so raising either step cannot silently break it.
257
+ *
258
+ * The deadline exists to stop a probe hanging forever, not to cut short one that
259
+ * would have finished: truncating a legitimate probe serves the previous list
260
+ * and hides exactly the newly released model the user opened `/model` to find.
261
+ * The wait is announced before it starts, so it reads as progress, not a stall.
262
+ */
263
+ export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
232
264
  export class FleetManager {
233
265
  dataDir;
234
266
  static signalTarget = null;
@@ -1751,16 +1783,60 @@ export class FleetManager {
1751
1783
  if (!generalName)
1752
1784
  return null;
1753
1785
  const adapter = this.getAdapterForInstance(generalName);
1786
+ const adapterId = this.getInstanceAdapterId(generalName);
1754
1787
  const chatId = this.getGroupIdForInstance(generalName);
1755
1788
  if (!adapter || !chatId)
1756
1789
  return null;
1757
1790
  const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
1758
1791
  return {
1759
1792
  adapter,
1793
+ resolveAdapter: adapterId ? () => this.readyProgressAdapter(adapterId) : undefined,
1760
1794
  chatId,
1761
1795
  threadId: topicId != null ? String(topicId) : undefined,
1762
1796
  };
1763
1797
  }
1798
+ /** Resolve only an adapter generation that can accept progress delivery.
1799
+ * Discord exposes direct gateway readiness; adapters without a health
1800
+ * snapshot use the fleet startup/retry state. */
1801
+ readyProgressAdapter(adapterId) {
1802
+ const adapter = this.adapters.get(adapterId);
1803
+ if (!adapter)
1804
+ return undefined;
1805
+ const health = adapter.getHealthSnapshot?.();
1806
+ if (health)
1807
+ return health.isReady ? adapter : undefined;
1808
+ return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
1809
+ }
1810
+ /** Last-resort completion after RestartProgress could not deliver to its
1811
+ * adopted target. It stays bounded and never wakes a not-ready gateway. */
1812
+ async sendFleetStartCompletionFallback(chatId, text, threadId) {
1813
+ const adapterId = this.getPrimaryAdapterId();
1814
+ const adapter = adapterId ? this.readyProgressAdapter(adapterId) : undefined;
1815
+ if (!adapter) {
1816
+ this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
1817
+ return false;
1818
+ }
1819
+ let timer;
1820
+ const timeout = new Promise(resolve => {
1821
+ timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
1822
+ timer.unref?.();
1823
+ });
1824
+ const delivery = Promise.resolve()
1825
+ .then(() => adapter.sendText(chatId, text, { threadId }))
1826
+ .then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
1827
+ const result = await Promise.race([delivery, timeout]);
1828
+ if (timer)
1829
+ clearTimeout(timer);
1830
+ if (result.status === "sent")
1831
+ return true;
1832
+ if (result.status === "failed") {
1833
+ this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
1834
+ }
1835
+ else {
1836
+ this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
1837
+ }
1838
+ return false;
1839
+ }
1764
1840
  async stopInstance(name) {
1765
1841
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
1766
1842
  this.cancelStartupRetry(name);
@@ -2317,12 +2393,17 @@ export class FleetManager {
2317
2393
  progressStart = adapterStartup.then(() => {
2318
2394
  if (pendingUpdateProgress) {
2319
2395
  const saved = pendingUpdateProgress.progress.target;
2320
- const adapter = this.adapters.get(saved.adapterId);
2321
- const target = adapter ? {
2322
- adapter,
2396
+ const target = {
2397
+ adapter: this.adapters.get(saved.adapterId),
2398
+ // Adapter retries replace the failed object in this map. Resolve at
2399
+ // every progress delivery so the adopted update message follows the
2400
+ // live, ready generation instead of remaining pinned to a stopped
2401
+ // client. This also waits when the first generation failed before
2402
+ // any adapter object was registered.
2403
+ resolveAdapter: () => this.readyProgressAdapter(saved.adapterId),
2323
2404
  chatId: saved.chatId,
2324
2405
  threadId: saved.threadId,
2325
- } : null;
2406
+ };
2326
2407
  return startupProgress.resume(target, saved.messageId);
2327
2408
  }
2328
2409
  return startupProgress.start(this.restartProgressTarget());
@@ -2439,7 +2520,7 @@ export class FleetManager {
2439
2520
  })()
2440
2521
  : undefined,
2441
2522
  });
2442
- if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
2523
+ if (!progressCompleted && fleet.channel?.group_id) {
2443
2524
  let text;
2444
2525
  if (failedNames.length === 0 && pausedNames.length === 0) {
2445
2526
  text = t("fleet.ready", started, total, agendVersion);
@@ -2451,9 +2532,7 @@ export class FleetManager {
2451
2532
  text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
2452
2533
  + (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
2453
2534
  }
2454
- this.adapter.sendText(String(fleet.channel.group_id), text, {
2455
- threadId: generalThreadId != null ? String(generalThreadId) : undefined,
2456
- }).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
2535
+ await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
2457
2536
  }
2458
2537
  }
2459
2538
  // Health HTTP endpoint
@@ -8376,6 +8455,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8376
8455
  catch { /* missing / stale / corrupt */ }
8377
8456
  return null;
8378
8457
  }
8458
+ /** True when a cached CLI env is old enough that `/model` should re-probe. */
8459
+ cliEnvNeedsRefresh(env) {
8460
+ return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
8461
+ }
8462
+ /**
8463
+ * Run a live probe under a deadline, falling back to whatever the cache holds.
8464
+ * A model list is an aid: a vendor that stops answering must degrade to the
8465
+ * previous list, never stall the command that asked for it.
8466
+ */
8467
+ async probeBackendBounded(backend) {
8468
+ const work = this.probeBackend(backend);
8469
+ work.catch(() => { });
8470
+ let timer;
8471
+ const deadline = new Promise(resolve => {
8472
+ timer = setTimeout(() => {
8473
+ this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
8474
+ resolve(null);
8475
+ }, CLI_ENV_PROBE_DEADLINE_MS);
8476
+ });
8477
+ try {
8478
+ return await Promise.race([work, deadline]);
8479
+ }
8480
+ finally {
8481
+ clearTimeout(timer);
8482
+ }
8483
+ }
8379
8484
  /**
8380
8485
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
8381
8486
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -8477,16 +8582,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8477
8582
  void this.probeBackend(b);
8478
8583
  }
8479
8584
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
8480
- async getModelOptions(instanceName, refresh = false) {
8585
+ async getModelOptions(instanceName, refresh = false, onLiveProbe) {
8481
8586
  const backendName = this.backendNameForInstance(instanceName);
8482
- if (!refresh) {
8483
- const cached = this.readCliEnv(backendName);
8484
- if (cached && cached.models.length)
8485
- return cached.models;
8486
- }
8487
- // Cache miss / stale / forced refresh → probe live (also refreshes the cache).
8488
- const env = await this.probeBackend(backendName);
8489
- return env?.models ?? [];
8587
+ const cached = this.readCliEnv(backendName);
8588
+ if (!refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached))
8589
+ return cached.models;
8590
+ // About to go to the vendor: let the caller say so. A silent 1–10s pause on
8591
+ // an interactive command reads as another hang, which is the wrong lesson to
8592
+ // teach a user who has just been bitten by one.
8593
+ onLiveProbe?.();
8594
+ // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
8595
+ // A newly released model is invisible until this runs, which is why staleness
8596
+ // triggers it rather than waiting for the 24h hard expiry or a cold start.
8597
+ const env = await this.probeBackendBounded(backendName);
8598
+ if (env?.models.length)
8599
+ return env.models;
8600
+ // Probe failed or timed out: the previous list is still the best answer.
8601
+ return cached?.models ?? [];
8490
8602
  }
8491
8603
  /**
8492
8604
  * Model catalog behind the `list_models` tool.
@@ -8777,7 +8889,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8777
8889
  await data.respond(t("model.usage"));
8778
8890
  return;
8779
8891
  }
8780
- const options = await this.getModelOptions(name, isRefresh);
8892
+ const options = await this.getModelOptions(name, isRefresh, () => {
8893
+ void data.respond(t("model.refreshing")).catch(() => { });
8894
+ });
8781
8895
  if (options.length === 0) {
8782
8896
  await data.respond(t("model.list_unavailable", name));
8783
8897
  return;
@@ -9788,6 +9902,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9788
9902
  this.logger.error("Cannot restart: no config path (was startAll called?)");
9789
9903
  return;
9790
9904
  }
9905
+ // A graceful restart keeps this manager process, so the startup probe does
9906
+ // not run again. Without this, `agend restart` left the cached CLI env
9907
+ // untouched and `/model` kept serving an old list until a cold start —
9908
+ // exactly the "only stop+start works" report. Background, never blocking:
9909
+ // /model re-probes on staleness anyway, this just makes a restart do the
9910
+ // refreshing a user expects of it.
9911
+ this.probeCliEnvs();
9791
9912
  const instanceNames = [...this.daemons.keys()];
9792
9913
  if (instanceNames.length === 0) {
9793
9914
  this.logger.info("No instances to restart");