@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.
- package/dist/backend/claude-code.js +2 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +22 -0
- package/dist/backend/types.js +23 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +13 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/cli.js +37 -37
- package/dist/cli.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -0
- package/dist/fleet-manager.d.ts +39 -0
- package/dist/fleet-manager.js +264 -31
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.d.ts +20 -0
- package/dist/full-restart.js +39 -0
- package/dist/full-restart.js.map +1 -0
- package/dist/locale.js +26 -2
- package/dist/locale.js.map +1 -1
- package/dist/restart-progress.d.ts +4 -1
- package/dist/restart-progress.js +30 -20
- package/dist/restart-progress.js.map +1 -1
- package/dist/service-restart-selection.d.ts +27 -0
- package/dist/service-restart-selection.js +25 -0
- package/dist/service-restart-selection.js.map +1 -0
- package/dist/topic-commands.js +22 -7
- package/dist/topic-commands.js.map +1 -1
- package/dist/update-marker.d.ts +11 -1
- package/dist/update-marker.js +19 -4
- package/dist/update-marker.js.map +1 -1
- package/dist/update-progress.d.ts +1 -1
- package/dist/update-progress.js +12 -0
- package/dist/update-progress.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -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; //
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
//
|
|
8535
|
-
|
|
8536
|
-
|
|
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");
|