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

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.
@@ -303,6 +303,8 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
303
303
  * TODO: per-instance startup timeout (existing issue, not introduced here)
304
304
  */
305
305
  private startInstancesWithConcurrency;
306
+ private runnableStartupCount;
307
+ private restartProgressTarget;
306
308
  stopInstance(name: string): Promise<void>;
307
309
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
308
310
  restartSingleInstance(name: string, opts?: {
@@ -52,6 +52,7 @@ 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
54
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
55
+ import { RestartProgress } from "./restart-progress.js";
55
56
  import { getTmuxSession } from "./config.js";
56
57
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
57
58
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -1061,7 +1062,7 @@ export class FleetManager {
1061
1062
  * to avoid config file races. Stagger delay is group-to-group, not instance-to-instance.
1062
1063
  * TODO: per-instance startup timeout (existing issue, not introduced here)
1063
1064
  */
1064
- async startInstancesWithConcurrency(entries, topicMode) {
1065
+ async startInstancesWithConcurrency(entries, topicMode, onReady) {
1065
1066
  // Persisted pauses are intentionally preserved across fleet restarts. Filter
1066
1067
  // them before grouping/staggering: startInstance() retains its own guard as
1067
1068
  // a final backstop, but putting a no-op entry in this queue still consumes a
@@ -1133,7 +1134,14 @@ export class FleetManager {
1133
1134
  lastStartAt = Date.now();
1134
1135
  (async () => {
1135
1136
  for (const [name, config] of group) {
1136
- await this.startInstance(name, config, topicMode).catch((err) => this.logger.error({ err, name }, "Failed to start instance"));
1137
+ try {
1138
+ await this.startInstance(name, config, topicMode);
1139
+ if (this.daemons.has(name))
1140
+ onReady?.(name);
1141
+ }
1142
+ catch (err) {
1143
+ this.logger.error({ err, name }, "Failed to start instance");
1144
+ }
1137
1145
  }
1138
1146
  })().finally(() => {
1139
1147
  running--;
@@ -1147,6 +1155,34 @@ export class FleetManager {
1147
1155
  startNext();
1148
1156
  });
1149
1157
  }
1158
+ runnableStartupCount(fleet, includeClassic) {
1159
+ const names = new Set(Object.keys(fleet.instances));
1160
+ if (includeClassic) {
1161
+ for (const channel of this.classicChannels?.getAll() ?? [])
1162
+ names.add(channel.instanceName);
1163
+ }
1164
+ let count = 0;
1165
+ for (const name of names) {
1166
+ if (!this.lifecycle.isPaused(name))
1167
+ count++;
1168
+ }
1169
+ return count;
1170
+ }
1171
+ restartProgressTarget() {
1172
+ const generalName = this.findGeneralInstance();
1173
+ if (!generalName)
1174
+ return null;
1175
+ const adapter = this.getAdapterForInstance(generalName);
1176
+ const chatId = this.getGroupIdForInstance(generalName);
1177
+ if (!adapter || !chatId)
1178
+ return null;
1179
+ const topicId = this.fleetConfig?.instances[generalName]?.topic_id;
1180
+ return {
1181
+ adapter,
1182
+ chatId,
1183
+ threadId: topicId != null ? String(topicId) : undefined,
1184
+ };
1185
+ }
1150
1186
  async stopInstance(name) {
1151
1187
  this.failoverActive.delete(name);
1152
1188
  this.cancelIdleButtonRetirement(name);
@@ -1255,6 +1291,7 @@ export class FleetManager {
1255
1291
  }
1256
1292
  /** Start all instances from fleet config */
1257
1293
  async startAll(configPath) {
1294
+ const startupStartedAt = Date.now();
1258
1295
  FleetManager.signalTarget = this;
1259
1296
  this.startupComplete = false;
1260
1297
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
@@ -1522,10 +1559,13 @@ export class FleetManager {
1522
1559
  const allEntries = Object.entries(fleet.instances);
1523
1560
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
1524
1561
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
1562
+ const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), startupStartedAt, this.logger);
1525
1563
  if (generals.length > 0) {
1526
1564
  for (const [name, cfg] of generals) {
1527
1565
  try {
1528
1566
  await this.startInstance(name, cfg, topicMode);
1567
+ if (this.daemons.has(name))
1568
+ startupProgress.markReady();
1529
1569
  }
1530
1570
  catch (err) {
1531
1571
  this.logger.error({ err, name }, "Failed to start general instance");
@@ -1540,6 +1580,26 @@ export class FleetManager {
1540
1580
  }
1541
1581
  }
1542
1582
  }
1583
+ // The adapter must exist before General can receive the progress message.
1584
+ // Start it after General is ready, in parallel with the remaining CLIs, so
1585
+ // progress is visible without adding adapter startup time to the critical path.
1586
+ let adapterStartup = null;
1587
+ let progressStart = Promise.resolve(false);
1588
+ if (topicMode && (fleet.channel || fleet.channels?.length)) {
1589
+ // An adapter becoming reachable during startup can receive messages; make
1590
+ // all existing topic ids routable before opening that inbound path.
1591
+ this.routing.rebuild(fleet);
1592
+ this.reregisterClassicChannels();
1593
+ adapterStartup = (async () => {
1594
+ try {
1595
+ await this.startSharedAdapter(fleet);
1596
+ }
1597
+ catch (err) {
1598
+ this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1599
+ }
1600
+ })();
1601
+ progressStart = adapterStartup.then(() => startupProgress.start(this.restartProgressTarget()));
1602
+ }
1543
1603
  // The systemd watchdog answers exactly one question: is this process still
1544
1604
  // turning its event loop? Pinging from a timer proves that, and after the
1545
1605
  // blocking child-process calls were made async it is a meaningful signal —
@@ -1565,15 +1625,10 @@ export class FleetManager {
1565
1625
  this.logRotateTimer.unref?.();
1566
1626
  // Phase 2: Start remaining instances with staggered concurrency
1567
1627
  if (others.length > 0) {
1568
- await this.startInstancesWithConcurrency(others, topicMode);
1628
+ await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady());
1569
1629
  }
1570
1630
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
1571
- try {
1572
- await this.startSharedAdapter(fleet);
1573
- }
1574
- catch (err) {
1575
- this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1576
- }
1631
+ await adapterStartup;
1577
1632
  // Bind every fleet instance deterministically. Explicit channel_id wins;
1578
1633
  // otherwise channels[0] is authoritative. Do not infer identity from
1579
1634
  // concurrent adapter startup or whichever bot receives a message first.
@@ -1614,18 +1669,30 @@ export class FleetManager {
1614
1669
  // Start classic channel instances (parallel, concurrency 3)
1615
1670
  if (this.classicChannels) {
1616
1671
  const fleetBackend = this.fleetConfig?.defaults?.backend;
1617
- const channels = this.classicChannels.getAll();
1672
+ const channels = this.classicChannels.getAll()
1673
+ .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
1618
1674
  const concurrency = 3;
1619
1675
  let idx = 0;
1620
1676
  while (idx < channels.length) {
1621
1677
  const batch = channels.slice(idx, idx + concurrency);
1622
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
1678
+ await Promise.allSettled(batch.map(async (ch) => {
1679
+ try {
1680
+ await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
1681
+ if (this.daemons.has(ch.instanceName))
1682
+ startupProgress.markReady();
1683
+ }
1684
+ catch (err) {
1685
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
1686
+ }
1687
+ }));
1623
1688
  idx += concurrency;
1624
1689
  }
1625
1690
  }
1626
1691
  for (const name of Object.keys(fleet.instances)) {
1627
1692
  this.startStatuslineWatcher(name);
1628
1693
  }
1694
+ await progressStart;
1695
+ const progressCompleted = await startupProgress.finish();
1629
1696
  // Notify General topic that fleet is up
1630
1697
  const classicCount = this.classicChannels?.getAll().length ?? 0;
1631
1698
  const total = Object.keys(fleet.instances).length + classicCount;
@@ -1638,7 +1705,7 @@ export class FleetManager {
1638
1705
  const { createRequire } = await import("node:module");
1639
1706
  const _require = createRequire(import.meta.url);
1640
1707
  const agendVersion = _require("../package.json").version ?? "unknown";
1641
- if (this.adapter && fleet.channel?.group_id) {
1708
+ if (!progressCompleted && this.adapter && fleet.channel?.group_id) {
1642
1709
  let text;
1643
1710
  if (failedNames.length === 0 && pausedNames.length === 0) {
1644
1711
  text = t("fleet.ready", started, total, agendVersion);
@@ -6618,6 +6685,10 @@ When users create specialized instances, suggest these configurations:
6618
6685
  clearTimeout(timeoutHandle);
6619
6686
  }
6620
6687
  this.logger.info("All instances idle — restarting...");
6688
+ const restartStartedAt = Date.now();
6689
+ // Capture the live adapter/topic before General's daemon is stopped. The
6690
+ // channel adapter remains connected throughout an in-process restart.
6691
+ const progressTarget = this.restartProgressTarget();
6621
6692
  this.clearStatuslineWatchers();
6622
6693
  for (const [, ipc] of this.instanceIpcClients) {
6623
6694
  await ipc.close();
@@ -6642,15 +6713,25 @@ When users create specialized instances, suggest these configurations:
6642
6713
  const fleet = this.loadConfig(this.configPath);
6643
6714
  this.fleetConfig = fleet;
6644
6715
  const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
6716
+ const restartProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), restartStartedAt, this.logger);
6645
6717
  // Phase 1: generals first
6646
6718
  const restartEntries = Object.entries(fleet.instances);
6647
6719
  const restartGenerals = restartEntries.filter(([_, cfg]) => cfg.general_topic);
6648
6720
  const restartOthers = restartEntries.filter(([_, cfg]) => !cfg.general_topic);
6649
6721
  for (const [name, cfg] of restartGenerals) {
6650
- await this.startInstance(name, cfg, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start general instance"));
6722
+ try {
6723
+ await this.startInstance(name, cfg, topicMode);
6724
+ if (this.daemons.has(name))
6725
+ restartProgress.markReady();
6726
+ }
6727
+ catch (err) {
6728
+ this.logger.error({ err, name }, "Failed to start general instance");
6729
+ }
6651
6730
  }
6731
+ // General is ready again; now its topic can own the live progress message.
6732
+ await restartProgress.start(progressTarget);
6652
6733
  if (restartOthers.length > 0) {
6653
- await this.startInstancesWithConcurrency(restartOthers, topicMode);
6734
+ await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady());
6654
6735
  }
6655
6736
  if (topicMode) {
6656
6737
  this.routing.rebuild(this.fleetConfig);
@@ -6659,12 +6740,22 @@ When users create specialized instances, suggest these configurations:
6659
6740
  // Restart classic channel instances (killed during orphan cleanup)
6660
6741
  if (this.classicChannels) {
6661
6742
  const fleetBackend = this.fleetConfig?.defaults?.backend;
6662
- const channels = this.classicChannels.getAll();
6743
+ const channels = this.classicChannels.getAll()
6744
+ .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
6663
6745
  const concurrency = 3;
6664
6746
  let idx = 0;
6665
6747
  while (idx < channels.length) {
6666
6748
  const batch = channels.slice(idx, idx + concurrency);
6667
- await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
6749
+ await Promise.allSettled(batch.map(async (ch) => {
6750
+ try {
6751
+ await this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend), this.classicChannels.getPreTaskCommand(ch.channelId, ch.adapterId), this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
6752
+ if (this.daemons.has(ch.instanceName))
6753
+ restartProgress.markReady();
6754
+ }
6755
+ catch (err) {
6756
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance");
6757
+ }
6758
+ }));
6668
6759
  idx += concurrency;
6669
6760
  }
6670
6761
  }
@@ -6673,6 +6764,7 @@ When users create specialized instances, suggest these configurations:
6673
6764
  }
6674
6765
  }
6675
6766
  this.logger.info("Graceful restart complete");
6767
+ const progressCompleted = await restartProgress.finish();
6676
6768
  if (groupId && this.adapter) {
6677
6769
  const total = Object.keys(fleet.instances).length;
6678
6770
  const started = this.daemons.size;
@@ -6693,8 +6785,10 @@ When users create specialized instances, suggest these configurations:
6693
6785
  restartText = t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "))
6694
6786
  + (pausedNames2.length > 0 ? `\n⏸ Paused: ${pausedNames2.join(", ")}` : "");
6695
6787
  }
6696
- await this.adapter.sendText(String(groupId), restartText, notifyOpts)
6697
- .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
6788
+ if (!progressCompleted) {
6789
+ await this.adapter.sendText(String(groupId), restartText, notifyOpts)
6790
+ .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
6791
+ }
6698
6792
  // Notify each instance's channel — staggered to avoid rate limit storm
6699
6793
  const instances = Object.entries(this.fleetConfig?.instances ?? {});
6700
6794
  this.logger.info({ count: instances.length }, "Sending restart notification to instances (staggered)");