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

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.
@@ -103,6 +103,8 @@ export interface FleetContext {
103
103
  /** Persist and edit one `/update` message across the fleet process restart. */
104
104
  beginUpdateProgress?(adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId: string | undefined, messageId: string): void;
105
105
  failUpdateProgress?(message: string): void;
106
+ /** Persist `/restart full`, wait for idle, then launch the environment-aware service restart. */
107
+ requestFullRestart?(adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId: string | undefined, messageId: string): Promise<boolean>;
106
108
  /** Post one random, not-yet-dismissed tip with a dismiss button. */
107
109
  promptTip?(generalName: string, adapter: import("./channel/types.js").ChannelAdapter, chatId: string, threadId?: string): Promise<"posted" | "empty" | "unavailable">;
108
110
  /** Persistently enable advanced tips without waiting for the dismissal threshold. */
@@ -221,6 +221,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
221
221
  private updateProgressEditRunning;
222
222
  private lastUpdateProgressText;
223
223
  private updateCompletionTipText;
224
+ /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
225
+ private fullRestartLauncher;
224
226
  private eventLogPruneTimer;
225
227
  private logRotateTimer;
226
228
  /** Days of event/activity history to keep. */
@@ -309,6 +311,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
309
311
  private isModelAdmin;
310
312
  private handlePauseWakeSlash;
311
313
  private handleUpdateSlash;
314
+ private handleRestartSlash;
312
315
  private handleTipsSlash;
313
316
  /** Admin-only full conversation reset for fleet-topic and Classic instances. */
314
317
  private handleClearSlash;
@@ -508,6 +511,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
508
511
  token: string | null;
509
512
  };
510
513
  beginUpdateProgress(adapter: ChannelAdapter, chatId: string, threadId: string | undefined, messageId: string): void;
514
+ /** Persist the public response, wait for idle, then start the canonical service restart. */
515
+ requestFullRestart(adapter: ChannelAdapter, chatId: string, threadId: string | undefined, messageId: string): Promise<boolean>;
516
+ private isOwnedFullRestartMarker;
517
+ /** Give current work the same bounded idle grace used by graceful reload. */
518
+ private waitForFullRestartIdleGrace;
519
+ private reportFullRestartFailure;
511
520
  failUpdateProgress(message: string): void;
512
521
  /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
513
522
  private startUpdateProgressMonitor;
@@ -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";
@@ -411,6 +412,8 @@ export class FleetManager {
411
412
  updateProgressEditRunning = false;
412
413
  lastUpdateProgressText = null;
413
414
  updateCompletionTipText = null;
415
+ /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
416
+ fullRestartLauncher = launchFullRestartHelper;
414
417
  eventLogPruneTimer = null;
415
418
  logRotateTimer = null;
416
419
  /** Days of event/activity history to keep. */
@@ -797,6 +800,28 @@ export class FleetManager {
797
800
  child.once("error", err => this.failUpdateProgress(err.message));
798
801
  child.unref();
799
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
+ }
800
825
  async handleTipsSlash(data, adapterId) {
801
826
  if (!this.fleetConfig)
802
827
  return;
@@ -1982,6 +2007,130 @@ export class FleetManager {
1982
2007
  this.updateCompletionTipText = null;
1983
2008
  this.startUpdateProgressMonitor(adapter);
1984
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
+ }
1985
2134
  failUpdateProgress(message) {
1986
2135
  setUpdateProgressStage(this.dataDir, "failed", { error: message });
1987
2136
  }
@@ -2064,6 +2213,9 @@ export class FleetManager {
2064
2213
  && savedUpdateProgress.progress.stage !== "complete"
2065
2214
  ? savedUpdateProgress
2066
2215
  : null;
2216
+ const pendingProgressOperation = pendingUpdateProgress
2217
+ ? updateProgressOperation(pendingUpdateProgress.progress)
2218
+ : null;
2067
2219
  if (pendingUpdateProgress) {
2068
2220
  setUpdateProgressStage(this.dataDir, "starting", { version: pendingUpdateProgress.progress.version });
2069
2221
  }
@@ -2344,7 +2496,7 @@ export class FleetManager {
2344
2496
  const allEntries = Object.entries(fleet.instances);
2345
2497
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
2346
2498
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
2347
- 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" });
2348
2500
  if (generals.length > 0) {
2349
2501
  for (const [name, cfg] of generals) {
2350
2502
  try {
@@ -2513,7 +2665,7 @@ export class FleetManager {
2513
2665
  version: agendVersion,
2514
2666
  pausedNames,
2515
2667
  failedNames,
2516
- tipText: pendingUpdateProgress && this.tipsEnabled()
2668
+ tipText: pendingProgressOperation === "update" && this.tipsEnabled()
2517
2669
  ? (() => {
2518
2670
  const tip = this.pickAvailableTip();
2519
2671
  return tip ? this.formatTip(tip) : undefined;
@@ -2522,7 +2674,16 @@ export class FleetManager {
2522
2674
  });
2523
2675
  if (!progressCompleted && fleet.channel?.group_id) {
2524
2676
  let text;
2525
- 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) {
2526
2687
  text = t("fleet.ready", started, total, agendVersion);
2527
2688
  }
2528
2689
  else if (failedNames.length === 0) {
@@ -3068,13 +3229,7 @@ export class FleetManager {
3068
3229
  await data.respond(this.topicCommands.getDashboardText());
3069
3230
  }
3070
3231
  else if (data.command === "restart") {
3071
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
3072
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
3073
- await data.respond(t("not_authorized"));
3074
- return;
3075
- }
3076
- await data.respond(t("restart.graceful"));
3077
- process.kill(process.pid, "SIGUSR2");
3232
+ await this.handleRestartSlash(data, adapterId);
3078
3233
  }
3079
3234
  else if (data.command === "compact") {
3080
3235
  const name = this.resolveSlashTarget(data.channelId, adapterId);
@@ -3417,13 +3572,7 @@ export class FleetManager {
3417
3572
  await data.respond(this.topicCommands.getDashboardText());
3418
3573
  }
3419
3574
  else if (data.command === "restart") {
3420
- const allowed = this.fleetConfig?.channel?.access?.allowed_users ?? [];
3421
- if (allowed.length > 0 && !allowed.some(u => String(u) === String(data.userId))) {
3422
- await data.respond(t("not_authorized"));
3423
- return;
3424
- }
3425
- await data.respond(t("restart.graceful"));
3426
- process.kill(process.pid, "SIGUSR2");
3575
+ await this.handleRestartSlash(data, adapterId);
3427
3576
  }
3428
3577
  else if (data.command === "compact") {
3429
3578
  const name = this.resolveSlashTarget(data.channelId, adapterId);
@@ -9725,8 +9874,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9725
9874
  return;
9726
9875
  }
9727
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
+ }
9728
9887
  const groupId = this.fleetConfig?.channel?.group_id;
9729
- if (groupId && this.adapter) {
9888
+ if (!trackedFullRestart && groupId && this.adapter) {
9730
9889
  await this.adapter.sendText(String(groupId), t("restart.full_initiated"))
9731
9890
  .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
9732
9891
  }