@songsid/agend 2.1.5-beta.4 → 2.1.5-beta.6

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.
@@ -7,7 +7,7 @@ import { join, dirname, basename } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { isDeepStrictEqual } from "node:util";
9
9
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
10
- import { beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, } from "./update-marker.js";
10
+ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
11
11
  import { formatUpdateProgress } from "./update-progress.js";
12
12
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
13
13
  import { readFleetMemory } from "./process-memory.js";
@@ -60,7 +60,8 @@ 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 { RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
63
+ import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
64
+ import { launchFullRestartHelper } from "./full-restart.js";
64
65
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
65
66
  import { StormWindow } from "./storm-window.js";
66
67
  import { SpawnGate } from "./spawn-gate.js";
@@ -228,7 +229,39 @@ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
228
229
  /** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
229
230
  const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
230
231
  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
232
+ const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // hard validity bound for the cached CLI env
233
+ /**
234
+ * How old the cached CLI env may be before `/model` re-probes it live.
235
+ *
236
+ * The cache is a file under AGEND_HOME, so it outlives the process, and the only
237
+ * thing that refreshed it was the startup probe. That is why a newly released
238
+ * model showed up only after `agend stop` + `agend start`: a bare `agend restart`
239
+ * signals SIGUSR2 and restarts the instances inside the *same* manager process,
240
+ * so nothing re-probed and `/model` kept serving a list up to 24h old.
241
+ */
242
+ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
243
+ /**
244
+ * Upper bound on a live probe driven by `/model`.
245
+ *
246
+ * A probe chains bounded leaves but is not itself bounded: claude-code runs
247
+ * `--version` (5s) then listModels then listApiModels (an 8s AbortController
248
+ * against api.anthropic.com), and several backends' probes carry no explicit
249
+ * timeout at all. Awaiting that inline would make `/model` the next thing to
250
+ * hang, so it races this deadline and falls back to the cached list.
251
+ *
252
+ * Must stay above CLI_PROBE_LONGEST_CHAIN_MS — the probe's bounded steps run
253
+ * back to back, so clearing only the longest single leaf would be false
254
+ * confidence: a deadline above 8s but below the 13s chain still truncates a
255
+ * probe that would have succeeded. Bounding the chain rather than the leaf is
256
+ * the lesson from the usage hang, and the tests assert against the derived
257
+ * chain constant so raising either step cannot silently break it.
258
+ *
259
+ * The deadline exists to stop a probe hanging forever, not to cut short one that
260
+ * would have finished: truncating a legitimate probe serves the previous list
261
+ * and hides exactly the newly released model the user opened `/model` to find.
262
+ * The wait is announced before it starts, so it reads as progress, not a stall.
263
+ */
264
+ export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
232
265
  export class FleetManager {
233
266
  dataDir;
234
267
  static signalTarget = null;
@@ -379,6 +412,8 @@ export class FleetManager {
379
412
  updateProgressEditRunning = false;
380
413
  lastUpdateProgressText = null;
381
414
  updateCompletionTipText = null;
415
+ /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
416
+ fullRestartLauncher = launchFullRestartHelper;
382
417
  eventLogPruneTimer = null;
383
418
  logRotateTimer = null;
384
419
  /** Days of event/activity history to keep. */
@@ -765,6 +800,28 @@ export class FleetManager {
765
800
  child.once("error", err => this.failUpdateProgress(err.message));
766
801
  child.unref();
767
802
  }
803
+ async handleRestartSlash(data, adapterId) {
804
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
805
+ await data.respond(t("not_authorized"));
806
+ return;
807
+ }
808
+ if (data.options?.mode !== "full") {
809
+ await data.respond(t("restart.graceful"));
810
+ process.kill(process.pid, "SIGUSR2");
811
+ return;
812
+ }
813
+ // Discord exposes only `full` as a choice. Keep a runtime check anyway: a
814
+ // briefly stale command schema must never turn an unknown value into SIGUSR1.
815
+ const messageId = await data.respond(t("restart.full_preparing"));
816
+ const adapter = this.adapters.get(adapterId) ?? this.adapter;
817
+ if (!messageId || !adapter) {
818
+ this.logger.error({ adapterId, hasMessageId: !!messageId }, "Full restart response could not be persisted — reload refused");
819
+ await data.respond(t("restart.full_launch_failed"));
820
+ return;
821
+ }
822
+ const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
823
+ await this.requestFullRestart(adapter, chatId, data.channelId, messageId);
824
+ }
768
825
  async handleTipsSlash(data, adapterId) {
769
826
  if (!this.fleetConfig)
770
827
  return;
@@ -1950,6 +2007,130 @@ export class FleetManager {
1950
2007
  this.updateCompletionTipText = null;
1951
2008
  this.startUpdateProgressMonitor(adapter);
1952
2009
  }
2010
+ /** Persist the public response, wait for idle, then start the canonical service restart. */
2011
+ async requestFullRestart(adapter, chatId, threadId, messageId) {
2012
+ const target = {
2013
+ adapterId: adapter.id,
2014
+ chatId,
2015
+ ...(threadId ? { threadId } : {}),
2016
+ messageId,
2017
+ };
2018
+ // This check is synchronous with the marker write below. A second command
2019
+ // cannot slip through between them on Node's event loop and replace the
2020
+ // first command's delivery target or launch a competing restart helper.
2021
+ if (this.shuttingDown || isUpdateInProgress(this.dataDir)) {
2022
+ this.logger.warn({ adapterId: adapter.id }, "Full restart refused because another planned restart is active");
2023
+ await this.reportFullRestartFailure(adapter, chatId, threadId, messageId, t("restart.full_busy"));
2024
+ return false;
2025
+ }
2026
+ if (!persistFullRestartProgress(this.dataDir, target)) {
2027
+ this.logger.error({ adapterId: adapter.id }, "Full restart marker could not be persisted — reload refused");
2028
+ await this.reportFullRestartFailure(adapter, chatId, threadId, messageId, t("restart.full_launch_failed"));
2029
+ return false;
2030
+ }
2031
+ const ownedMarker = readUpdateProgress(this.dataDir);
2032
+ if (!ownedMarker) {
2033
+ this.logger.error({ adapterId: adapter.id }, "Full restart marker disappeared after persistence — reload refused");
2034
+ await this.reportFullRestartFailure(adapter, chatId, threadId, messageId, t("restart.full_launch_failed"));
2035
+ return false;
2036
+ }
2037
+ this.lastUpdateProgressText = null;
2038
+ this.updateCompletionTipText = null;
2039
+ this.startUpdateProgressMonitor(adapter);
2040
+ await this.waitForFullRestartIdleGrace();
2041
+ // An update can begin while the idle wait yields. Never launch a second
2042
+ // process replacement against a marker we no longer own.
2043
+ if (this.shuttingDown || !this.isOwnedFullRestartMarker(ownedMarker.startedAt, target)) {
2044
+ this.logger.warn({ adapterId: adapter.id }, "Full restart superseded during idle wait — reload refused");
2045
+ if (this.isOwnedFullRestartMarker(ownedMarker.startedAt, target))
2046
+ clearUpdateMarker(this.dataDir);
2047
+ await this.reportFullRestartFailure(adapter, chatId, threadId, messageId, t("restart.full_busy"));
2048
+ return false;
2049
+ }
2050
+ let helper;
2051
+ try {
2052
+ helper = await this.fullRestartLauncher();
2053
+ }
2054
+ catch (err) {
2055
+ this.logger.error({ err }, "Full restart helper failed to spawn — reload refused");
2056
+ if (this.isOwnedFullRestartMarker(ownedMarker.startedAt, target))
2057
+ clearUpdateMarker(this.dataDir);
2058
+ await this.reportFullRestartFailure(adapter, chatId, threadId, messageId, t("restart.full_launch_failed"));
2059
+ return false;
2060
+ }
2061
+ void helper.completion.then(result => {
2062
+ // A zero exit means the service manager accepted the restart. launchd
2063
+ // may report that before its SIGTERM reaches us, so only an explicit
2064
+ // helper error/non-zero exit is evidence of failure. A signal is also
2065
+ // ambiguous under systemd because this helper shares the old cgroup.
2066
+ if (this.shuttingDown || !this.isOwnedFullRestartMarker(ownedMarker.startedAt, target))
2067
+ return;
2068
+ if (!result.error && (result.code === 0 || result.code === null))
2069
+ return;
2070
+ const detail = result.error
2071
+ ? "reload helper failed after launch"
2072
+ : `reload helper exited before process hand-off (code ${result.code ?? "null"}, signal ${result.signal ?? "none"})`;
2073
+ this.logger.error({ result }, "Full restart helper exited before the fleet began shutting down");
2074
+ setUpdateProgressStage(this.dataDir, "failed", { error: detail });
2075
+ });
2076
+ return true;
2077
+ }
2078
+ isOwnedFullRestartMarker(startedAt, target) {
2079
+ const marker = readUpdateProgress(this.dataDir);
2080
+ if (!marker || marker.startedAt !== startedAt || marker.pid !== process.pid)
2081
+ return false;
2082
+ if (updateProgressOperation(marker.progress) !== "full-restart")
2083
+ return false;
2084
+ const current = marker.progress.target;
2085
+ return current.adapterId === target.adapterId
2086
+ && current.chatId === target.chatId
2087
+ && current.threadId === target.threadId
2088
+ && current.messageId === target.messageId;
2089
+ }
2090
+ /** Give current work the same bounded idle grace used by graceful reload. */
2091
+ async waitForFullRestartIdleGrace() {
2092
+ const instanceNames = [...this.daemons.keys()];
2093
+ if (instanceNames.length === 0)
2094
+ return;
2095
+ const IDLE_TIMEOUT_MS = 5 * 60_000;
2096
+ let timeoutHandle;
2097
+ const deadline = new Promise((_, reject) => {
2098
+ timeoutHandle = setTimeout(() => reject(new Error("Idle wait timed out after 5 minutes")), IDLE_TIMEOUT_MS);
2099
+ });
2100
+ try {
2101
+ await Promise.race([
2102
+ Promise.all(instanceNames.map(async (name) => {
2103
+ const daemon = this.daemons.get(name);
2104
+ if (!daemon)
2105
+ return;
2106
+ this.logger.info(`Full restart: waiting for ${name} to idle...`);
2107
+ await daemon.waitForIdle(10_000);
2108
+ })),
2109
+ deadline,
2110
+ ]);
2111
+ }
2112
+ catch (err) {
2113
+ this.logger.warn({ err }, "Full restart idle wait timed out — continuing with service restart");
2114
+ }
2115
+ finally {
2116
+ clearTimeout(timeoutHandle);
2117
+ }
2118
+ }
2119
+ async reportFullRestartFailure(adapter, chatId, threadId, messageId, text) {
2120
+ try {
2121
+ await adapter.editMessage(chatId, messageId, text, threadId);
2122
+ return;
2123
+ }
2124
+ catch (err) {
2125
+ this.logger.warn({ err, adapterId: adapter.id }, "Failed to edit rejected full-restart request; posting a fresh notice");
2126
+ }
2127
+ try {
2128
+ await adapter.sendText(chatId, text, { threadId });
2129
+ }
2130
+ catch (err) {
2131
+ this.logger.error({ err, adapterId: adapter.id }, "Failed to deliver full-restart rejection");
2132
+ }
2133
+ }
1953
2134
  failUpdateProgress(message) {
1954
2135
  setUpdateProgressStage(this.dataDir, "failed", { error: message });
1955
2136
  }
@@ -2032,6 +2213,9 @@ export class FleetManager {
2032
2213
  && savedUpdateProgress.progress.stage !== "complete"
2033
2214
  ? savedUpdateProgress
2034
2215
  : null;
2216
+ const pendingProgressOperation = pendingUpdateProgress
2217
+ ? updateProgressOperation(pendingUpdateProgress.progress)
2218
+ : null;
2035
2219
  if (pendingUpdateProgress) {
2036
2220
  setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
2037
2221
  }
@@ -2312,7 +2496,7 @@ export class FleetManager {
2312
2496
  const allEntries = Object.entries(fleet.instances);
2313
2497
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
2314
2498
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
2315
- const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingUpdateProgress ? "update" : "restart" });
2499
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
2316
2500
  if (generals.length > 0) {
2317
2501
  for (const [name, cfg] of generals) {
2318
2502
  try {
@@ -2481,7 +2665,7 @@ export class FleetManager {
2481
2665
  version: agendVersion,
2482
2666
  pausedNames,
2483
2667
  failedNames,
2484
- tipText: pendingUpdateProgress && this.tipsEnabled()
2668
+ tipText: pendingProgressOperation === "update" && this.tipsEnabled()
2485
2669
  ? (() => {
2486
2670
  const tip = this.pickAvailableTip();
2487
2671
  return tip ? this.formatTip(tip) : undefined;
@@ -2490,7 +2674,16 @@ export class FleetManager {
2490
2674
  });
2491
2675
  if (!progressCompleted && fleet.channel?.group_id) {
2492
2676
  let text;
2493
- if (failedNames.length === 0 && pausedNames.length === 0) {
2677
+ if (pendingProgressOperation === "full-restart") {
2678
+ text = formatRestartProgressCompletion("reload", {
2679
+ running: started,
2680
+ total,
2681
+ version: agendVersion,
2682
+ pausedNames,
2683
+ failedNames,
2684
+ }, pendingUpdateProgress.startedAt);
2685
+ }
2686
+ else if (failedNames.length === 0 && pausedNames.length === 0) {
2494
2687
  text = t("fleet.ready", started, total, agendVersion);
2495
2688
  }
2496
2689
  else if (failedNames.length === 0) {
@@ -3036,13 +3229,7 @@ export class FleetManager {
3036
3229
  await data.respond(this.topicCommands.getDashboardText());
3037
3230
  }
3038
3231
  else if (data.command === "restart") {
3039
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
3040
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
3041
- await data.respond(t("not_authorized"));
3042
- return;
3043
- }
3044
- await data.respond(t("restart.graceful"));
3045
- process.kill(process.pid, "SIGUSR2");
3232
+ await this.handleRestartSlash(data, adapterId);
3046
3233
  }
3047
3234
  else if (data.command === "compact") {
3048
3235
  const name = this.resolveSlashTarget(data.channelId, adapterId);
@@ -3385,13 +3572,7 @@ export class FleetManager {
3385
3572
  await data.respond(this.topicCommands.getDashboardText());
3386
3573
  }
3387
3574
  else if (data.command === "restart") {
3388
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
3389
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
3390
- await data.respond(t("not_authorized"));
3391
- return;
3392
- }
3393
- await data.respond(t("restart.graceful"));
3394
- process.kill(process.pid, "SIGUSR2");
3575
+ await this.handleRestartSlash(data, adapterId);
3395
3576
  }
3396
3577
  else if (data.command === "compact") {
3397
3578
  const name = this.resolveSlashTarget(data.channelId, adapterId);
@@ -8423,6 +8604,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8423
8604
  catch { /* missing / stale / corrupt */ }
8424
8605
  return null;
8425
8606
  }
8607
+ /** True when a cached CLI env is old enough that `/model` should re-probe. */
8608
+ cliEnvNeedsRefresh(env) {
8609
+ return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
8610
+ }
8611
+ /**
8612
+ * Run a live probe under a deadline, falling back to whatever the cache holds.
8613
+ * A model list is an aid: a vendor that stops answering must degrade to the
8614
+ * previous list, never stall the command that asked for it.
8615
+ */
8616
+ async probeBackendBounded(backend) {
8617
+ const work = this.probeBackend(backend);
8618
+ work.catch(() => { });
8619
+ let timer;
8620
+ const deadline = new Promise(resolve => {
8621
+ timer = setTimeout(() => {
8622
+ this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
8623
+ resolve(null);
8624
+ }, CLI_ENV_PROBE_DEADLINE_MS);
8625
+ });
8626
+ try {
8627
+ return await Promise.race([work, deadline]);
8628
+ }
8629
+ finally {
8630
+ clearTimeout(timer);
8631
+ }
8632
+ }
8426
8633
  /**
8427
8634
  * Resolve the effective model for a fleet or ClassicBot instance, plus where it
8428
8635
  * came from. Single source of truth for `/model` and `/ctx` — precedence:
@@ -8524,16 +8731,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8524
8731
  void this.probeBackend(b);
8525
8732
  }
8526
8733
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
8527
- async getModelOptions(instanceName, refresh = false) {
8734
+ async getModelOptions(instanceName, refresh = false, onLiveProbe) {
8528
8735
  const backendName = this.backendNameForInstance(instanceName);
8529
- if (!refresh) {
8530
- const cached = this.readCliEnv(backendName);
8531
- if (cached && cached.models.length)
8532
- return cached.models;
8533
- }
8534
- // Cache miss / stale / forced refresh → probe live (also refreshes the cache).
8535
- const env = await this.probeBackend(backendName);
8536
- return env?.models ?? [];
8736
+ const cached = this.readCliEnv(backendName);
8737
+ if (!refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached))
8738
+ return cached.models;
8739
+ // About to go to the vendor: let the caller say so. A silent 1–10s pause on
8740
+ // an interactive command reads as another hang, which is the wrong lesson to
8741
+ // teach a user who has just been bitten by one.
8742
+ onLiveProbe?.();
8743
+ // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
8744
+ // A newly released model is invisible until this runs, which is why staleness
8745
+ // triggers it rather than waiting for the 24h hard expiry or a cold start.
8746
+ const env = await this.probeBackendBounded(backendName);
8747
+ if (env?.models.length)
8748
+ return env.models;
8749
+ // Probe failed or timed out: the previous list is still the best answer.
8750
+ return cached?.models ?? [];
8537
8751
  }
8538
8752
  /**
8539
8753
  * Model catalog behind the `list_models` tool.
@@ -8824,7 +9038,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
8824
9038
  await data.respond(t("model.usage"));
8825
9039
  return;
8826
9040
  }
8827
- const options = await this.getModelOptions(name, isRefresh);
9041
+ const options = await this.getModelOptions(name, isRefresh, () => {
9042
+ void data.respond(t("model.refreshing")).catch(() => { });
9043
+ });
8828
9044
  if (options.length === 0) {
8829
9045
  await data.respond(t("model.list_unavailable", name));
8830
9046
  return;
@@ -9658,8 +9874,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9658
9874
  return;
9659
9875
  }
9660
9876
  this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
9877
+ const trackedProgress = readUpdateProgress(this.dataDir);
9878
+ const trackedFullRestart = trackedProgress
9879
+ && updateProgressOperation(trackedProgress.progress) === "full-restart"
9880
+ && trackedProgress.progress.stage !== "failed"
9881
+ && trackedProgress.progress.stage !== "complete";
9882
+ if (trackedFullRestart) {
9883
+ // `/restart full` already posted and persisted one public progress message.
9884
+ // Keep that single message; the new process will adopt and finish it.
9885
+ setUpdateProgressStage(this.dataDir, "stopping");
9886
+ }
9661
9887
  const groupId = this.fleetConfig?.channel?.group_id;
9662
- if (groupId && this.adapter) {
9888
+ if (!trackedFullRestart && groupId && this.adapter) {
9663
9889
  await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
9664
9890
  .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
9665
9891
  }
@@ -9835,6 +10061,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9835
10061
  this.logger.error("Cannot restart: no config path (was startAll called?)");
9836
10062
  return;
9837
10063
  }
10064
+ // A graceful restart keeps this manager process, so the startup probe does
10065
+ // not run again. Without this, `agend restart` left the cached CLI env
10066
+ // untouched and `/model` kept serving an old list until a cold start —
10067
+ // exactly the "only stop+start works" report. Background, never blocking:
10068
+ // /model re-probes on staleness anyway, this just makes a restart do the
10069
+ // refreshing a user expects of it.
10070
+ this.probeCliEnvs();
9838
10071
  const instanceNames = [...this.daemons.keys()];
9839
10072
  if (instanceNames.length === 0) {
9840
10073
  this.logger.info("No instances to restart");