@songsid/agend 2.1.5-beta.9 → 2.1.6-beta.1
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.d.ts +12 -0
- package/dist/backend/claude-code.js +12 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +41 -1
- package/dist/backend/codex.js +126 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +30 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +61 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +17 -2
- package/dist/channel/adapters/discord.js +64 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +41 -2
- package/dist/channel/adapters/telegram.js +116 -20
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/factory.js +6 -1
- package/dist/channel/factory.js.map +1 -1
- package/dist/channel/types.d.ts +33 -1
- package/dist/classic-channel-manager.d.ts +23 -3
- package/dist/classic-channel-manager.js +35 -0
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/config-validator.js +23 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts +158 -21
- package/dist/daemon.js +1099 -200
- package/dist/daemon.js.map +1 -1
- package/dist/deadline.d.ts +23 -0
- package/dist/deadline.js +29 -0
- package/dist/deadline.js.map +1 -0
- package/dist/fleet-context.d.ts +15 -1
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +186 -3
- package/dist/fleet-manager.js +833 -119
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +26 -2
- package/dist/instance-lifecycle.d.ts +8 -2
- package/dist/instance-lifecycle.js +47 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instance-removal.d.ts +13 -0
- package/dist/instance-removal.js +14 -0
- package/dist/instance-removal.js.map +1 -0
- package/dist/instructions.js +2 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +28 -2
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +31 -4
- package/dist/login-controller.js +52 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +29 -0
- package/dist/login-flows.js +48 -1
- package/dist/login-flows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +1 -0
- package/dist/outbound-handlers.js +118 -29
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +25 -2
- package/dist/pane-input-residue.js +31 -2
- package/dist/pane-input-residue.js.map +1 -1
- package/dist/restart-progress.js +3 -21
- package/dist/restart-progress.js.map +1 -1
- package/dist/settings-api.d.ts +1 -1
- package/dist/settings-api.js +38 -6
- package/dist/settings-api.js.map +1 -1
- package/dist/topic-commands.d.ts +27 -2
- package/dist/topic-commands.js +37 -6
- package/dist/topic-commands.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +41 -0
- package/dist/turn-reply-guard.js +77 -0
- package/dist/turn-reply-guard.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/ui/dashboard.html +38 -10
- package/dist/ui/settings.html +93 -15
- package/dist/ui/view.html +35 -20
- package/dist/view-api.d.ts +1 -0
- package/dist/view-api.js +12 -2
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +22 -1
- package/dist/web-api.js +25 -1
- package/dist/web-api.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { freemem, totalmem } from "node:os";
|
|
4
|
+
import { freemem, totalmem, cpus } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
6
|
import { join, dirname, basename } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
@@ -33,7 +33,7 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
33
33
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
34
34
|
import { Scheduler } from "./scheduler/index.js";
|
|
35
35
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
36
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
36
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
|
|
37
37
|
import { DailySummary } from "./daily-summary.js";
|
|
38
38
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
39
39
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -48,12 +48,14 @@ import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
|
48
48
|
import { handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
49
49
|
import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES, checkAuthStatus } from "./login-flows.js";
|
|
50
50
|
import { LoginSession } from "./login-manager.js";
|
|
51
|
-
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX } from "./login-controller.js";
|
|
51
|
+
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS, announcePostLoginRecovery } from "./login-controller.js";
|
|
52
|
+
import { runBeforeDeadline } from "./deadline.js";
|
|
52
53
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
53
54
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
54
55
|
import { setLocale, detectLocale, getLocale, t } from "./locale.js";
|
|
55
56
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
56
57
|
import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
|
|
58
|
+
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
57
59
|
import { validateFleetConfig } from "./config-validator.js";
|
|
58
60
|
import { readLastInboundAt } from "./daemon.js";
|
|
59
61
|
import { clearPausedMarker } from "./pause-marker.js";
|
|
@@ -179,6 +181,7 @@ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌"]);
|
|
|
179
181
|
const IGNORED_REACTION_EMOJIS = new Set(["📷"]);
|
|
180
182
|
const HOT_INSTANCE_CONFIG_KEYS = new Set([
|
|
181
183
|
"tool_progress",
|
|
184
|
+
"reply_completion_guard",
|
|
182
185
|
"mcp_proxy_reply",
|
|
183
186
|
"auto_pause_after",
|
|
184
187
|
"warm_cap",
|
|
@@ -219,7 +222,7 @@ const HANG_CALLBACK_PREFIX = "hang:";
|
|
|
219
222
|
const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
220
223
|
const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
|
|
221
224
|
const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
|
|
222
|
-
const LOGIN_CALLBACK_PREFIX = "login:";
|
|
225
|
+
export const LOGIN_CALLBACK_PREFIX = "login:";
|
|
223
226
|
const INSTALL_CALLBACK_PREFIX = "install-select:";
|
|
224
227
|
const CLASSIC_APPROVE_CALLBACK_PREFIX = "classic-approve:";
|
|
225
228
|
const LOGIN_MENU_CALLBACK_PREFIX = "login-menu:";
|
|
@@ -262,6 +265,18 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
|
|
|
262
265
|
* The wait is announced before it starts, so it reads as progress, not a stall.
|
|
263
266
|
*/
|
|
264
267
|
export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
|
|
268
|
+
/**
|
|
269
|
+
* How many CLIs may cold-start at once, from BOTH memory and cores.
|
|
270
|
+
*
|
|
271
|
+
* Memory alone said 10 on any host with roughly 3GB free, so a three-core box
|
|
272
|
+
* started ten CLIs together, saturated the CPU, and healthy starts then missed
|
|
273
|
+
* their startup budget — which used to cost the user their conversation. Cores
|
|
274
|
+
* bound how many can actually make progress; memory bounds how many fit.
|
|
275
|
+
*/
|
|
276
|
+
export function deriveSpawnConcurrency(freeMemMB, cores) {
|
|
277
|
+
const byMemory = Math.floor(freeMemMB / 300);
|
|
278
|
+
return Math.max(2, Math.min(10, byMemory, Math.max(1, cores)));
|
|
279
|
+
}
|
|
265
280
|
export class FleetManager {
|
|
266
281
|
dataDir;
|
|
267
282
|
static signalTarget = null;
|
|
@@ -303,6 +318,33 @@ export class FleetManager {
|
|
|
303
318
|
reloadPending = false;
|
|
304
319
|
/** A running reconciliation; only one may mutate lifecycle/config state at a time. */
|
|
305
320
|
reconcileInFlight = null;
|
|
321
|
+
/** Topology checks are serialized separately from config reconciliation. */
|
|
322
|
+
topicCleanupInFlight = null;
|
|
323
|
+
topicCleanupGeneration = 0;
|
|
324
|
+
topicProbeWarnings = new Map();
|
|
325
|
+
/**
|
|
326
|
+
* Consecutive unknown probe results per route (or per adapter for outage
|
|
327
|
+
* class reasons). A single transient never reaches the operator; only a
|
|
328
|
+
* streak of TOPIC_PROBE_UNKNOWN_ESCALATION does.
|
|
329
|
+
*/
|
|
330
|
+
topicProbeUnknownStreak = new Map();
|
|
331
|
+
/** Unknown results in a row before the operator is told. 3 × 5 min poller = 15 min. */
|
|
332
|
+
static TOPIC_PROBE_UNKNOWN_ESCALATION = 3;
|
|
333
|
+
/** Reasons that describe the adapter, not one topic — counted once per adapter. */
|
|
334
|
+
static TOPIC_PROBE_ADAPTER_SCOPED_REASONS = new Set([
|
|
335
|
+
"owner-adapter-unavailable",
|
|
336
|
+
"owner-adapter-not-ready",
|
|
337
|
+
"owner-adapter-generation-changed",
|
|
338
|
+
"owner-adapter-changed-before-action",
|
|
339
|
+
"adapter-not-ready",
|
|
340
|
+
"adapter-not-initialized",
|
|
341
|
+
"adapter-generation-changed",
|
|
342
|
+
"topic-close-from-unready-adapter",
|
|
343
|
+
"topic-close-generation-changed",
|
|
344
|
+
// Telegram probe: the transport or Telegram itself is down, not one topic.
|
|
345
|
+
"transport-failed",
|
|
346
|
+
"provider-unavailable",
|
|
347
|
+
]);
|
|
306
348
|
logger = createLogger("info");
|
|
307
349
|
topicCommands;
|
|
308
350
|
// sessionName → instanceName mapping for external sessions
|
|
@@ -341,6 +383,10 @@ export class FleetManager {
|
|
|
341
383
|
// reply, on cancel, or when a newer button supersedes it for the same
|
|
342
384
|
// instance. Per-button tracking means a failed delete never strands a button.
|
|
343
385
|
cancelButtons = new Map();
|
|
386
|
+
/** Latest publication generation per instance. Late results from older
|
|
387
|
+
* generations are retired without touching the current button. */
|
|
388
|
+
cancelButtonPublications = new Map();
|
|
389
|
+
nextCancelButtonPublicationGeneration = 0;
|
|
344
390
|
/** Pending idle-edge retirement, one timer per instance. */
|
|
345
391
|
cancelButtonIdleRetireTimers = new Map();
|
|
346
392
|
/** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
|
|
@@ -452,8 +498,7 @@ export class FleetManager {
|
|
|
452
498
|
const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
|
|
453
499
|
if (explicit != null)
|
|
454
500
|
return Math.max(1, Math.min(20, explicit));
|
|
455
|
-
|
|
456
|
-
return Math.max(2, Math.min(10, Math.floor(freeMemMB / 300)));
|
|
501
|
+
return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
|
|
457
502
|
}
|
|
458
503
|
/** Wire the one fleet-wide storm into notification and recovery surfaces. */
|
|
459
504
|
bindStormWindowEvents() {
|
|
@@ -916,6 +961,45 @@ export class FleetManager {
|
|
|
916
961
|
}
|
|
917
962
|
return channels[0];
|
|
918
963
|
}
|
|
964
|
+
/**
|
|
965
|
+
* The configured world that owns `chatId`, when that is provably NOT the
|
|
966
|
+
* world `target` lives in. Returns undefined when they agree, when there is
|
|
967
|
+
* nothing to check, or when no configured channel claims the id.
|
|
968
|
+
*
|
|
969
|
+
* Deliberately one-sided: only a POSITIVE match against another channel's
|
|
970
|
+
* group id counts as foreign. A chat id that matches nothing may still be
|
|
971
|
+
* legitimate for this world (a classic channel, a DM), and treating
|
|
972
|
+
* "unrecognised" as "wrong" would stop seeding for cases that work today.
|
|
973
|
+
*
|
|
974
|
+
* Read from config rather than the live worlds map on purpose: a channel
|
|
975
|
+
* whose adapter failed to start still owns its group id, and the coordinates
|
|
976
|
+
* are just as unusable by the target's adapter either way.
|
|
977
|
+
*
|
|
978
|
+
* This is the other half of what scheduleSourceAdapter fixed. That one stops
|
|
979
|
+
* the trigger NOTICE being sent through the wrong bot; this one stops the
|
|
980
|
+
* same coordinates being planted as the target instance's reply context,
|
|
981
|
+
* which is what made its own replies fail until someone spoke to it (#752).
|
|
982
|
+
*/
|
|
983
|
+
scheduleChatWorldMismatch(target, chatId) {
|
|
984
|
+
if (!chatId)
|
|
985
|
+
return undefined;
|
|
986
|
+
const targetWorld = this.getInstanceAdapterId(target);
|
|
987
|
+
if (!targetWorld)
|
|
988
|
+
return undefined;
|
|
989
|
+
const channels = this.fleetConfig?.channels
|
|
990
|
+
?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
991
|
+
// ALL owners, not the first: a persona bot shares the primary's guild
|
|
992
|
+
// (quickstart writes `group_id: primary.group_id`), so one group id is
|
|
993
|
+
// legitimately claimed by two channels. Taking the first match called a
|
|
994
|
+
// persona instance's own guild "another world" and stopped seeding a
|
|
995
|
+
// context it can address perfectly well.
|
|
996
|
+
const owners = channels.filter(ch => ch.group_id != null && String(ch.group_id) === String(chatId));
|
|
997
|
+
if (owners.length === 0)
|
|
998
|
+
return undefined;
|
|
999
|
+
if (owners.some(ch => (ch.id ?? ch.type) === targetWorld))
|
|
1000
|
+
return undefined;
|
|
1001
|
+
return owners[0].id ?? owners[0].type;
|
|
1002
|
+
}
|
|
919
1003
|
/** Get the group_id for an instance's bound adapter */
|
|
920
1004
|
getGroupIdForInstance(name) {
|
|
921
1005
|
const adapterId = this.getInstanceAdapterId(name);
|
|
@@ -1101,12 +1185,17 @@ export class FleetManager {
|
|
|
1101
1185
|
}
|
|
1102
1186
|
scheduleIdleButtonRetirement(name) {
|
|
1103
1187
|
this.cancelIdleButtonRetirement(name);
|
|
1188
|
+
// Bind this edge to the publication that was current when idle was
|
|
1189
|
+
// observed. A later inbound may start a new generation before this timer
|
|
1190
|
+
// fires; the old edge must not mark that newer button for retirement.
|
|
1191
|
+
const publication = this.cancelButtonPublications.get(name);
|
|
1104
1192
|
const timer = setTimeout(() => {
|
|
1105
1193
|
// Ignore a superseded timer even if it was already queued to run.
|
|
1106
1194
|
if (this.cancelButtonIdleRetireTimers.get(name) !== timer)
|
|
1107
1195
|
return;
|
|
1108
1196
|
this.cancelButtonIdleRetireTimers.delete(name);
|
|
1109
1197
|
if (this.getInstanceExecutionState(name) === "idle") {
|
|
1198
|
+
this.markCancelButtonPublicationForRetirement(name, publication);
|
|
1110
1199
|
this.retireInstanceButtons(name);
|
|
1111
1200
|
}
|
|
1112
1201
|
}, CANCEL_BTN_IDLE_RETIRE_GRACE_MS);
|
|
@@ -1426,8 +1515,27 @@ export class FleetManager {
|
|
|
1426
1515
|
await this.lifecycle.pause(name);
|
|
1427
1516
|
return this.lifecycle.isPaused(name) ? "paused" : "not_idle";
|
|
1428
1517
|
}
|
|
1518
|
+
/** Deliver an already-resolved hot snapshot without depending on IPC timing. */
|
|
1519
|
+
applyHotConfigUpdate(instanceName, update) {
|
|
1520
|
+
const daemon = this.daemons.get(instanceName);
|
|
1521
|
+
if (!daemon)
|
|
1522
|
+
return false;
|
|
1523
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1524
|
+
const sent = ipc?.connected === true && ipc.send({ type: "config_update", config: update });
|
|
1525
|
+
if (!sent) {
|
|
1526
|
+
daemon.applyConfigUpdate(update);
|
|
1527
|
+
this.logger.warn({ name: instanceName }, "Config-update IPC unavailable — applied hot config in-process");
|
|
1528
|
+
}
|
|
1529
|
+
return true;
|
|
1530
|
+
}
|
|
1531
|
+
classicBehaviorUpdate(instanceName) {
|
|
1532
|
+
return {
|
|
1533
|
+
tool_progress: this.classicChannels?.getToolProgressByInstance(instanceName, this.fleetConfig?.defaults?.tool_progress) ?? "off",
|
|
1534
|
+
reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1429
1537
|
/** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
|
|
1430
|
-
async restartClassicInstanceFromSettings(instanceName) {
|
|
1538
|
+
async restartClassicInstanceFromSettings(instanceName, changedFields = []) {
|
|
1431
1539
|
if (!this.classicChannels)
|
|
1432
1540
|
throw new Error("Classic channel manager not initialized");
|
|
1433
1541
|
const wasRunning = this.daemons.has(instanceName);
|
|
@@ -1439,10 +1547,77 @@ export class FleetManager {
|
|
|
1439
1547
|
throw new Error("Classic channel not found after reload");
|
|
1440
1548
|
if (!wasRunning)
|
|
1441
1549
|
return;
|
|
1550
|
+
const hotOnly = changedFields.length > 0
|
|
1551
|
+
&& changedFields.every(field => field === "tool_progress" || field === "reply_completion_guard");
|
|
1552
|
+
if (hotOnly) {
|
|
1553
|
+
this.applyHotConfigUpdate(instanceName, this.classicBehaviorUpdate(instanceName));
|
|
1554
|
+
this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1442
1557
|
await this.stopInstance(instanceName);
|
|
1443
1558
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1444
1559
|
await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
1445
1560
|
}
|
|
1561
|
+
/** Reload classicBot.yaml once. Kept callable so the periodic production
|
|
1562
|
+
* path is covered without relying on fake timers around startAll(). */
|
|
1563
|
+
async reloadClassicConfigFromDisk() {
|
|
1564
|
+
try {
|
|
1565
|
+
if (!this.classicChannels)
|
|
1566
|
+
return;
|
|
1567
|
+
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
1568
|
+
const fleetModel = this.fleetConfig?.defaults?.model;
|
|
1569
|
+
const oldBackends = new Map();
|
|
1570
|
+
const oldModels = new Map();
|
|
1571
|
+
const oldAutoPause = new Map();
|
|
1572
|
+
const oldToolProgress = new Map();
|
|
1573
|
+
const oldReplyGuard = new Map();
|
|
1574
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
1575
|
+
oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
|
|
1576
|
+
oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
|
|
1577
|
+
oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
1578
|
+
oldToolProgress.set(ch.instanceName, this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress));
|
|
1579
|
+
oldReplyGuard.set(ch.instanceName, this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard));
|
|
1580
|
+
}
|
|
1581
|
+
if (!this.classicChannels.checkReload())
|
|
1582
|
+
return;
|
|
1583
|
+
// A reload can introduce a bad id (hand edit) or clear one; the
|
|
1584
|
+
// throttle keeps a repeated report from flooding the topic.
|
|
1585
|
+
this.reportClassicUnrecoverableIds();
|
|
1586
|
+
this.reregisterClassicChannels();
|
|
1587
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
1588
|
+
const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
|
|
1589
|
+
const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
|
|
1590
|
+
const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
|
|
1591
|
+
const newToolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
|
|
1592
|
+
const newReplyGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
|
|
1593
|
+
const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
|
|
1594
|
+
const modelChanged = oldModels.get(ch.instanceName) !== newModel;
|
|
1595
|
+
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
1596
|
+
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
|
|
1597
|
+
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
|
|
1598
|
+
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
1599
|
+
// Small delay to let tmux window clean up
|
|
1600
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1601
|
+
// The manager already holds the new backend/model/auto-pause; the
|
|
1602
|
+
// unattended helper reads them from it and schedules the delayed
|
|
1603
|
+
// retry on failure like every other unattended start.
|
|
1604
|
+
await this.startClassicInstanceUnattended(ch, "classic instance after backend/model change");
|
|
1605
|
+
}
|
|
1606
|
+
else if (this.daemons.has(ch.instanceName)
|
|
1607
|
+
&& (oldToolProgress.get(ch.instanceName) !== newToolProgress
|
|
1608
|
+
|| oldReplyGuard.get(ch.instanceName) !== newReplyGuard)) {
|
|
1609
|
+
this.applyHotConfigUpdate(ch.instanceName, {
|
|
1610
|
+
tool_progress: newToolProgress,
|
|
1611
|
+
reply_completion_guard: newReplyGuard,
|
|
1612
|
+
});
|
|
1613
|
+
this.logger.info({ instanceName: ch.instanceName }, "Classic instance hot config reloaded");
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
catch (err) {
|
|
1618
|
+
this.logger.warn({ err }, "classicBot.yaml reload error");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1446
1621
|
async startInstance(name, config, topicMode, kind = "fleet-topic",
|
|
1447
1622
|
/**
|
|
1448
1623
|
* Explicit starts (CLI/API) may resume a paused or failed daemon. Startup
|
|
@@ -2300,48 +2475,8 @@ export class FleetManager {
|
|
|
2300
2475
|
this.instanceWorldBinding.set(ch.instanceName, ch.adapterId);
|
|
2301
2476
|
}
|
|
2302
2477
|
// Poll classicBot.yaml for external changes every 30s
|
|
2303
|
-
this.classicReloadTimer = setInterval(
|
|
2304
|
-
|
|
2305
|
-
if (!this.classicChannels)
|
|
2306
|
-
return;
|
|
2307
|
-
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
2308
|
-
const fleetModel = this.fleetConfig?.defaults?.model;
|
|
2309
|
-
const oldBackends = new Map();
|
|
2310
|
-
const oldModels = new Map();
|
|
2311
|
-
const oldAutoPause = new Map();
|
|
2312
|
-
for (const ch of this.classicChannels.getAll()) {
|
|
2313
|
-
oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
|
|
2314
|
-
oldModels.set(ch.instanceName, this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel));
|
|
2315
|
-
oldAutoPause.set(ch.instanceName, this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2316
|
-
}
|
|
2317
|
-
if (!this.classicChannels.checkReload())
|
|
2318
|
-
return;
|
|
2319
|
-
// A reload can introduce a bad id (hand edit) or clear one; the
|
|
2320
|
-
// throttle keeps a repeated report from flooding the topic.
|
|
2321
|
-
this.reportClassicUnrecoverableIds();
|
|
2322
|
-
this.reregisterClassicChannels();
|
|
2323
|
-
for (const ch of this.classicChannels.getAll()) {
|
|
2324
|
-
const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
|
|
2325
|
-
const newModel = this.classicChannels.getModel(ch.channelId, ch.adapterId, fleetModel);
|
|
2326
|
-
const newAutoPause = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
|
|
2327
|
-
const backendChanged = oldBackends.get(ch.instanceName) !== newBackend;
|
|
2328
|
-
const modelChanged = oldModels.get(ch.instanceName) !== newModel;
|
|
2329
|
-
const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
|
|
2330
|
-
if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged)) {
|
|
2331
|
-
this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel }, "Backend/model changed — restarting");
|
|
2332
|
-
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
2333
|
-
// Small delay to let tmux window clean up
|
|
2334
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
2335
|
-
// The manager already holds the new backend/model/auto-pause; the
|
|
2336
|
-
// unattended helper reads them from it and schedules the delayed
|
|
2337
|
-
// retry on failure like every other unattended start.
|
|
2338
|
-
await this.startClassicInstanceUnattended(ch, "classic instance after backend/model change");
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
}
|
|
2342
|
-
catch (err) {
|
|
2343
|
-
this.logger.warn({ err }, "classicBot.yaml reload error");
|
|
2344
|
-
}
|
|
2478
|
+
this.classicReloadTimer = setInterval(() => {
|
|
2479
|
+
void this.reloadClassicConfigFromDisk();
|
|
2345
2480
|
}, 30_000);
|
|
2346
2481
|
const costGuardConfig = {
|
|
2347
2482
|
...DEFAULT_COST_GUARD,
|
|
@@ -2369,10 +2504,7 @@ export class FleetManager {
|
|
|
2369
2504
|
...fleet.defaults.daily_summary,
|
|
2370
2505
|
};
|
|
2371
2506
|
this.dailySummary = new DailySummary(summaryConfig, costGuardConfig.timezone, (text) => {
|
|
2372
|
-
|
|
2373
|
-
return;
|
|
2374
|
-
this.adapter.sendText(String(this.fleetConfig.channel.group_id), text)
|
|
2375
|
-
.catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
|
|
2507
|
+
this.postDailySummary(text);
|
|
2376
2508
|
// Rotate classic channel chat logs daily
|
|
2377
2509
|
this.classicChannels?.rotateLogs();
|
|
2378
2510
|
this.rotateInboxes();
|
|
@@ -2970,10 +3102,11 @@ export class FleetManager {
|
|
|
2970
3102
|
accessManager,
|
|
2971
3103
|
inboxDir,
|
|
2972
3104
|
});
|
|
2973
|
-
const
|
|
3105
|
+
const adapter = this.adapter;
|
|
3106
|
+
const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
|
|
2974
3107
|
this.worlds.set(adapterId, world);
|
|
2975
|
-
this.adapters.set(adapterId,
|
|
2976
|
-
this.bindAdapterHealth(
|
|
3108
|
+
this.adapters.set(adapterId, adapter);
|
|
3109
|
+
this.bindAdapterHealth(adapter, adapterId);
|
|
2977
3110
|
this.adapter.on("message", safeHandler(async (msg) => {
|
|
2978
3111
|
await this.handleInboundMessage(msg);
|
|
2979
3112
|
}, this.logger, "adapter.message"));
|
|
@@ -3018,12 +3151,7 @@ export class FleetManager {
|
|
|
3018
3151
|
return;
|
|
3019
3152
|
}
|
|
3020
3153
|
}, this.logger, "adapter.callback_query"));
|
|
3021
|
-
this.adapter.
|
|
3022
|
-
// Skip unbind if we archived this topic ourselves
|
|
3023
|
-
if (this.topicArchiver.isArchived(data.threadId))
|
|
3024
|
-
return;
|
|
3025
|
-
await this.topicCommands.handleTopicDeleted(data.threadId);
|
|
3026
|
-
}, this.logger, "adapter.topic_closed"));
|
|
3154
|
+
this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
|
|
3027
3155
|
// Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
|
|
3028
3156
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
3029
3157
|
if (data.command === "start") {
|
|
@@ -3414,11 +3542,7 @@ export class FleetManager {
|
|
|
3414
3542
|
return;
|
|
3415
3543
|
}
|
|
3416
3544
|
}, this.logger, `adapter[${adapterId}].callback_query`));
|
|
3417
|
-
|
|
3418
|
-
if (this.topicArchiver.isArchived(data.threadId))
|
|
3419
|
-
return;
|
|
3420
|
-
await this.topicCommands.handleTopicDeleted(data.threadId);
|
|
3421
|
-
}, this.logger, `adapter[${adapterId}].topic_closed`));
|
|
3545
|
+
this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
|
|
3422
3546
|
// Slash commands: classic bot + admin commands
|
|
3423
3547
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
3424
3548
|
if (data.command === "start") {
|
|
@@ -4777,7 +4901,9 @@ export class FleetManager {
|
|
|
4777
4901
|
// The adapter resolves only after the platform POST returns. Keep
|
|
4778
4902
|
// outward-facing logs/cancel state on the same confirmation boundary:
|
|
4779
4903
|
// a routed-but-failed reply is not a delivered reply.
|
|
4780
|
-
|
|
4904
|
+
// `statusOnly` is daemon-owned envelope metadata, not an MCP argument:
|
|
4905
|
+
// an agent cannot invent it to suppress the normal completion marker.
|
|
4906
|
+
if (!error && result != null && msg.statusOnly !== true) {
|
|
4781
4907
|
try {
|
|
4782
4908
|
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
4783
4909
|
}
|
|
@@ -4883,12 +5009,25 @@ export class FleetManager {
|
|
|
4883
5009
|
// the target instance's configured world so replies use its persona
|
|
4884
5010
|
// after a fresh start instead of falling back to channels[0].
|
|
4885
5011
|
const adapterId = this.getInstanceAdapterId(target);
|
|
5012
|
+
// ...but the stored reply coordinates belong to the chat the schedule
|
|
5013
|
+
// was CREATED in, which is not always the target's world. Pairing them
|
|
5014
|
+
// with the target's adapter hands one platform's ids to another's API:
|
|
5015
|
+
// a Telegram group + forum topic delivered to a Discord instance made
|
|
5016
|
+
// every reply on that turn fetch `/channels/<telegram topic>` and fail
|
|
5017
|
+
// with Unknown Channel, and because the seeding repeats on each trigger
|
|
5018
|
+
// it stayed broken until a real inbound overwrote the context (#752).
|
|
5019
|
+
const foreignWorld = this.scheduleChatWorldMismatch(target, reply_chat_id);
|
|
5020
|
+
if (foreignWorld) {
|
|
5021
|
+
this.logger.warn({ scheduleId: id, target, foreignWorld, targetWorld: adapterId, chatId: reply_chat_id }, "Schedule reply target belongs to another channel world — not seeding chat context; the instance keeps its own last known chat");
|
|
5022
|
+
}
|
|
4886
5023
|
await this.deliverToInstance(target, {
|
|
4887
5024
|
type: "fleet_schedule_trigger",
|
|
4888
5025
|
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
4889
5026
|
meta: {
|
|
4890
|
-
|
|
4891
|
-
|
|
5027
|
+
// Omitted on a mismatch: the daemon then keeps its persisted
|
|
5028
|
+
// last-chat, which is in the right world, instead of being
|
|
5029
|
+
// overwritten with coordinates the target's adapter cannot address.
|
|
5030
|
+
...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
|
|
4892
5031
|
user: "scheduler",
|
|
4893
5032
|
...(adapterId ? { adapter_id: adapterId } : {}),
|
|
4894
5033
|
},
|
|
@@ -4920,19 +5059,69 @@ export class FleetManager {
|
|
|
4920
5059
|
this.scheduler.recordRun(id, "instance_offline", `retry ${retryCount}x failed`);
|
|
4921
5060
|
this.notifyScheduleFailure(schedule);
|
|
4922
5061
|
}
|
|
5062
|
+
/**
|
|
5063
|
+
* The adapter that can actually post into a chat, found by its group.
|
|
5064
|
+
*
|
|
5065
|
+
* A schedule records where it was created (reply_chat_id) separately from
|
|
5066
|
+
* what it triggers (target). Those need not share a platform: a Telegram
|
|
5067
|
+
* group can schedule a Discord-topic instance. Picking the adapter from the
|
|
5068
|
+
* target then sends a Telegram chat id through the Discord bot, which fails
|
|
5069
|
+
* with Unknown Channel — the source topic never hears that its schedule ran.
|
|
5070
|
+
*
|
|
5071
|
+
* When several bots share one guild, the primary wins: a persona should not
|
|
5072
|
+
* be the voice announcing fleet scheduling.
|
|
5073
|
+
*/
|
|
5074
|
+
adapterForChat(chatId) {
|
|
5075
|
+
const id = String(chatId);
|
|
5076
|
+
const matches = [...this.worlds.values()].filter(world => String(world.groupId) === id);
|
|
5077
|
+
if (matches.length === 0)
|
|
5078
|
+
return undefined;
|
|
5079
|
+
const primaryId = this.getPrimaryAdapterId();
|
|
5080
|
+
return (matches.find(world => world.id === primaryId) ?? matches[0]).adapter;
|
|
5081
|
+
}
|
|
5082
|
+
/**
|
|
5083
|
+
* The adapter that can answer a schedule in the chat it was created from.
|
|
5084
|
+
*
|
|
5085
|
+
* A schedule records its creator (source) and its trigger (target) separately,
|
|
5086
|
+
* and they need not share a platform — the live fleet has a Telegram group
|
|
5087
|
+
* scheduling a Discord-topic instance. Routing by target sends a Telegram chat
|
|
5088
|
+
* id through the Discord bot, which is one half of the Unknown Channel errors.
|
|
5089
|
+
*
|
|
5090
|
+
* The creator's own adapter comes first, and only when its world actually owns
|
|
5091
|
+
* that chat. Classic keeps its own identity, so a schedule made from a
|
|
5092
|
+
* persona-bound Classic channel is answered by that persona: the primary bot
|
|
5093
|
+
* may not even have access there, and would be the wrong voice if it did.
|
|
5094
|
+
* Falling back to the target's adapter is deliberately NOT an option — that is
|
|
5095
|
+
* the misroute itself; callers say why they stayed silent instead.
|
|
5096
|
+
*/
|
|
5097
|
+
scheduleSourceAdapter(schedule) {
|
|
5098
|
+
const chatId = String(schedule.reply_chat_id);
|
|
5099
|
+
const sourceAdapterId = schedule.source ? this.getInstanceAdapterId(schedule.source) : undefined;
|
|
5100
|
+
const sourceWorld = sourceAdapterId ? this.worlds.get(sourceAdapterId) : undefined;
|
|
5101
|
+
if (sourceWorld && String(sourceWorld.groupId) === chatId)
|
|
5102
|
+
return sourceWorld.adapter;
|
|
5103
|
+
return this.adapterForChat(chatId);
|
|
5104
|
+
}
|
|
4923
5105
|
notifySourceTopic(schedule) {
|
|
4924
|
-
const adapter = this.
|
|
4925
|
-
if (!adapter)
|
|
5106
|
+
const adapter = this.scheduleSourceAdapter(schedule);
|
|
5107
|
+
if (!adapter) {
|
|
5108
|
+
this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — trigger notice not sent");
|
|
4926
5109
|
return;
|
|
5110
|
+
}
|
|
4927
5111
|
const text = `⏰ Schedule "${schedule.label ?? schedule.id}" triggered, target: ${schedule.target}`;
|
|
4928
5112
|
adapter.sendText(schedule.reply_chat_id, text, {
|
|
4929
5113
|
threadId: schedule.reply_thread_id ?? undefined,
|
|
4930
5114
|
}).catch((err) => this.logger.error({ err }, "Failed to send cross-instance notification"));
|
|
4931
5115
|
}
|
|
4932
5116
|
notifyScheduleFailure(schedule) {
|
|
4933
|
-
|
|
4934
|
-
|
|
5117
|
+
// Same resolver as the success path: a failure notice was still being sent
|
|
5118
|
+
// through the target's adapter, so a Telegram-created schedule for a
|
|
5119
|
+
// Discord target announced its failure into the wrong platform.
|
|
5120
|
+
const adapter = this.scheduleSourceAdapter(schedule);
|
|
5121
|
+
if (!adapter) {
|
|
5122
|
+
this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — failure notice not sent");
|
|
4935
5123
|
return;
|
|
5124
|
+
}
|
|
4936
5125
|
const text = `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`;
|
|
4937
5126
|
adapter.sendText(schedule.reply_chat_id, text, {
|
|
4938
5127
|
threadId: schedule.reply_thread_id ?? undefined,
|
|
@@ -5343,26 +5532,261 @@ export class FleetManager {
|
|
|
5343
5532
|
sessionPruneTimer = null;
|
|
5344
5533
|
classicReloadTimer = null;
|
|
5345
5534
|
botUserId;
|
|
5346
|
-
/** Periodically check if bound topics still exist */
|
|
5535
|
+
/** Periodically check if bound topics still exist. Never deletes user data. */
|
|
5347
5536
|
startTopicCleanupPoller() {
|
|
5348
|
-
this.topicCleanupTimer
|
|
5349
|
-
|
|
5537
|
+
if (this.topicCleanupTimer)
|
|
5538
|
+
clearInterval(this.topicCleanupTimer);
|
|
5539
|
+
const generation = ++this.topicCleanupGeneration;
|
|
5540
|
+
this.topicCleanupTimer = setInterval(() => { void this.scheduleTopicCleanup(generation); }, 5 * 60_000);
|
|
5541
|
+
}
|
|
5542
|
+
/** Coalesce timer ticks; an outage must not create overlapping destructive-looking scans. */
|
|
5543
|
+
scheduleTopicCleanup(generation = this.topicCleanupGeneration) {
|
|
5544
|
+
if (this.topicCleanupInFlight)
|
|
5545
|
+
return this.topicCleanupInFlight;
|
|
5546
|
+
const run = this.runTopicCleanup(generation).finally(() => {
|
|
5547
|
+
if (this.topicCleanupInFlight === run)
|
|
5548
|
+
this.topicCleanupInFlight = null;
|
|
5549
|
+
});
|
|
5550
|
+
this.topicCleanupInFlight = run;
|
|
5551
|
+
return run;
|
|
5552
|
+
}
|
|
5553
|
+
confirmedProbeFence(adapterId, adapter) {
|
|
5554
|
+
const health = adapter.getHealthSnapshot?.();
|
|
5555
|
+
if (health) {
|
|
5556
|
+
return health.status === "connected" && health.isReady
|
|
5557
|
+
? { generation: health.generation }
|
|
5558
|
+
: null;
|
|
5559
|
+
}
|
|
5560
|
+
return this.adapterState.get(adapterId)?.status === "connected" ? {} : null;
|
|
5561
|
+
}
|
|
5562
|
+
sameProbeFence(adapterId, adapter, before, result) {
|
|
5563
|
+
const after = this.confirmedProbeFence(adapterId, adapter);
|
|
5564
|
+
if (!after)
|
|
5565
|
+
return false;
|
|
5566
|
+
if (before.generation !== after.generation)
|
|
5567
|
+
return false;
|
|
5568
|
+
return result?.generation === undefined || result.generation === after.generation;
|
|
5569
|
+
}
|
|
5570
|
+
topicProbeStreakKey(threadId, adapterId, reason) {
|
|
5571
|
+
return FleetManager.TOPIC_PROBE_ADAPTER_SCOPED_REASONS.has(reason)
|
|
5572
|
+
? `adapter:${adapterId ?? "unbound"}`
|
|
5573
|
+
: `thread:${threadId}`;
|
|
5574
|
+
}
|
|
5575
|
+
/** A definite answer (present or missing) ends the unknown streak for that route and its adapter. */
|
|
5576
|
+
clearTopicProbeUnknownStreak(threadId, adapterId) {
|
|
5577
|
+
this.topicProbeUnknownStreak.delete(`thread:${threadId}`);
|
|
5578
|
+
this.topicProbeUnknownStreak.delete(`adapter:${adapterId ?? "unbound"}`);
|
|
5579
|
+
}
|
|
5580
|
+
/**
|
|
5581
|
+
* Record one unknown probe result. Nothing here can touch quarantine or
|
|
5582
|
+
* removal: unknown is always retained data. The only question is whether
|
|
5583
|
+
* the operator hears about it, and a single transient (one flaky HTTP call
|
|
5584
|
+
* out of dozens per pass) must not — only a streak does.
|
|
5585
|
+
*
|
|
5586
|
+
* Used directly for single-route events (channelDelete, the pre-action
|
|
5587
|
+
* fence). The periodic scan goes through a TopicProbePass instead, so one
|
|
5588
|
+
* pass over N routes of a dead adapter counts as ONE check, not N.
|
|
5589
|
+
*/
|
|
5590
|
+
warnTopicProbeUnknown(instanceName, threadId, adapterId, reason, detail) {
|
|
5591
|
+
this.noteTopicProbeUnknown(this.topicProbeStreakKey(threadId, adapterId, reason), { instanceName, threadId, adapterId, reason, detail });
|
|
5592
|
+
}
|
|
5593
|
+
/** One scan's worth of probe outcomes, applied to the streaks after the loop. */
|
|
5594
|
+
newTopicProbePass() {
|
|
5595
|
+
return { unknown: new Map(), definite: new Set() };
|
|
5596
|
+
}
|
|
5597
|
+
passTopicProbeUnknown(pass, instanceName, threadId, adapterId, reason, detail) {
|
|
5598
|
+
const key = this.topicProbeStreakKey(threadId, adapterId, reason);
|
|
5599
|
+
// First unknown per key per pass wins; the rest of the routes on a dead
|
|
5600
|
+
// adapter are the same observation, not additional checks.
|
|
5601
|
+
if (!pass.unknown.has(key))
|
|
5602
|
+
pass.unknown.set(key, { instanceName, threadId, adapterId, reason, detail });
|
|
5603
|
+
}
|
|
5604
|
+
passTopicProbeDefinite(pass, threadId, adapterId) {
|
|
5605
|
+
pass.definite.add(`thread:${threadId}`);
|
|
5606
|
+
pass.definite.add(`adapter:${adapterId ?? "unbound"}`);
|
|
5607
|
+
}
|
|
5608
|
+
/**
|
|
5609
|
+
* Apply a pass: a definite answer resets its keys, and an adapter that
|
|
5610
|
+
* answered for any route this pass is evidently alive, so an unknown for the
|
|
5611
|
+
* same adapter key in the same pass does not count — regardless of the order
|
|
5612
|
+
* the routes happened to be probed in.
|
|
5613
|
+
*/
|
|
5614
|
+
applyTopicProbePass(pass) {
|
|
5615
|
+
for (const key of pass.definite)
|
|
5616
|
+
this.topicProbeUnknownStreak.delete(key);
|
|
5617
|
+
for (const [key, ctx] of pass.unknown) {
|
|
5618
|
+
if (pass.definite.has(key))
|
|
5619
|
+
continue;
|
|
5620
|
+
this.noteTopicProbeUnknown(key, ctx);
|
|
5621
|
+
}
|
|
5622
|
+
}
|
|
5623
|
+
noteTopicProbeUnknown(streakKey, { instanceName, threadId, adapterId, reason, detail }) {
|
|
5624
|
+
const streak = (this.topicProbeUnknownStreak.get(streakKey) ?? 0) + 1;
|
|
5625
|
+
this.topicProbeUnknownStreak.set(streakKey, streak);
|
|
5626
|
+
if (streak < FleetManager.TOPIC_PROBE_UNKNOWN_ESCALATION) {
|
|
5627
|
+
this.logger.debug({ instanceName, threadId, adapterId, reason, detail, streak }, "Topic presence not confirmed this pass — transient, retaining instance and all data");
|
|
5628
|
+
return;
|
|
5629
|
+
}
|
|
5630
|
+
const key = `${adapterId ?? "unbound"}:${reason}`;
|
|
5631
|
+
const now = Date.now();
|
|
5632
|
+
const last = this.topicProbeWarnings.get(key) ?? 0;
|
|
5633
|
+
if (now - last < FleetManager.FLEET_ERROR_THROTTLE_MS)
|
|
5634
|
+
return;
|
|
5635
|
+
this.topicProbeWarnings.set(key, now);
|
|
5636
|
+
this.logger.error({ instanceName, threadId, adapterId, reason, detail, streak }, "Topic presence could not be confirmed repeatedly — retaining instance and all data");
|
|
5637
|
+
this.notifyFleetError(t("fleet.topic_probe_unknown", instanceName, adapterId ?? "unbound", streak));
|
|
5638
|
+
}
|
|
5639
|
+
/** One fixed-snapshot topology pass. Automatic evidence can only quarantine. */
|
|
5640
|
+
async runTopicCleanup(generation) {
|
|
5641
|
+
if (generation !== this.topicCleanupGeneration || this.shuttingDown)
|
|
5642
|
+
return;
|
|
5643
|
+
const snapshot = [...this.routing.entries()].filter(([, target]) => isProbeableRouteTarget(target));
|
|
5644
|
+
const missing = [];
|
|
5645
|
+
const pass = this.newTopicProbePass();
|
|
5646
|
+
const skippedOnDemand = new Set();
|
|
5647
|
+
for (const [threadId, target] of snapshot) {
|
|
5648
|
+
if (generation !== this.topicCleanupGeneration || this.shuttingDown)
|
|
5350
5649
|
return;
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
|
|
5650
|
+
// The route may have been replaced while an earlier probe was in flight.
|
|
5651
|
+
if (this.routing.resolve(threadId) !== target)
|
|
5652
|
+
continue;
|
|
5653
|
+
const adapterId = this.getInstanceAdapterId(target.name);
|
|
5654
|
+
const adapter = adapterId ? this.adapters.get(adapterId) : undefined;
|
|
5655
|
+
if (!adapterId || !adapter?.probeTopicPresence) {
|
|
5656
|
+
this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-unavailable");
|
|
5657
|
+
continue;
|
|
5658
|
+
}
|
|
5659
|
+
// An on-demand adapter's probe is reserved for confirming a delivery
|
|
5660
|
+
// failure hint (handleProviderTopicClosed); the periodic scan leaves its
|
|
5661
|
+
// routes alone — no probe, no unknown, no streak, no notice.
|
|
5662
|
+
if (adapter.topicProbePolicy?.() === "on-demand") {
|
|
5663
|
+
if (!skippedOnDemand.has(adapterId)) {
|
|
5664
|
+
skippedOnDemand.add(adapterId);
|
|
5665
|
+
this.logger.debug({ adapterId }, "Topic scan skipping on-demand adapter — presence is confirmed only on delivery failure");
|
|
5360
5666
|
}
|
|
5361
|
-
|
|
5362
|
-
|
|
5667
|
+
continue;
|
|
5668
|
+
}
|
|
5669
|
+
const before = this.confirmedProbeFence(adapterId, adapter);
|
|
5670
|
+
if (!before) {
|
|
5671
|
+
this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-not-ready");
|
|
5672
|
+
continue;
|
|
5673
|
+
}
|
|
5674
|
+
let result;
|
|
5675
|
+
try {
|
|
5676
|
+
result = await adapter.probeTopicPresence(threadId);
|
|
5677
|
+
}
|
|
5678
|
+
catch {
|
|
5679
|
+
result = { status: "unknown", reason: "provider-probe-threw" };
|
|
5680
|
+
}
|
|
5681
|
+
if (generation !== this.topicCleanupGeneration || this.shuttingDown)
|
|
5682
|
+
return;
|
|
5683
|
+
if (!this.sameProbeFence(adapterId, adapter, before, result)) {
|
|
5684
|
+
this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, "owner-adapter-generation-changed");
|
|
5685
|
+
continue;
|
|
5686
|
+
}
|
|
5687
|
+
if (result.status === "unknown") {
|
|
5688
|
+
this.passTopicProbeUnknown(pass, target.name, threadId, adapterId, result.reason, result.detail);
|
|
5689
|
+
}
|
|
5690
|
+
else {
|
|
5691
|
+
this.passTopicProbeDefinite(pass, threadId, adapterId);
|
|
5692
|
+
if (result.status === "missing") {
|
|
5693
|
+
missing.push({ threadId, target, adapterId, adapter, generation: result.generation });
|
|
5363
5694
|
}
|
|
5364
5695
|
}
|
|
5365
|
-
}
|
|
5696
|
+
}
|
|
5697
|
+
if (generation !== this.topicCleanupGeneration || this.shuttingDown)
|
|
5698
|
+
return;
|
|
5699
|
+
// One pass, one check: streaks move by at most one per key here.
|
|
5700
|
+
this.applyTopicProbePass(pass);
|
|
5701
|
+
if (missing.length === 0)
|
|
5702
|
+
return;
|
|
5703
|
+
if (missing.length > 1) {
|
|
5704
|
+
this.logger.error({ missing: missing.map(item => ({ instanceName: item.target.name, threadId: item.threadId, adapterId: item.adapterId })) }, "Multiple topics appeared missing in one pass — treating topology evidence as untrusted and retaining all data");
|
|
5705
|
+
this.notifyFleetError(t("fleet.topic_probe_bulk", missing.length));
|
|
5706
|
+
return;
|
|
5707
|
+
}
|
|
5708
|
+
const item = missing[0];
|
|
5709
|
+
if (this.routing.resolve(item.threadId) !== item.target
|
|
5710
|
+
|| this.getInstanceAdapterId(item.target.name) !== item.adapterId
|
|
5711
|
+
|| this.adapters.get(item.adapterId) !== item.adapter)
|
|
5712
|
+
return;
|
|
5713
|
+
const current = this.confirmedProbeFence(item.adapterId, item.adapter);
|
|
5714
|
+
if (!current || (item.generation !== undefined && current.generation !== item.generation)) {
|
|
5715
|
+
this.warnTopicProbeUnknown(item.target.name, item.threadId, item.adapterId, "owner-adapter-changed-before-action");
|
|
5716
|
+
return;
|
|
5717
|
+
}
|
|
5718
|
+
this.topicCommands.handleTopicDeleted(item.threadId, {
|
|
5719
|
+
source: "provider-probe",
|
|
5720
|
+
adapterId: item.adapterId,
|
|
5721
|
+
generation: item.generation,
|
|
5722
|
+
});
|
|
5723
|
+
}
|
|
5724
|
+
/**
|
|
5725
|
+
* A gateway event is only a hint. Confirm it through the passive REST probe;
|
|
5726
|
+
* reconnecting gateways have emitted false channelDelete events in practice.
|
|
5727
|
+
*/
|
|
5728
|
+
async handleProviderTopicClosed(threadId, adapterId, adapter) {
|
|
5729
|
+
const target = this.routing.resolve(threadId);
|
|
5730
|
+
if (!target || !isProbeableRouteTarget(target))
|
|
5731
|
+
return;
|
|
5732
|
+
if (this.getInstanceAdapterId(target.name) !== adapterId)
|
|
5733
|
+
return;
|
|
5734
|
+
if (this.adapters.get(adapterId) !== adapter)
|
|
5735
|
+
return;
|
|
5736
|
+
const before = this.confirmedProbeFence(adapterId, adapter);
|
|
5737
|
+
if (!before || !adapter.probeTopicPresence) {
|
|
5738
|
+
this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-from-unready-adapter");
|
|
5739
|
+
return;
|
|
5740
|
+
}
|
|
5741
|
+
let result;
|
|
5742
|
+
try {
|
|
5743
|
+
result = await adapter.probeTopicPresence(threadId);
|
|
5744
|
+
}
|
|
5745
|
+
catch {
|
|
5746
|
+
result = { status: "unknown", reason: "provider-probe-threw" };
|
|
5747
|
+
}
|
|
5748
|
+
if (this.routing.resolve(threadId) !== target
|
|
5749
|
+
|| this.getInstanceAdapterId(target.name) !== adapterId
|
|
5750
|
+
|| this.adapters.get(adapterId) !== adapter
|
|
5751
|
+
|| !this.sameProbeFence(adapterId, adapter, before, result)) {
|
|
5752
|
+
this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-generation-changed");
|
|
5753
|
+
return;
|
|
5754
|
+
}
|
|
5755
|
+
if (result.status === "unknown") {
|
|
5756
|
+
this.warnTopicProbeUnknown(target.name, threadId, adapterId, result.reason, result.detail);
|
|
5757
|
+
return;
|
|
5758
|
+
}
|
|
5759
|
+
this.clearTopicProbeUnknownStreak(threadId, adapterId);
|
|
5760
|
+
if (result.status !== "missing") {
|
|
5761
|
+
// The gateway said deleted, REST says present: a definite answer, so it
|
|
5762
|
+
// is not an unknown streak — but it is worth one debug line.
|
|
5763
|
+
this.logger.debug({ instanceName: target.name, threadId, adapterId }, "channelDelete hint contradicted by REST — topic present, nothing to do");
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
this.topicCommands.handleTopicDeleted(threadId, {
|
|
5767
|
+
source: "provider-event",
|
|
5768
|
+
adapterId,
|
|
5769
|
+
generation: result.generation,
|
|
5770
|
+
});
|
|
5771
|
+
}
|
|
5772
|
+
bindTopicClosedHandler(adapter, adapterId, label) {
|
|
5773
|
+
adapter.on("topic_closed", safeHandler(async (data) => {
|
|
5774
|
+
if (this.topicArchiver.isArchived(data.threadId))
|
|
5775
|
+
return;
|
|
5776
|
+
await this.handleProviderTopicClosed(data.threadId, adapterId, adapter);
|
|
5777
|
+
}, this.logger, label));
|
|
5778
|
+
}
|
|
5779
|
+
/**
|
|
5780
|
+
* Remove only the volatile route. The instance config, daemon, schedules,
|
|
5781
|
+
* teams, metadata directory, and working tree remain untouched until an
|
|
5782
|
+
* authenticated explicit deletion is requested.
|
|
5783
|
+
*/
|
|
5784
|
+
quarantineMissingTopic(threadId, target, evidence) {
|
|
5785
|
+
if (!isProbeableRouteTarget(target) || this.routing.resolve(threadId) !== target)
|
|
5786
|
+
return;
|
|
5787
|
+
this.routing.unregister(threadId);
|
|
5788
|
+
this.logger.error({ instanceName: target.name, threadId, ...evidence }, "Topic is confirmed missing — route quarantined; instance configuration and user data were retained");
|
|
5789
|
+
this.notifyFleetError(t("fleet.topic_quarantined", target.name, threadId));
|
|
5366
5790
|
}
|
|
5367
5791
|
/**
|
|
5368
5792
|
* Patch only values changed in the effective config into the original YAML
|
|
@@ -5515,7 +5939,8 @@ export class FleetManager {
|
|
|
5515
5939
|
}
|
|
5516
5940
|
}
|
|
5517
5941
|
}
|
|
5518
|
-
async removeInstance(name) {
|
|
5942
|
+
async removeInstance(name, authorization) {
|
|
5943
|
+
assertExplicitInstanceRemoval(authorization);
|
|
5519
5944
|
// Drop cached pane context — the map is keyed by instance name and nothing
|
|
5520
5945
|
// else evicted deleted entries, so it grew for the life of the process.
|
|
5521
5946
|
forgetInstanceContext(name);
|
|
@@ -5541,7 +5966,7 @@ export class FleetManager {
|
|
|
5541
5966
|
}
|
|
5542
5967
|
}
|
|
5543
5968
|
}
|
|
5544
|
-
await this.lifecycle.remove(name);
|
|
5969
|
+
await this.lifecycle.remove(name, authorization);
|
|
5545
5970
|
// Clean up per-instance tracking maps so they don't grow unbounded
|
|
5546
5971
|
// as instances are created and deleted over the lifetime of the fleet.
|
|
5547
5972
|
this.lastActivity.delete(name);
|
|
@@ -5778,6 +6203,65 @@ export class FleetManager {
|
|
|
5778
6203
|
this.logger.error({ ids: bad }, "classicBot.yaml holds ids that can never match");
|
|
5779
6204
|
this.notifyFleetError(t("classic.unrecoverable_ids", list));
|
|
5780
6205
|
}
|
|
6206
|
+
/**
|
|
6207
|
+
* Where a fleet-wide notice can actually be posted, or null if nowhere.
|
|
6208
|
+
*
|
|
6209
|
+
* On Telegram a group id is itself a chat, so posting straight to it is
|
|
6210
|
+
* right. On Discord it is a *guild* id, and sending there makes the adapter
|
|
6211
|
+
* fetch a channel that does not exist — DiscordAPIError 10003 Unknown
|
|
6212
|
+
* Channel. That is why the daily summary never arrived on a Discord fleet:
|
|
6213
|
+
* it had been posting to the guild every night and only the catch handler
|
|
6214
|
+
* ever saw it.
|
|
6215
|
+
*
|
|
6216
|
+
* Discord therefore needs a real channel: the General topic (resolved from
|
|
6217
|
+
* config rather than findGeneralInstance, so a fleet-level fault can still be
|
|
6218
|
+
* reported while the General daemon is down), else the adapter's configured
|
|
6219
|
+
* general_channel_id. With neither, there is no safe target and the caller
|
|
6220
|
+
* should say so rather than send into a guaranteed failure.
|
|
6221
|
+
*/
|
|
6222
|
+
fleetNoticeTarget(adapterId) {
|
|
6223
|
+
const cfg = this.getChannelConfig(adapterId);
|
|
6224
|
+
const groupId = cfg?.group_id;
|
|
6225
|
+
if (groupId == null)
|
|
6226
|
+
return null;
|
|
6227
|
+
const chatId = String(groupId);
|
|
6228
|
+
// The General must belong to the SAME adapter as the group above. Taking
|
|
6229
|
+
// whichever General comes first in the instance map produced a mixed target
|
|
6230
|
+
// on a dual-platform fleet — a Telegram group id carrying a Discord channel
|
|
6231
|
+
// as its thread — and made the result depend on map insertion order.
|
|
6232
|
+
// Resolved from config, not from a live daemon, so a fleet-level fault is
|
|
6233
|
+
// still reportable while the General itself is down.
|
|
6234
|
+
const ownerId = cfg?.id ?? cfg?.type;
|
|
6235
|
+
const generalTopic = Object.entries(this.fleetConfig?.instances ?? {})
|
|
6236
|
+
.find(([name, instance]) => instance.general_topic === true
|
|
6237
|
+
&& this.getInstanceAdapterId(name) === ownerId)?.[1]?.topic_id;
|
|
6238
|
+
if (generalTopic != null)
|
|
6239
|
+
return { chatId, opts: { threadId: String(generalTopic) } };
|
|
6240
|
+
if (cfg?.type === "discord") {
|
|
6241
|
+
const configured = cfg.options?.general_channel_id;
|
|
6242
|
+
if (configured != null && String(configured)) {
|
|
6243
|
+
return { chatId, opts: { threadId: String(configured) } };
|
|
6244
|
+
}
|
|
6245
|
+
return null; // a guild id is not a channel; sending would always fail
|
|
6246
|
+
}
|
|
6247
|
+
return { chatId, opts: {} };
|
|
6248
|
+
}
|
|
6249
|
+
/**
|
|
6250
|
+
* Post the daily summary where fleet-wide notices go.
|
|
6251
|
+
*
|
|
6252
|
+
* A named method rather than an inline closure so a test can drive the real
|
|
6253
|
+
* thing: asserting on fleetNoticeTarget alone leaves the call site free to go
|
|
6254
|
+
* back to posting at the bare group id, which is the defect this replaced.
|
|
6255
|
+
*/
|
|
6256
|
+
postDailySummary(text) {
|
|
6257
|
+
const target = this.fleetNoticeTarget();
|
|
6258
|
+
if (!this.adapter || !target) {
|
|
6259
|
+
this.logger.warn("Daily summary has no postable target — set a General topic or channel.options.general_channel_id");
|
|
6260
|
+
return;
|
|
6261
|
+
}
|
|
6262
|
+
this.adapter.sendText(target.chatId, text, target.opts)
|
|
6263
|
+
.catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
|
|
6264
|
+
}
|
|
5781
6265
|
notifyFleetError(text) {
|
|
5782
6266
|
const now = Date.now();
|
|
5783
6267
|
const key = text.slice(0, 200);
|
|
@@ -5804,10 +6288,12 @@ export class FleetManager {
|
|
|
5804
6288
|
dispatched = this.notifyInstanceTopic(general, body);
|
|
5805
6289
|
}
|
|
5806
6290
|
else {
|
|
5807
|
-
// No General instance — fall back to the primary channel's
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
6291
|
+
// No General instance — fall back to the primary channel's own notice
|
|
6292
|
+
// target. Posting to the bare group id looked right but is a guild id on
|
|
6293
|
+
// Discord, so every such fallback failed inside the catch handler.
|
|
6294
|
+
const target = this.fleetNoticeTarget();
|
|
6295
|
+
if (this.adapter && target) {
|
|
6296
|
+
this.adapter.sendText(target.chatId, body, target.opts)
|
|
5811
6297
|
.catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
|
|
5812
6298
|
dispatched = true;
|
|
5813
6299
|
}
|
|
@@ -5866,13 +6352,16 @@ export class FleetManager {
|
|
|
5866
6352
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
|
|
5867
6353
|
return true;
|
|
5868
6354
|
}
|
|
5869
|
-
// Fallback:
|
|
5870
|
-
|
|
5871
|
-
|
|
6355
|
+
// Fallback: the instance has neither a topic nor a classic channel, so post
|
|
6356
|
+
// where fleet-wide notices go. Not the bare group id: on Discord that is a
|
|
6357
|
+
// guild, and the send fails inside the catch handler.
|
|
6358
|
+
const target = this.fleetNoticeTarget(this.getInstanceAdapterId(instanceName));
|
|
6359
|
+
if (target) {
|
|
6360
|
+
adapter.sendText(target.chatId, text, { ...target.opts, ...extraOpts })
|
|
5872
6361
|
.catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
|
|
5873
6362
|
return true;
|
|
5874
6363
|
}
|
|
5875
|
-
this.logger.warn({ instanceName }, "No
|
|
6364
|
+
this.logger.warn({ instanceName }, "No postable target — instance topic notification not sent");
|
|
5876
6365
|
return false;
|
|
5877
6366
|
}
|
|
5878
6367
|
// ── Nonce-armed button prompts (hang / assist / exit / clear) ──
|
|
@@ -6433,6 +6922,41 @@ export class FleetManager {
|
|
|
6433
6922
|
extra: { generalName, promptKind: kind },
|
|
6434
6923
|
});
|
|
6435
6924
|
}
|
|
6925
|
+
/**
|
|
6926
|
+
* Offer a one-tap re-login next to an auth alert.
|
|
6927
|
+
*
|
|
6928
|
+
* The alert already names the remedy in words (`/login <backend>`), which
|
|
6929
|
+
* still leaves the user to retype it somewhere. The button routes into the
|
|
6930
|
+
* same chooser `/login` uses, so pressing it starts the flow in place.
|
|
6931
|
+
*
|
|
6932
|
+
* Backends with no remote login flow (opencode logs in from a terminal) get
|
|
6933
|
+
* no button — the alert's own wording already tells them what to run.
|
|
6934
|
+
*/
|
|
6935
|
+
async offerBackendLogin(targetInstance, backend) {
|
|
6936
|
+
if (!LOGIN_FLOWS[backend])
|
|
6937
|
+
return;
|
|
6938
|
+
const adapterId = this.getInstanceAdapterId(targetInstance);
|
|
6939
|
+
const adapter = this.getAdapterForInstance(targetInstance);
|
|
6940
|
+
const chatId = this.getGroupIdForInstance(targetInstance);
|
|
6941
|
+
const topicId = this.fleetConfig?.instances[targetInstance]?.topic_id;
|
|
6942
|
+
const threadId = topicId != null ? String(topicId) : undefined;
|
|
6943
|
+
if (!adapter || !adapterId || !chatId) {
|
|
6944
|
+
this.logger.warn({ targetInstance, backend, adapterId, chatId }, "Cannot address the re-login button — the alert text still names the command");
|
|
6945
|
+
return;
|
|
6946
|
+
}
|
|
6947
|
+
await this.postNonceButtonPrompt({
|
|
6948
|
+
prefix: LOGIN_CALLBACK_PREFIX,
|
|
6949
|
+
alertType: "login",
|
|
6950
|
+
instanceName: "login",
|
|
6951
|
+
adapter,
|
|
6952
|
+
adapterId,
|
|
6953
|
+
chatId,
|
|
6954
|
+
threadId,
|
|
6955
|
+
message: t("login.offer", backend),
|
|
6956
|
+
choices: [{ action: backend, label: t("login.offer_action", backend) }],
|
|
6957
|
+
expiredText: t("buttons.stale"),
|
|
6958
|
+
});
|
|
6959
|
+
}
|
|
6436
6960
|
/** Consume a General assist button exactly once. */
|
|
6437
6961
|
async handleInteractivePromptAssist(data, callbackAdapterId, receivingAdapter) {
|
|
6438
6962
|
const claimed = this.consumeNonceCallback(INTERACTIVE_ASSIST_CALLBACK_PREFIX, /^interactive-assist:([0-9a-f]+):(confirm|cancel)$/, data, callbackAdapterId, receivingAdapter);
|
|
@@ -6583,6 +7107,30 @@ export class FleetManager {
|
|
|
6583
7107
|
}
|
|
6584
7108
|
return false;
|
|
6585
7109
|
}
|
|
7110
|
+
beginCancelButtonPublication(instanceName, correlationId) {
|
|
7111
|
+
// A new handoff supersedes any idle timer left by the previous turn. If the
|
|
7112
|
+
// instance is still idle after this publication, the post-await level check
|
|
7113
|
+
// below starts a fresh grace period for this generation.
|
|
7114
|
+
this.cancelIdleButtonRetirement(instanceName);
|
|
7115
|
+
const publication = {
|
|
7116
|
+
generation: ++this.nextCancelButtonPublicationGeneration,
|
|
7117
|
+
correlationId,
|
|
7118
|
+
inFlight: true,
|
|
7119
|
+
retirePending: false,
|
|
7120
|
+
};
|
|
7121
|
+
this.cancelButtonPublications.set(instanceName, publication);
|
|
7122
|
+
return publication;
|
|
7123
|
+
}
|
|
7124
|
+
/** Remember a clear/cancel/idle decision that arrived before notifyAlert
|
|
7125
|
+
* returned a message id. `expected` fences an idle timer to its generation. */
|
|
7126
|
+
markCancelButtonPublicationForRetirement(instanceName, expected) {
|
|
7127
|
+
const publication = this.cancelButtonPublications.get(instanceName);
|
|
7128
|
+
if (!publication?.inFlight)
|
|
7129
|
+
return;
|
|
7130
|
+
if (expected && publication !== expected)
|
|
7131
|
+
return;
|
|
7132
|
+
publication.retirePending = true;
|
|
7133
|
+
}
|
|
6586
7134
|
async sendCancelButton(instanceName, correlationId, preserveProgress = false) {
|
|
6587
7135
|
// Post first, retire after (see the tail of this method). Retiring up front
|
|
6588
7136
|
// meant that from the delete until the new message came back — a chat API
|
|
@@ -6621,6 +7169,7 @@ export class FleetManager {
|
|
|
6621
7169
|
this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
|
|
6622
7170
|
return;
|
|
6623
7171
|
}
|
|
7172
|
+
const publication = this.beginCancelButtonPublication(instanceName, correlationId);
|
|
6624
7173
|
try {
|
|
6625
7174
|
const sent = await adapter.notifyAlert(chatId, {
|
|
6626
7175
|
type: "cancel",
|
|
@@ -6628,6 +7177,7 @@ export class FleetManager {
|
|
|
6628
7177
|
message: "👀 處理中…",
|
|
6629
7178
|
choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
|
|
6630
7179
|
}, threadId ? { threadId } : undefined);
|
|
7180
|
+
publication.inFlight = false;
|
|
6631
7181
|
const entry = {
|
|
6632
7182
|
instanceName,
|
|
6633
7183
|
adapterId,
|
|
@@ -6649,6 +7199,16 @@ export class FleetManager {
|
|
|
6649
7199
|
// empty even if the daemon's reset broadcast is still in flight.
|
|
6650
7200
|
toolProgress: preserveProgress ? this.instanceProgress.get(instanceName) : undefined,
|
|
6651
7201
|
};
|
|
7202
|
+
// A newer send started while this API call was pending. Track this late
|
|
7203
|
+
// message just long enough for the normal bounded delete/retry machinery
|
|
7204
|
+
// to remove it; critically, do not sweep the newer generation.
|
|
7205
|
+
if (this.cancelButtonPublications.get(instanceName) !== publication) {
|
|
7206
|
+
this.cancelButtons.set(sent.messageId, entry);
|
|
7207
|
+
this.persistCancelButtons();
|
|
7208
|
+
this.logger.debug({ instanceName, messageId: sent.messageId, generation: publication.generation }, "Retiring superseded cancel-button publication");
|
|
7209
|
+
this.retireButton(entry);
|
|
7210
|
+
return;
|
|
7211
|
+
}
|
|
6652
7212
|
this.startProgressTicker(entry);
|
|
6653
7213
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
6654
7214
|
// button. Covers turns that end without hitting a clear trigger (reply /
|
|
@@ -6685,8 +7245,30 @@ export class FleetManager {
|
|
|
6685
7245
|
}
|
|
6686
7246
|
this.persistCancelButtons();
|
|
6687
7247
|
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
7248
|
+
// Retirement is normally edge-triggered, but the edge (or an explicit
|
|
7249
|
+
// reply/cancel clear) may have happened while notifyAlert was in flight.
|
|
7250
|
+
// Reconcile the level after publication so a late button cannot resurrect
|
|
7251
|
+
// on an already-idle instance. Preserve the existing two-second grace:
|
|
7252
|
+
// a working transition cancels this timer just as it does for a normal
|
|
7253
|
+
// idle edge.
|
|
7254
|
+
if (publication.retirePending) {
|
|
7255
|
+
this.retireButton(entry);
|
|
7256
|
+
}
|
|
7257
|
+
else if (this.getInstanceExecutionState(instanceName) === "idle") {
|
|
7258
|
+
this.scheduleIdleButtonRetirement(instanceName);
|
|
7259
|
+
}
|
|
6688
7260
|
}
|
|
6689
7261
|
catch (e) {
|
|
7262
|
+
if (this.cancelButtonPublications.get(instanceName) === publication) {
|
|
7263
|
+
this.cancelButtonPublications.delete(instanceName);
|
|
7264
|
+
// beginCancelButtonPublication cancelled the previous turn's idle
|
|
7265
|
+
// timer so it could not act on this generation. If the provider POST
|
|
7266
|
+
// failed while the instance remained idle, restore that retirement
|
|
7267
|
+
// opportunity for any older button still on screen.
|
|
7268
|
+
if (this.getInstanceExecutionState(instanceName) === "idle") {
|
|
7269
|
+
this.scheduleIdleButtonRetirement(instanceName);
|
|
7270
|
+
}
|
|
7271
|
+
}
|
|
6690
7272
|
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
6691
7273
|
}
|
|
6692
7274
|
}
|
|
@@ -7039,6 +7621,7 @@ export class FleetManager {
|
|
|
7039
7621
|
}
|
|
7040
7622
|
/** Retire all cancel buttons for an instance — on reply or cancel. */
|
|
7041
7623
|
clearCancelButton(instanceName) {
|
|
7624
|
+
this.markCancelButtonPublicationForRetirement(instanceName);
|
|
7042
7625
|
this.retireInstanceButtons(instanceName);
|
|
7043
7626
|
}
|
|
7044
7627
|
/** Retire the cross-instance button matching a delegate→report correlation id.
|
|
@@ -7047,6 +7630,11 @@ export class FleetManager {
|
|
|
7047
7630
|
clearCancelButtonByCorrelation(correlationId) {
|
|
7048
7631
|
if (!correlationId)
|
|
7049
7632
|
return;
|
|
7633
|
+
for (const [instanceName, publication] of this.cancelButtonPublications) {
|
|
7634
|
+
if (publication.inFlight && publication.correlationId === correlationId) {
|
|
7635
|
+
this.markCancelButtonPublicationForRetirement(instanceName, publication);
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7050
7638
|
for (const e of [...this.cancelButtons.values()]) {
|
|
7051
7639
|
if (e.correlationId === correlationId)
|
|
7052
7640
|
this.retireButton(e);
|
|
@@ -7421,11 +8009,15 @@ export class FleetManager {
|
|
|
7421
8009
|
if (cleanupFailed) {
|
|
7422
8010
|
await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
|
|
7423
8011
|
}
|
|
8012
|
+
const send = (text) => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId }).catch(() => { });
|
|
7424
8013
|
let text;
|
|
7425
8014
|
if (ok) {
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
8015
|
+
// Same order as the web-login path: the login result goes out first,
|
|
8016
|
+
// then the recovery reports its own outcome. Waiting for recovery to
|
|
8017
|
+
// build this message is what made a successful login look hung.
|
|
8018
|
+
await send(t("login.completed", backend));
|
|
8019
|
+
await announcePostLoginRecovery(backend, () => this.recoverBackendInstances(backend), send);
|
|
8020
|
+
return;
|
|
7429
8021
|
}
|
|
7430
8022
|
else if (detail === "cancelled") {
|
|
7431
8023
|
// The cancel command's own reply already announced this — a second
|
|
@@ -7549,31 +8141,53 @@ export class FleetManager {
|
|
|
7549
8141
|
* only re-reads credentials on process start (a paused instance's CLI is
|
|
7550
8142
|
* already dead, so waking it respawns with the new token for free).
|
|
7551
8143
|
*/
|
|
7552
|
-
|
|
8144
|
+
/**
|
|
8145
|
+
* Wake/restart every instance of a backend after a successful re-login.
|
|
8146
|
+
*
|
|
8147
|
+
* Bounded by a wall-clock deadline. This used to be an unbounded sequential
|
|
8148
|
+
* loop, and the caller only built its "login completed" message AFTER it
|
|
8149
|
+
* returned — so with several instances (or one slow restart) the user was
|
|
8150
|
+
* told nothing at all for minutes, concluded the login had hung, and
|
|
8151
|
+
* restarted things by hand. Instances that do not finish in time are NOT
|
|
8152
|
+
* cancelled: they are still coming back, and are reported as pending so the
|
|
8153
|
+
* message can say so instead of implying failure.
|
|
8154
|
+
*/
|
|
8155
|
+
async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
|
|
7553
8156
|
const woken = [];
|
|
7554
8157
|
const restarted = [];
|
|
8158
|
+
const pending = [];
|
|
8159
|
+
const deadline = Date.now() + deadlineMs;
|
|
7555
8160
|
for (const name of this.configuredBackendInstanceNames()) {
|
|
7556
8161
|
if (this.backendNameOf(name) !== backend)
|
|
7557
8162
|
continue;
|
|
7558
8163
|
const status = this.getInstanceStatus(name);
|
|
7559
|
-
|
|
8164
|
+
if (status !== "paused" && status !== "running")
|
|
8165
|
+
continue;
|
|
8166
|
+
const result = await runBeforeDeadline(async () => {
|
|
7560
8167
|
if (status === "paused") {
|
|
7561
8168
|
if (this.daemons.has(name))
|
|
7562
8169
|
await this.lifecycle.wake(name, 30_000);
|
|
7563
8170
|
else
|
|
7564
8171
|
await this.startPersistedPausedInstance(name);
|
|
7565
|
-
woken.push(name);
|
|
7566
8172
|
}
|
|
7567
|
-
else
|
|
8173
|
+
else {
|
|
7568
8174
|
await this.restartSingleInstance(name);
|
|
7569
|
-
restarted.push(name);
|
|
7570
8175
|
}
|
|
8176
|
+
}, deadline);
|
|
8177
|
+
if (result.status === "fulfilled") {
|
|
8178
|
+
(status === "paused" ? woken : restarted).push(name);
|
|
7571
8179
|
}
|
|
7572
|
-
|
|
7573
|
-
|
|
8180
|
+
else if (result.status === "timeout") {
|
|
8181
|
+
// Out of time: record it and stop waiting, but keep walking the list —
|
|
8182
|
+
// the remaining instances are checked against the same deadline and
|
|
8183
|
+
// fall straight through, so the caller still learns about all of them.
|
|
8184
|
+
pending.push(name);
|
|
8185
|
+
}
|
|
8186
|
+
else {
|
|
8187
|
+
this.logger.warn({ err: result.reason?.message, name, status }, "Post-login recovery failed");
|
|
7574
8188
|
}
|
|
7575
8189
|
}
|
|
7576
|
-
return { woken, restarted };
|
|
8190
|
+
return { woken, restarted, pending };
|
|
7577
8191
|
}
|
|
7578
8192
|
/** Backend chooser button → start that backend's login session. */
|
|
7579
8193
|
async handleLoginBackendSelect(data, callbackAdapterId, receivingAdapter) {
|
|
@@ -7985,7 +8599,7 @@ You CAN write code snippets, explain code, and answer technical questions direct
|
|
|
7985
8599
|
- **Delegate to 1 instance**: scoped to one project/repo, needs file access or execution.
|
|
7986
8600
|
- **Coordinate multiple**: spans repos, outputs feed each other, or parallel helps (max 3 per task).
|
|
7987
8601
|
|
|
7988
|
-
Instance discovery
|
|
8602
|
+
Instance discovery: start with list_instances(); follow the guidance in its response. Prefer reuse; never duplicate a running instance.
|
|
7989
8603
|
|
|
7990
8604
|
## Reply Contract
|
|
7991
8605
|
|
|
@@ -9613,11 +10227,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
9613
10227
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
9614
10228
|
ensureWorkspaceGit(workDir);
|
|
9615
10229
|
const classicIdentity = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
|
|
10230
|
+
const toolProgress = classicIdentity
|
|
10231
|
+
? this.classicChannels?.getToolProgress(classicIdentity.channelId, classicIdentity.adapterId, this.fleetConfig?.defaults?.tool_progress)
|
|
10232
|
+
: this.fleetConfig?.defaults?.tool_progress;
|
|
10233
|
+
const replyCompletionGuard = classicIdentity
|
|
10234
|
+
? this.classicChannels?.getReplyCompletionGuard(classicIdentity.channelId, classicIdentity.adapterId, this.fleetConfig?.defaults?.reply_completion_guard)
|
|
10235
|
+
: this.fleetConfig?.defaults?.reply_completion_guard;
|
|
9616
10236
|
const config = {
|
|
9617
10237
|
...DEFAULT_INSTANCE_CONFIG,
|
|
9618
10238
|
...this.fleetConfig?.defaults,
|
|
9619
10239
|
working_directory: workDir,
|
|
9620
10240
|
lightweight: true,
|
|
10241
|
+
tool_progress: toolProgress ?? "off",
|
|
10242
|
+
reply_completion_guard: replyCompletionGuard ?? true,
|
|
9621
10243
|
...(backend ? { backend } : {}),
|
|
9622
10244
|
...(model ? { model } : {}),
|
|
9623
10245
|
...(classicIdentity?.displayName ? { display_name: classicIdentity.displayName } : {}),
|
|
@@ -9750,6 +10372,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
9750
10372
|
clearInterval(entry.progressTimer);
|
|
9751
10373
|
}
|
|
9752
10374
|
this.cancelButtons.clear();
|
|
10375
|
+
this.cancelButtonPublications.clear();
|
|
9753
10376
|
for (const timer of this.cancelButtonIdleRetireTimers.values())
|
|
9754
10377
|
clearTimeout(timer);
|
|
9755
10378
|
this.cancelButtonIdleRetireTimers.clear();
|
|
@@ -9757,6 +10380,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
9757
10380
|
clearInterval(this.topicCleanupTimer);
|
|
9758
10381
|
this.topicCleanupTimer = null;
|
|
9759
10382
|
}
|
|
10383
|
+
this.topicCleanupGeneration++;
|
|
9760
10384
|
if (this.sessionPruneTimer) {
|
|
9761
10385
|
clearInterval(this.sessionPruneTimer);
|
|
9762
10386
|
this.sessionPruneTimer = null;
|
|
@@ -9899,10 +10523,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
9899
10523
|
// Keep that single message; the new process will adopt and finish it.
|
|
9900
10524
|
setUpdateProgressStage(this.dataDir, "stopping");
|
|
9901
10525
|
}
|
|
9902
|
-
const
|
|
9903
|
-
if (!trackedFullRestart &&
|
|
9904
|
-
|
|
9905
|
-
|
|
10526
|
+
const restartTarget = this.fleetNoticeTarget();
|
|
10527
|
+
if (!trackedFullRestart && this.adapter) {
|
|
10528
|
+
if (restartTarget) {
|
|
10529
|
+
await this.adapter.sendText(restartTarget.chatId, t("restart.full_initiated"), restartTarget.opts)
|
|
10530
|
+
.catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
|
|
10531
|
+
}
|
|
10532
|
+
else {
|
|
10533
|
+
// Say why nothing was posted. A restart that announces itself nowhere,
|
|
10534
|
+
// for a reason nobody logged, is the harder version of this bug.
|
|
10535
|
+
this.logger.warn("Full restart notice has no postable target — set a General topic or channel.options.general_channel_id");
|
|
10536
|
+
}
|
|
9906
10537
|
}
|
|
9907
10538
|
// Wait for idle with 5-minute timeout
|
|
9908
10539
|
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -10002,6 +10633,30 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10002
10633
|
this.notifyFleetError(t("fleet.reload_rejected", why));
|
|
10003
10634
|
return;
|
|
10004
10635
|
}
|
|
10636
|
+
// Classic behavior settings share the fleet defaults but are not entries
|
|
10637
|
+
// in fleet.yaml. Snapshot the old effective chain before reloading the
|
|
10638
|
+
// Classic file so SIGHUP can hot-apply either source without waiting for
|
|
10639
|
+
// the 30-second Classic poller.
|
|
10640
|
+
const oldClassicBehavior = new Map();
|
|
10641
|
+
if (this.classicChannels) {
|
|
10642
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
10643
|
+
const runtimeConfig = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.();
|
|
10644
|
+
oldClassicBehavior.set(ch.instanceName, {
|
|
10645
|
+
backend: this.classicChannels.getBackend(ch.channelId, ch.adapterId, oldConfig?.defaults?.backend),
|
|
10646
|
+
model: this.classicChannels.getModel(ch.channelId, ch.adapterId, oldConfig?.defaults?.model),
|
|
10647
|
+
autoPauseAfter: this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, oldConfig?.defaults?.auto_pause_after),
|
|
10648
|
+
// Settings mutates FleetManager's in-memory defaults before SIGHUP.
|
|
10649
|
+
// The live daemon is therefore the authority for the previous hot
|
|
10650
|
+
// values, exactly as in the fleet-topic reconciliation below.
|
|
10651
|
+
toolProgress: runtimeConfig?.tool_progress
|
|
10652
|
+
?? this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, oldConfig?.defaults?.tool_progress),
|
|
10653
|
+
replyCompletionGuard: runtimeConfig?.reply_completion_guard
|
|
10654
|
+
?? this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, oldConfig?.defaults?.reply_completion_guard),
|
|
10655
|
+
});
|
|
10656
|
+
}
|
|
10657
|
+
if (this.classicChannels.checkReload())
|
|
10658
|
+
this.reportClassicUnrecoverableIds();
|
|
10659
|
+
}
|
|
10005
10660
|
this.routing.rebuild(this.fleetConfig);
|
|
10006
10661
|
this.reregisterClassicChannels();
|
|
10007
10662
|
this.scheduler?.reload();
|
|
@@ -10066,6 +10721,35 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10066
10721
|
}
|
|
10067
10722
|
}
|
|
10068
10723
|
}
|
|
10724
|
+
// A Classic channel inherits fleet defaults beneath its own two levels.
|
|
10725
|
+
// Recompute that complete chain on SIGHUP. Only the two behavior switches
|
|
10726
|
+
// are hot; changes to backend/model/auto-pause retain the existing restart
|
|
10727
|
+
// semantics.
|
|
10728
|
+
if (this.classicChannels) {
|
|
10729
|
+
for (const ch of this.classicChannels.getAll()) {
|
|
10730
|
+
const old = oldClassicBehavior.get(ch.instanceName);
|
|
10731
|
+
if (!old || !this.daemons.has(ch.instanceName))
|
|
10732
|
+
continue;
|
|
10733
|
+
const backend = this.classicChannels.getBackend(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.backend);
|
|
10734
|
+
const model = this.classicChannels.getModel(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.model);
|
|
10735
|
+
const autoPauseAfter = this.classicChannels.getAutoPauseAfter(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.auto_pause_after);
|
|
10736
|
+
if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter) {
|
|
10737
|
+
this.logger.info({ instanceName: ch.instanceName }, "Classic cold config changed — restarting");
|
|
10738
|
+
await this.stopInstance(ch.instanceName).catch(() => { });
|
|
10739
|
+
await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
|
|
10740
|
+
continue;
|
|
10741
|
+
}
|
|
10742
|
+
const toolProgress = this.classicChannels.getToolProgress(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.tool_progress);
|
|
10743
|
+
const replyCompletionGuard = this.classicChannels.getReplyCompletionGuard(ch.channelId, ch.adapterId, this.fleetConfig?.defaults?.reply_completion_guard);
|
|
10744
|
+
if (old.toolProgress !== toolProgress || old.replyCompletionGuard !== replyCompletionGuard) {
|
|
10745
|
+
this.applyHotConfigUpdate(ch.instanceName, {
|
|
10746
|
+
tool_progress: toolProgress,
|
|
10747
|
+
reply_completion_guard: replyCompletionGuard,
|
|
10748
|
+
});
|
|
10749
|
+
this.logger.info({ instanceName: ch.instanceName }, "Classic inherited hot config reloaded");
|
|
10750
|
+
}
|
|
10751
|
+
}
|
|
10752
|
+
}
|
|
10069
10753
|
// warm_cap is fleet-owned; enforce the reloaded value immediately against
|
|
10070
10754
|
// currently idle instances instead of waiting for a future state edge.
|
|
10071
10755
|
this.enforceWarmCap();
|
|
@@ -10683,11 +11367,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10683
11367
|
const instances = names.map(name => {
|
|
10684
11368
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
10685
11369
|
let cost = 0;
|
|
10686
|
-
let model = "";
|
|
10687
11370
|
try {
|
|
10688
11371
|
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
10689
11372
|
cost = data.cost?.total_cost_usd ?? 0;
|
|
10690
|
-
model = data.model?.display_name ?? "";
|
|
10691
11373
|
}
|
|
10692
11374
|
catch (err) {
|
|
10693
11375
|
this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
|
|
@@ -10700,8 +11382,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10700
11382
|
?? this.fleetConfig?.defaults?.backend
|
|
10701
11383
|
?? "claude-code");
|
|
10702
11384
|
const { context } = resolveInstanceContext(this.dataDir, name, backend);
|
|
10703
|
-
|
|
10704
|
-
|
|
11385
|
+
// context_pct: null when unavailable, not 0
|
|
11386
|
+
const context_pct = context ?? null;
|
|
11387
|
+
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
11388
|
+
// readStatuslineModel provides the /ctx-aligned display_name+id combo for Claude.
|
|
11389
|
+
// Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
|
|
11390
|
+
const resolved = this.resolveInstanceModel(name);
|
|
11391
|
+
const liveModel = backend === "claude-code" ? readStatuslineModel(this.dataDir, name) : null;
|
|
11392
|
+
// Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
|
|
11393
|
+
const model = liveModel ?? resolved.display;
|
|
11394
|
+
// model_source: "live" when Claude statusline succeeded, else the resolver's source
|
|
11395
|
+
const model_source = liveModel ? "live" : resolved.source;
|
|
11396
|
+
// Effort: aligned with /ctx's effortLineFor — unsupported and antigravity don't show effort.
|
|
11397
|
+
const effortStrategy = this.effortStrategyFor(name);
|
|
11398
|
+
const isAgy = backend === "antigravity" || backend === "agy";
|
|
11399
|
+
const effortResolved = this.resolveInstanceEffort(name);
|
|
11400
|
+
// Only show effort if backend supports it and it's not antigravity
|
|
11401
|
+
const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
|
|
11402
|
+
const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
|
|
11403
|
+
// Display name: fleet config → classic channel → undefined
|
|
11404
|
+
const display_name = classic
|
|
11405
|
+
? this.classicChannels?.getAll().find(ch => ch.instanceName === name)?.displayName
|
|
11406
|
+
: this.fleetConfig?.instances[name]?.display_name;
|
|
11407
|
+
return {
|
|
11408
|
+
name,
|
|
11409
|
+
display_name: display_name || undefined,
|
|
11410
|
+
status: this.getInstanceStatus(name),
|
|
11411
|
+
context_pct,
|
|
11412
|
+
cost,
|
|
11413
|
+
model,
|
|
11414
|
+
model_source,
|
|
11415
|
+
backend,
|
|
11416
|
+
effort,
|
|
11417
|
+
effort_source,
|
|
11418
|
+
};
|
|
10705
11419
|
});
|
|
10706
11420
|
return {
|
|
10707
11421
|
instances,
|