@songsid/agend 2.1.1-beta.1 → 2.1.1-beta.3

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.
@@ -42,6 +42,7 @@ import { handleSettingsRequest } from "./settings-api.js";
42
42
  import { setLocale, detectLocale, t } from "./locale.js";
43
43
  import { handleAgentRequest } from "./agent-endpoint.js";
44
44
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
45
+ import { validateFleetConfig } from "./config-validator.js";
45
46
  import { readLastInboundAt } from "./daemon.js";
46
47
  import { clearPausedMarker } from "./pause-marker.js";
47
48
  import { getTmuxSession } from "./config.js";
@@ -88,6 +89,8 @@ const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
88
89
  const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // /model reads cached CLI env within 24h
89
90
  export class FleetManager {
90
91
  dataDir;
92
+ static signalTarget = null;
93
+ static sighupHandlerInstalled = false;
91
94
  children = new Map();
92
95
  lifecycle;
93
96
  /** @deprecated Use lifecycle.daemons — kept for backward compat */
@@ -112,6 +115,12 @@ export class FleetManager {
112
115
  instanceIpcClients = new Map();
113
116
  scheduler = null;
114
117
  configPath = "";
118
+ /** SIGHUPs received before startAll finishes are replayed once startup is safe. */
119
+ startupComplete = false;
120
+ /** Coalesces one or more SIGHUPs into at most one follow-up reconciliation. */
121
+ reloadPending = false;
122
+ /** A running reconciliation; only one may mutate lifecycle/config state at a time. */
123
+ reconcileInFlight = null;
115
124
  logger = createLogger("info");
116
125
  topicCommands;
117
126
  // sessionName → instanceName mapping for external sessions
@@ -174,11 +183,46 @@ export class FleetManager {
174
183
  viewToken = null;
175
184
  constructor(dataDir) {
176
185
  this.dataDir = dataDir;
186
+ FleetManager.signalTarget = this;
187
+ if (!FleetManager.sighupHandlerInstalled) {
188
+ process.on("SIGHUP", () => FleetManager.signalTarget?.handleSighup());
189
+ FleetManager.sighupHandlerInstalled = true;
190
+ }
177
191
  this.lifecycle = new InstanceLifecycle(this);
178
192
  this.topicCommands = new TopicCommands(this);
179
193
  this.topicArchiver = new TopicArchiver(this);
180
194
  this.statuslineWatcher = new StatuslineWatcher(this);
181
195
  }
196
+ handleSighup() {
197
+ this.logger.info("Received SIGHUP, hot-reloading config...");
198
+ if (!this.startupComplete) {
199
+ this.reloadPending = true;
200
+ this.logger.info("Fleet startup is still in progress — queued config reload");
201
+ return;
202
+ }
203
+ this.scheduleReconcile();
204
+ }
205
+ scheduleReconcile() {
206
+ if (this.reconcileInFlight) {
207
+ this.reloadPending = true;
208
+ this.logger.info("Config reconciliation already running — coalesced reload request");
209
+ return;
210
+ }
211
+ this.reloadPending = false;
212
+ this.reconcileInFlight = this.reconcileInstances()
213
+ .catch(err => this.logger.error({ err }, "SIGHUP config reload failed"))
214
+ .finally(() => {
215
+ this.reconcileInFlight = null;
216
+ if (this.reloadPending && this.startupComplete) {
217
+ this.scheduleReconcile();
218
+ }
219
+ });
220
+ }
221
+ finishStartup() {
222
+ this.startupComplete = true;
223
+ if (this.reloadPending)
224
+ this.scheduleReconcile();
225
+ }
182
226
  // ── ArchiverContext bridge ────────────────────────────────────────────
183
227
  lastActivityMs(name) {
184
228
  return this.lastActivity.get(name) ?? 0;
@@ -796,6 +840,8 @@ export class FleetManager {
796
840
  }
797
841
  /** Start all instances from fleet config */
798
842
  async startAll(configPath) {
843
+ FleetManager.signalTarget = this;
844
+ this.startupComplete = false;
799
845
  this.configPath = configPath;
800
846
  this.loadEnvFile();
801
847
  // Rotate fleet.log if oversized (before any logging)
@@ -1184,14 +1230,6 @@ export class FleetManager {
1184
1230
  this.checkForUpdates();
1185
1231
  this.updateCheckTimer = setInterval(() => this.checkForUpdates(), 24 * 60 * 60 * 1000);
1186
1232
  }, 60 * 60 * 1000);
1187
- // SIGHUP: hot-reload instance config (add/remove/restart instances)
1188
- const onSighup = () => {
1189
- this.logger.info("Received SIGHUP, hot-reloading config...");
1190
- this.reconcileInstances()
1191
- .catch(err => this.logger.error({ err }, "SIGHUP config reload failed"));
1192
- process.once("SIGHUP", onSighup);
1193
- };
1194
- process.once("SIGHUP", onSighup);
1195
1233
  const onRestart = () => {
1196
1234
  this.logger.info("Received SIGUSR2, initiating graceful restart...");
1197
1235
  this.restartInstances()
@@ -1213,6 +1251,10 @@ export class FleetManager {
1213
1251
  });
1214
1252
  };
1215
1253
  process.once("SIGUSR1", onFullRestart);
1254
+ // A SIGHUP may arrive after the PID/general is available but before the
1255
+ // rest of startup finishes. Replay one coalesced reload only after all
1256
+ // startup-owned lifecycle work and signal handlers are in place.
1257
+ this.finishStartup();
1216
1258
  }
1217
1259
  /**
1218
1260
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
@@ -4744,6 +4786,8 @@ When users create specialized instances, suggest these configurations:
4744
4786
  return t("classic.stopped");
4745
4787
  }
4746
4788
  async stopAll() {
4789
+ this.startupComplete = false;
4790
+ this.reloadPending = false;
4747
4791
  this.ipcStoppingInstances.add("__fleet_stopping__");
4748
4792
  sdNotify("STOPPING=1");
4749
4793
  if (this.watchdogTimer) {
@@ -4951,7 +4995,40 @@ When users create specialized instances, suggest these configurations:
4951
4995
  if (!this.configPath)
4952
4996
  return;
4953
4997
  const oldConfig = this.fleetConfig;
4954
- this.loadConfig(this.configPath);
4998
+ const previousRawConfig = this.rawFleetConfig;
4999
+ const previousRawDocument = this.rawFleetDocument;
5000
+ const previousSavedSnapshot = this.savedFleetConfigSnapshot;
5001
+ try {
5002
+ this.loadConfig(this.configPath);
5003
+ }
5004
+ catch (err) {
5005
+ this.fleetConfig = oldConfig;
5006
+ this.rawFleetConfig = previousRawConfig;
5007
+ this.rawFleetDocument = previousRawDocument;
5008
+ this.savedFleetConfigSnapshot = previousSavedSnapshot;
5009
+ throw err;
5010
+ }
5011
+ const validation = validateFleetConfig(this.rawFleetConfig);
5012
+ const oldCount = Object.keys(oldConfig?.instances ?? {}).length;
5013
+ const newCount = Object.keys(this.fleetConfig?.instances ?? {}).length;
5014
+ const removedRatio = oldCount > 0 && newCount < oldCount
5015
+ ? (oldCount - newCount) / oldCount
5016
+ : 0;
5017
+ const unsafeEmpty = oldCount > 0 && newCount === 0;
5018
+ const unsafeBulkRemoval = removedRatio > 0.5;
5019
+ if (!validation.valid || unsafeEmpty || unsafeBulkRemoval) {
5020
+ this.fleetConfig = oldConfig;
5021
+ this.rawFleetConfig = previousRawConfig;
5022
+ this.rawFleetDocument = previousRawDocument;
5023
+ this.savedFleetConfigSnapshot = previousSavedSnapshot;
5024
+ this.logger.error({
5025
+ oldCount,
5026
+ newCount,
5027
+ removedRatio,
5028
+ validationErrors: validation.errors,
5029
+ }, "Refusing unsafe fleet config reload; running configuration was kept");
5030
+ return;
5031
+ }
4955
5032
  this.routing.rebuild(this.fleetConfig);
4956
5033
  this.reregisterClassicChannels();
4957
5034
  this.scheduler?.reload();