@songsid/agend 2.1.6-beta.4 → 2.1.6-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 +24 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/factory.js +4 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/muse.d.ts +90 -0
- package/dist/backend/muse.js +455 -0
- package/dist/backend/muse.js.map +1 -0
- package/dist/backend/types.d.ts +2 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/access-manager.d.ts +20 -0
- package/dist/channel/access-manager.js +25 -0
- package/dist/channel/access-manager.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +3 -0
- package/dist/channel/adapters/discord.js +13 -10
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/markdown-chunk.d.ts +8 -3
- package/dist/channel/markdown-chunk.js +12 -3
- package/dist/channel/markdown-chunk.js.map +1 -1
- package/dist/channel/tool-router.js +12 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/config-validator.js +14 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.js +7 -2
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +32 -0
- package/dist/fleet-manager.js +137 -12
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.js +8 -4
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +14 -0
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +42 -25
- package/dist/logger.js.map +1 -1
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart-api.d.ts +3 -0
- package/dist/quickstart-api.js +1 -0
- package/dist/quickstart-api.js.map +1 -1
- package/dist/scheduler/db.js +17 -5
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/db.test.js +34 -1
- package/dist/scheduler/db.test.js.map +1 -1
- package/dist/scheduler/types.d.ts +4 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/setup-wizard.js +4 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/steer-capability.js +4 -1
- package/dist/steer-capability.js.map +1 -1
- package/dist/tmux-manager.js +29 -4
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.js +1 -0
- package/dist/topic-commands.js.map +1 -1
- package/dist/web-api.js +2 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +30 -1
- package/dist/web-terminal.js +86 -3
- package/dist/web-terminal.js.map +1 -1
- package/package.json +2 -1
package/dist/fleet-manager.d.ts
CHANGED
|
@@ -573,6 +573,15 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
573
573
|
* from restarting the same process twice; backendNameOf defines ownership.
|
|
574
574
|
*/
|
|
575
575
|
private configuredBackendInstanceNames;
|
|
576
|
+
/**
|
|
577
|
+
* Probe the same executable set exposed by the web backend catalog.
|
|
578
|
+
*
|
|
579
|
+
* This deliberately has no cache: an `/install-cli` completion can add a
|
|
580
|
+
* binary to PATH while the fleet process remains alive, and the next bare
|
|
581
|
+
* `/login` must see it without requiring a restart or an explicit cache
|
|
582
|
+
* invalidation call.
|
|
583
|
+
*/
|
|
584
|
+
private probeInstalledBackends;
|
|
576
585
|
/**
|
|
577
586
|
* One fleet-level notice per burst, not one per instance: a post-update herd
|
|
578
587
|
* fails many instances within the same second. Two notices per incident at
|
|
@@ -757,6 +766,14 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
757
766
|
* Pure: callers rely on being able to ask before claiming the dedup key.
|
|
758
767
|
*/
|
|
759
768
|
private botMessageDropReason;
|
|
769
|
+
/**
|
|
770
|
+
* Say so when an adapter's access mode is coming from its state file rather
|
|
771
|
+
* than from fleet.yaml. The state file wins by design — a pairing done at
|
|
772
|
+
* runtime has to survive a restart — but that also means an edit to
|
|
773
|
+
* `access.mode` silently does nothing, which is indistinguishable from the
|
|
774
|
+
* fleet not having reloaded. Naming the file is what makes it fixable.
|
|
775
|
+
*/
|
|
776
|
+
private warnIfAccessModeOverridden;
|
|
760
777
|
private handleInboundMessage;
|
|
761
778
|
/** Handle outbound tool calls from a daemon instance */
|
|
762
779
|
/** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
|
|
@@ -1084,6 +1101,21 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
1084
1101
|
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
1085
1102
|
* of its 15 minutes.
|
|
1086
1103
|
*/
|
|
1104
|
+
/**
|
|
1105
|
+
* Collapse every still-armed button prompt, for a fleet that is going away.
|
|
1106
|
+
*
|
|
1107
|
+
* The map is in memory; the buttons are in the chat for up to 24 hours. A
|
|
1108
|
+
* restart therefore leaves them looking live — pressing one takes the stale
|
|
1109
|
+
* branch, which acknowledges the click and drops the dismissal without
|
|
1110
|
+
* saying so. Retiring them here means the user meets a spent prompt instead
|
|
1111
|
+
* of a live-looking dead one.
|
|
1112
|
+
*
|
|
1113
|
+
* Best effort and bounded. Each collapse is a platform call, a day of tips
|
|
1114
|
+
* can be many of them, and shutdown still has instances to stop. Whatever
|
|
1115
|
+
* has not finished by the deadline is abandoned — that is exactly today's
|
|
1116
|
+
* behaviour, so the budget can only leave things no worse than before.
|
|
1117
|
+
*/
|
|
1118
|
+
private retirePendingNoncePrompts;
|
|
1087
1119
|
clearNoncePromptsForInstance(instanceName: string): void;
|
|
1088
1120
|
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
1089
1121
|
* the operator's. Keep the instance notice passive and put the action in the
|
package/dist/fleet-manager.js
CHANGED
|
@@ -217,6 +217,8 @@ const CLEAR_CONFIRM_TIMEOUT_MS = 15_000;
|
|
|
217
217
|
/** Default lifetime for long-lived nonce prompts (clear overrides this to 15s). */
|
|
218
218
|
const NONCE_BUTTON_TIMEOUT_MS = 15 * 60_000;
|
|
219
219
|
const TIP_BUTTON_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
220
|
+
/** How long shutdown will spend retiring still-armed button prompts. */
|
|
221
|
+
const NONCE_RETIRE_BUDGET_MS = 5_000;
|
|
220
222
|
const CLI_ENV_TTL_MS = 24 * 60 * 60 * 1000; // hard validity bound for the cached CLI env
|
|
221
223
|
/**
|
|
222
224
|
* How old the cached CLI env may be before `/model` re-probes it live.
|
|
@@ -2019,6 +2021,22 @@ export class FleetManager {
|
|
|
2019
2021
|
names.add(channel.instanceName);
|
|
2020
2022
|
return [...names];
|
|
2021
2023
|
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Probe the same executable set exposed by the web backend catalog.
|
|
2026
|
+
*
|
|
2027
|
+
* This deliberately has no cache: an `/install-cli` completion can add a
|
|
2028
|
+
* binary to PATH while the fleet process remains alive, and the next bare
|
|
2029
|
+
* `/login` must see it without requiring a restart or an explicit cache
|
|
2030
|
+
* invalidation call.
|
|
2031
|
+
*/
|
|
2032
|
+
probeInstalledBackends() {
|
|
2033
|
+
const installed = new Set();
|
|
2034
|
+
for (const [backend, info] of Object.entries(BACKEND_INSTALLATION_INFO)) {
|
|
2035
|
+
if (checkBinaryInstalled(info.binary))
|
|
2036
|
+
installed.add(backend);
|
|
2037
|
+
}
|
|
2038
|
+
return installed;
|
|
2039
|
+
}
|
|
2022
2040
|
/**
|
|
2023
2041
|
* One fleet-level notice per burst, not one per instance: a post-update herd
|
|
2024
2042
|
* fails many instances within the same second. Two notices per incident at
|
|
@@ -3167,7 +3185,9 @@ export class FleetManager {
|
|
|
3167
3185
|
}
|
|
3168
3186
|
const accessDir = join(this.dataDir, "access");
|
|
3169
3187
|
mkdirSync(accessDir, { recursive: true });
|
|
3170
|
-
const
|
|
3188
|
+
const accessStatePath = join(accessDir, "access.json");
|
|
3189
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
3190
|
+
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
3171
3191
|
this.accessManager = accessManager;
|
|
3172
3192
|
const inboxDir = join(this.dataDir, "inbox");
|
|
3173
3193
|
mkdirSync(inboxDir, { recursive: true });
|
|
@@ -3559,7 +3579,9 @@ export class FleetManager {
|
|
|
3559
3579
|
}
|
|
3560
3580
|
const accessDir = join(this.dataDir, "access");
|
|
3561
3581
|
mkdirSync(accessDir, { recursive: true });
|
|
3562
|
-
const
|
|
3582
|
+
const accessStatePath = join(accessDir, `access-${adapterId}.json`);
|
|
3583
|
+
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
3584
|
+
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
3563
3585
|
const inboxDir = join(this.dataDir, "inbox");
|
|
3564
3586
|
mkdirSync(inboxDir, { recursive: true });
|
|
3565
3587
|
const adapter = await createAdapter(channelConfig, {
|
|
@@ -4314,13 +4336,36 @@ export class FleetManager {
|
|
|
4314
4336
|
const target = this.routing.resolve(threadId);
|
|
4315
4337
|
if (!target)
|
|
4316
4338
|
return "fleet topic: no instance routed for this thread";
|
|
4317
|
-
// Fleet topic:
|
|
4339
|
+
// Fleet topic: this gate lets the copy through when the adapter is open OR
|
|
4340
|
+
// collab is on for the instance. "Through" is not "delivered" — access
|
|
4341
|
+
// control runs next, and the two arms are not symmetric there:
|
|
4342
|
+
//
|
|
4343
|
+
// open adapter → isAllowed() returns true outright: really admitted.
|
|
4344
|
+
// locked + collab → the bot's user id still has to be on the allowlist,
|
|
4345
|
+
// so the message is normally refused a few lines later
|
|
4346
|
+
// under "Access DENIED for non-allowed user".
|
|
4347
|
+
//
|
|
4348
|
+
// Collab alone therefore does not admit a bot on a locked adapter; it only
|
|
4349
|
+
// declines to drop the copy here and leaves the decision to access control.
|
|
4318
4350
|
const isOpen = this.getChannelConfig(msg.adapterId)?.access?.mode === "open";
|
|
4319
4351
|
if (!isOpen && !this.collabInstances.has(target.name)) {
|
|
4320
4352
|
return `fleet topic: adapter not open and collab off for ${target.name}`;
|
|
4321
4353
|
}
|
|
4322
4354
|
return null;
|
|
4323
4355
|
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Say so when an adapter's access mode is coming from its state file rather
|
|
4358
|
+
* than from fleet.yaml. The state file wins by design — a pairing done at
|
|
4359
|
+
* runtime has to survive a restart — but that also means an edit to
|
|
4360
|
+
* `access.mode` silently does nothing, which is indistinguishable from the
|
|
4361
|
+
* fleet not having reloaded. Naming the file is what makes it fixable.
|
|
4362
|
+
*/
|
|
4363
|
+
warnIfAccessModeOverridden(accessManager, statePath) {
|
|
4364
|
+
const configured = accessManager.overriddenConfigMode();
|
|
4365
|
+
if (!configured)
|
|
4366
|
+
return;
|
|
4367
|
+
this.logger.warn({ statePath, configured, inEffect: accessManager.getMode() }, `access mode "${accessManager.getMode()}" comes from the state file and overrides "${configured}" in fleet.yaml; delete ${statePath} to go back to the configured value`);
|
|
4368
|
+
}
|
|
4324
4369
|
async handleInboundMessage(msg) {
|
|
4325
4370
|
const threadId = msg.threadId || undefined;
|
|
4326
4371
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
@@ -5227,6 +5272,15 @@ export class FleetManager {
|
|
|
5227
5272
|
*/
|
|
5228
5273
|
scheduleSourceAdapter(schedule) {
|
|
5229
5274
|
const chatId = String(schedule.reply_chat_id);
|
|
5275
|
+
// New schedules carry the adapter that owned the source chat at creation.
|
|
5276
|
+
// Keep that persona across source-instance rebinding, but never trust a
|
|
5277
|
+
// stale adapter after the chat has moved to another world.
|
|
5278
|
+
const persistedWorld = schedule.reply_adapter_id
|
|
5279
|
+
? this.worlds.get(schedule.reply_adapter_id)
|
|
5280
|
+
: undefined;
|
|
5281
|
+
if (persistedWorld && String(persistedWorld.groupId) === chatId) {
|
|
5282
|
+
return persistedWorld.adapter;
|
|
5283
|
+
}
|
|
5230
5284
|
const sourceAdapterId = schedule.source ? this.getInstanceAdapterId(schedule.source) : undefined;
|
|
5231
5285
|
const sourceWorld = sourceAdapterId ? this.worlds.get(sourceAdapterId) : undefined;
|
|
5232
5286
|
if (sourceWorld && String(sourceWorld.groupId) === chatId)
|
|
@@ -5337,6 +5391,7 @@ export class FleetManager {
|
|
|
5337
5391
|
target: payload.target || instanceName,
|
|
5338
5392
|
reply_chat_id: meta.chat_id,
|
|
5339
5393
|
reply_thread_id: meta.thread_id || null,
|
|
5394
|
+
reply_adapter_id: meta.adapter_id || null,
|
|
5340
5395
|
label: payload.label,
|
|
5341
5396
|
timezone: payload.timezone,
|
|
5342
5397
|
silent: !!(payload.silent),
|
|
@@ -7004,6 +7059,39 @@ export class FleetManager {
|
|
|
7004
7059
|
* (or an assist for one they stopped) must not stay clickable for the rest
|
|
7005
7060
|
* of its 15 minutes.
|
|
7006
7061
|
*/
|
|
7062
|
+
/**
|
|
7063
|
+
* Collapse every still-armed button prompt, for a fleet that is going away.
|
|
7064
|
+
*
|
|
7065
|
+
* The map is in memory; the buttons are in the chat for up to 24 hours. A
|
|
7066
|
+
* restart therefore leaves them looking live — pressing one takes the stale
|
|
7067
|
+
* branch, which acknowledges the click and drops the dismissal without
|
|
7068
|
+
* saying so. Retiring them here means the user meets a spent prompt instead
|
|
7069
|
+
* of a live-looking dead one.
|
|
7070
|
+
*
|
|
7071
|
+
* Best effort and bounded. Each collapse is a platform call, a day of tips
|
|
7072
|
+
* can be many of them, and shutdown still has instances to stop. Whatever
|
|
7073
|
+
* has not finished by the deadline is abandoned — that is exactly today's
|
|
7074
|
+
* behaviour, so the budget can only leave things no worse than before.
|
|
7075
|
+
*/
|
|
7076
|
+
async retirePendingNoncePrompts(budgetMs = NONCE_RETIRE_BUDGET_MS) {
|
|
7077
|
+
const entries = [...this.pendingNonceButtons.values()];
|
|
7078
|
+
this.pendingNonceButtons.clear();
|
|
7079
|
+
for (const entry of entries)
|
|
7080
|
+
if (entry.timer)
|
|
7081
|
+
clearTimeout(entry.timer);
|
|
7082
|
+
const collapses = entries
|
|
7083
|
+
.filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
|
|
7084
|
+
.map(entry => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")));
|
|
7085
|
+
if (!collapses.length)
|
|
7086
|
+
return;
|
|
7087
|
+
await Promise.race([
|
|
7088
|
+
Promise.allSettled(collapses),
|
|
7089
|
+
new Promise(resolve => {
|
|
7090
|
+
const timer = setTimeout(resolve, budgetMs);
|
|
7091
|
+
timer.unref?.();
|
|
7092
|
+
}),
|
|
7093
|
+
]);
|
|
7094
|
+
}
|
|
7007
7095
|
clearNoncePromptsForInstance(instanceName) {
|
|
7008
7096
|
for (const [nonce, entry] of this.pendingNonceButtons) {
|
|
7009
7097
|
if (entry.instanceName !== instanceName)
|
|
@@ -7944,11 +8032,39 @@ export class FleetManager {
|
|
|
7944
8032
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
7945
8033
|
configured.add(this.backendNameOf(name));
|
|
7946
8034
|
}
|
|
7947
|
-
const
|
|
7948
|
-
|
|
7949
|
-
|
|
8035
|
+
const installed = this.probeInstalledBackends();
|
|
8036
|
+
const candidates = new Set([...installed, ...configured]);
|
|
8037
|
+
const unsupported = [];
|
|
8038
|
+
const choices = [...candidates].sort().flatMap(backend => {
|
|
8039
|
+
const flow = LOGIN_FLOWS[backend];
|
|
8040
|
+
const remoteLogin = !!flow && flow.remoteLogin !== "unsupported";
|
|
8041
|
+
const status = [];
|
|
8042
|
+
if (installed.has(backend))
|
|
8043
|
+
status.push(t("login.status_installed"));
|
|
8044
|
+
if (configured.has(backend))
|
|
8045
|
+
status.push(t("login.status_configured"));
|
|
8046
|
+
if (remoteLogin)
|
|
8047
|
+
status.push(t("login.status_auth"));
|
|
8048
|
+
else
|
|
8049
|
+
status.push(t("login.status_unsupported"));
|
|
8050
|
+
if (!remoteLogin) {
|
|
8051
|
+
unsupported.push({ backend, flow, status });
|
|
8052
|
+
return [];
|
|
8053
|
+
}
|
|
8054
|
+
return [{ action: backend, label: `${backend} · ${status.join(" · ")}` }];
|
|
8055
|
+
});
|
|
8056
|
+
if (unsupported.length) {
|
|
8057
|
+
const guidance = unsupported.map(({ backend, flow, status }) => `${backend} · ${status.join(" · ")} — ${flow?.remoteLogin === "unsupported"
|
|
8058
|
+
? t("login.remote_unsupported_agent_cli", backend, flow.command)
|
|
8059
|
+
: backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
|
|
8060
|
+
await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
|
|
8061
|
+
.catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
|
|
8062
|
+
}
|
|
7950
8063
|
if (choices.length === 0) {
|
|
7951
|
-
|
|
8064
|
+
if (!unsupported.length) {
|
|
8065
|
+
await chat.adapter.sendText(chat.chatId, t("login.none_available"), { threadId: chat.threadId })
|
|
8066
|
+
.catch(err => this.logger.warn({ err }, "Failed to post empty login chooser guidance"));
|
|
8067
|
+
}
|
|
7952
8068
|
return;
|
|
7953
8069
|
}
|
|
7954
8070
|
await this.postNonceButtonPrompt({
|
|
@@ -8537,6 +8653,12 @@ export class FleetManager {
|
|
|
8537
8653
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
8538
8654
|
return;
|
|
8539
8655
|
}
|
|
8656
|
+
// The button is deliberately best-effort: it is a short-lived
|
|
8657
|
+
// capability and can be lost during an adapter reconnect. Always
|
|
8658
|
+
// publish a durable completion line first so a successful install can
|
|
8659
|
+
// never look like it silently disappeared. Include the binary that
|
|
8660
|
+
// passed the fresh-login-shell verification and the exact next step.
|
|
8661
|
+
await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
|
|
8540
8662
|
await this.postNonceButtonPrompt({
|
|
8541
8663
|
prefix: INSTALL_LOGIN_CALLBACK_PREFIX,
|
|
8542
8664
|
alertType: "login",
|
|
@@ -8788,6 +8910,8 @@ export class FleetManager {
|
|
|
8788
8910
|
// Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
|
|
8789
8911
|
// same files their writeConfig() appends fleet instructions to.
|
|
8790
8912
|
"grok": "AGENTS.md",
|
|
8913
|
+
// muse init scaffolds AGENTS.md and the binary reads it as project rules.
|
|
8914
|
+
"muse": "AGENTS.md",
|
|
8791
8915
|
"antigravity": ".agents/agents.md",
|
|
8792
8916
|
"mock": "CLAUDE.md",
|
|
8793
8917
|
};
|
|
@@ -8863,6 +8987,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
8863
8987
|
// vendor-canonical location is .grok/skills.
|
|
8864
8988
|
"opencode": [".agents", "skills"],
|
|
8865
8989
|
"grok": [".grok", "skills"],
|
|
8990
|
+
// Live-verified on muse 1.3.0: `muse skills list --source project` sees
|
|
8991
|
+
// .agents/skills and ignores .muse/skills.
|
|
8992
|
+
"muse": [".agents", "skills"],
|
|
8866
8993
|
"antigravity": [".agents", "skills"],
|
|
8867
8994
|
};
|
|
8868
8995
|
/** Copy general-knowledge steering + all role-eligible skills to General. */
|
|
@@ -10602,11 +10729,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10602
10729
|
for (const pending of this.pendingClassicStarts.values())
|
|
10603
10730
|
clearTimeout(pending.timer);
|
|
10604
10731
|
this.pendingClassicStarts.clear();
|
|
10605
|
-
|
|
10606
|
-
|
|
10607
|
-
|
|
10608
|
-
}
|
|
10609
|
-
this.pendingNonceButtons.clear();
|
|
10732
|
+
// Adapters are still connected here — they are stopped further down — so
|
|
10733
|
+
// this is the last moment the prompts can be collapsed.
|
|
10734
|
+
await this.retirePendingNoncePrompts();
|
|
10610
10735
|
this.topicArchiver.stop();
|
|
10611
10736
|
this.scheduler?.shutdown();
|
|
10612
10737
|
// Stop instances in parallel batches to avoid long sequential waits.
|