@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.
package/dist/fleet-manager.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/fleet-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
6697
|
-
|
|
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)");
|