@songsid/agend 2.1.8-beta.1 → 2.1.8-beta.11
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/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +4 -1
- package/dist/agent-endpoint.js +10 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +35 -5
- package/dist/backend/claude-code.js +288 -17
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/cli-env-probe-worker.d.ts +1 -0
- package/dist/backend/cli-env-probe-worker.js +25 -0
- package/dist/backend/cli-env-probe-worker.js.map +1 -0
- package/dist/backend/codex-session-lookup.d.ts +16 -0
- package/dist/backend/codex-session-lookup.js +76 -2
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +3 -4
- package/dist/backend/codex.js +44 -6
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +9 -0
- package/dist/backend/credential-profile.js +20 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/grok.js +19 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/muse.d.ts +6 -0
- package/dist/backend/muse.js +37 -3
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +13 -2
- package/dist/backend/types.js +14 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +16 -4
- package/dist/channel/adapters/discord.js +20 -5
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +6 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +138 -48
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +11 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +7 -0
- package/dist/cross-instance-envelope.js +19 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +130 -6
- package/dist/daemon.js +486 -69
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +9 -1
- package/dist/delivery-outbox.js +25 -4
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +5 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +165 -18
- package/dist/fleet-manager.js +821 -121
- package/dist/fleet-manager.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +160 -0
- package/dist/forged-envelope-scan.js +312 -0
- package/dist/forged-envelope-scan.js.map +1 -0
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +55 -0
- package/dist/instance-lifecycle.d.ts +81 -6
- package/dist/instance-lifecycle.js +201 -17
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +34 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.js +11 -4
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.d.ts +18 -1
- package/dist/login-flows.js +9 -2
- package/dist/login-flows.js.map +1 -1
- package/dist/ls-rows.d.ts +17 -0
- package/dist/ls-rows.js +73 -0
- package/dist/ls-rows.js.map +1 -1
- package/dist/outbound-handlers.d.ts +3 -0
- package/dist/outbound-handlers.js +3 -3
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +23 -4
- package/dist/outbound-schemas.js +12 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.js +4 -2
- package/dist/paths.js.map +1 -1
- package/dist/pause-marker.d.ts +10 -1
- package/dist/pause-marker.js +26 -2
- package/dist/pause-marker.js.map +1 -1
- package/dist/service-installer.d.ts +8 -0
- package/dist/service-installer.js +36 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +8 -8
- package/dist/settings-api.js +12 -5
- package/dist/settings-api.js.map +1 -1
- package/dist/status-emojis.d.ts +28 -1
- package/dist/status-emojis.js +11 -1
- package/dist/status-emojis.js.map +1 -1
- package/dist/target-queue-worker.d.ts +67 -0
- package/dist/target-queue-worker.js +76 -0
- package/dist/target-queue-worker.js.map +1 -0
- package/dist/tool-permissions.js +15 -4
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +9 -0
- package/dist/topic-commands.js +64 -21
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +3 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +63 -0
- package/dist/transcript-sources.js +171 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/ui/settings.html +25 -13
- package/dist/wake-coordinator.d.ts +143 -0
- package/dist/wake-coordinator.js +321 -0
- package/dist/wake-coordinator.js.map +1 -0
- package/dist/web-api.d.ts +3 -1
- package/dist/web-api.js +1 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-terminal.d.ts +2 -0
- package/dist/web-terminal.js +3 -6
- package/dist/web-terminal.js.map +1 -1
- package/package.json +1 -1
- package/templates/systemd.service.ejs +7 -0
package/dist/fleet-manager.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { freemem, totalmem, cpus } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
6
|
-
import {
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { join, dirname, basename, delimiter, isAbsolute } from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
9
10
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
|
|
@@ -16,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
16
17
|
const __dirname = dirname(__filename);
|
|
17
18
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
18
19
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
19
|
-
import { STATUS_EMOJI_KEYS, builtinStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
|
+
import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
|
|
20
21
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
21
22
|
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
22
23
|
import { EventLog } from "./event-log.js";
|
|
@@ -28,7 +29,7 @@ import { IpcClient } from "./channel/ipc-bridge.js";
|
|
|
28
29
|
import { createAdapter } from "./channel/factory.js";
|
|
29
30
|
import { TelegramAdapter } from "./channel/adapters/telegram.js";
|
|
30
31
|
import { createBackend } from "./backend/factory.js";
|
|
31
|
-
import { isModelCompatible } from "./backend/types.js";
|
|
32
|
+
import { isModelCompatible, SYSINFO_BACKEND_IDS } from "./backend/types.js";
|
|
32
33
|
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
33
34
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
34
35
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
@@ -40,7 +41,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
|
|
|
40
41
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
41
42
|
import { safeHandler } from "./safe-async.js";
|
|
42
43
|
import { RoutingEngine } from "./routing-engine.js";
|
|
43
|
-
import { InstanceLifecycle, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
44
|
+
import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
|
|
44
45
|
import { TopicArchiver } from "./topic-archiver.js";
|
|
45
46
|
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
46
47
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
@@ -65,7 +66,10 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
|
|
|
65
66
|
import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
66
67
|
import { validateFleetConfig } from "./config-validator.js";
|
|
67
68
|
import { readLastInboundAt } from "./daemon.js";
|
|
68
|
-
import { clearPausedMarker } from "./pause-marker.js";
|
|
69
|
+
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
70
|
+
import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
|
|
71
|
+
import { TargetQueueWorker } from "./target-queue-worker.js";
|
|
72
|
+
import { resolveDeliveryWorkerMode } from "./types.js";
|
|
69
73
|
import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLock } from "./fleet-lock.js";
|
|
70
74
|
import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
|
|
71
75
|
import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
|
|
@@ -285,6 +289,14 @@ export class FleetManager {
|
|
|
285
289
|
managerBootId = randomUUID();
|
|
286
290
|
deliveryOutbox = null;
|
|
287
291
|
deliveryOutboxRecovered = false;
|
|
292
|
+
/** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
|
|
293
|
+
wakeCoordinator = null;
|
|
294
|
+
/**
|
|
295
|
+
* Phase 2c: targets whose durable lane a TargetQueueWorker owns
|
|
296
|
+
* (delivery_worker: on). Granted and released only in the pump's
|
|
297
|
+
* synchronous section and only while the lane is empty; the pump skips them.
|
|
298
|
+
*/
|
|
299
|
+
queueWorkers = new Map();
|
|
288
300
|
deliveryPumpScheduled = false;
|
|
289
301
|
deliveryPumpRunning = false;
|
|
290
302
|
deliveryPumpTimer = null;
|
|
@@ -569,9 +581,15 @@ export class FleetManager {
|
|
|
569
581
|
this.logger.info({ ...recovered }, "Recovered durable delivery outbox for this process boot");
|
|
570
582
|
this.deliveryOutboxRecovered = true;
|
|
571
583
|
}
|
|
572
|
-
outbox.on("admitted", () => this.scheduleDeliveryOutboxPump());
|
|
584
|
+
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
573
585
|
outbox.on("state", (event) => {
|
|
574
586
|
this.scheduleDeliveryOutboxPump();
|
|
587
|
+
this.wakeCoordinator?.kick();
|
|
588
|
+
if (event.deliveryId && event.state === "delivered") {
|
|
589
|
+
const target = outbox.get(event.deliveryId)?.targetInstance;
|
|
590
|
+
if (target)
|
|
591
|
+
this.wakeCoordinator?.noteDelivered(target);
|
|
592
|
+
}
|
|
575
593
|
if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
|
|
576
594
|
const row = outbox.get(event.deliveryId);
|
|
577
595
|
if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
|
|
@@ -590,8 +608,11 @@ export class FleetManager {
|
|
|
590
608
|
outbox.on("content_digest_mismatch", (event) => {
|
|
591
609
|
this.logger.warn(event, "Delivered text differs from the admitted text (content digest mismatch)");
|
|
592
610
|
});
|
|
611
|
+
this.wakeCoordinator = this.createWakeCoordinator();
|
|
612
|
+
this.wakeCoordinator.start();
|
|
593
613
|
outbox.on("expired", (event) => {
|
|
594
614
|
this.scheduleDeliveryOutboxPump();
|
|
615
|
+
this.wakeCoordinator?.kick();
|
|
595
616
|
if (event.count)
|
|
596
617
|
this.notifyFleetError(`${event.count} durable delivery row(s) reached the 24-hour outbox bound; ${event.uncertain ?? 0} unresolved submissions are uncertain and sender notices were queued.`);
|
|
597
618
|
});
|
|
@@ -618,6 +639,9 @@ export class FleetManager {
|
|
|
618
639
|
kind: input.kind,
|
|
619
640
|
}, "Durable cross-instance delivery accepted");
|
|
620
641
|
this.scheduleDeliveryOutboxPump();
|
|
642
|
+
// Phase 2b: a paused wake_only target is woken by the coordinator, never by
|
|
643
|
+
// the pump; admission must tell it, or the work waits for the watchdog.
|
|
644
|
+
this.wakeCoordinator?.kick();
|
|
621
645
|
return {
|
|
622
646
|
deliveryId: admitted.delivery.deliveryId,
|
|
623
647
|
state: admitted.delivery.state,
|
|
@@ -680,7 +704,7 @@ export class FleetManager {
|
|
|
680
704
|
}
|
|
681
705
|
}
|
|
682
706
|
scheduleDeliveryOutboxPump(delayMs = 0) {
|
|
683
|
-
if (!this.deliveryOutbox || this.shuttingDown)
|
|
707
|
+
if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
|
|
684
708
|
return;
|
|
685
709
|
if (this.deliveryPumpTimer) {
|
|
686
710
|
if (delayMs > 0)
|
|
@@ -700,14 +724,27 @@ export class FleetManager {
|
|
|
700
724
|
}
|
|
701
725
|
async runDeliveryOutboxPump() {
|
|
702
726
|
const outbox = this.deliveryOutbox;
|
|
703
|
-
|
|
727
|
+
// A closed database is never queried: this runs from timers, where a throw
|
|
728
|
+
// is an unhandled rejection. Checking here covers the finally block too:
|
|
729
|
+
// the body below never awaits, so the database cannot close in between.
|
|
730
|
+
if (!outbox || this.shuttingDown || this.deliveryPumpRunning || !outbox.isOpen)
|
|
704
731
|
return;
|
|
705
732
|
this.deliveryPumpRunning = true;
|
|
706
733
|
this.deliveryPumpScheduled = false;
|
|
707
734
|
try {
|
|
708
|
-
outbox.expireStale(
|
|
735
|
+
outbox.expireStale(undefined, undefined, target => {
|
|
736
|
+
const failure = this.wakeCoordinator?.wakeFailure(target);
|
|
737
|
+
return failure ? `target could not be woken (${failure})` : undefined;
|
|
738
|
+
});
|
|
739
|
+
this.reconcileQueueWorkers(outbox);
|
|
709
740
|
while (this.activeDurableTargets.size < 8) {
|
|
710
|
-
const claimed = outbox.claimNext(this.managerBootId,
|
|
741
|
+
const claimed = outbox.claimNext(this.managerBootId,
|
|
742
|
+
// Phase 2b: a wake_only target is claimed only once it is awake; the
|
|
743
|
+
// wake coordinator, not this pump, wakes it (no claim, no attempt).
|
|
744
|
+
// Phase 2c: a lane a queue worker owns is never claimed here.
|
|
745
|
+
target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
|
|
746
|
+
? null
|
|
747
|
+
: this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
|
|
711
748
|
if (!claimed)
|
|
712
749
|
break;
|
|
713
750
|
this.activeDurableTargets.add(claimed.targetInstance);
|
|
@@ -735,7 +772,67 @@ export class FleetManager {
|
|
|
735
772
|
}
|
|
736
773
|
}
|
|
737
774
|
}
|
|
738
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Phase 2c ownership (design §1.5-A), called synchronously from the pump
|
|
777
|
+
* before its claim loop, so a grant and the pump's own claims cannot
|
|
778
|
+
* interleave. A worker is granted a target's lane only when the target is
|
|
779
|
+
* `on` and its lane is empty: no row in flight in the outbox and no pump
|
|
780
|
+
* dispatch holding it. It gives the lane back only when it is idle and the
|
|
781
|
+
* target is no longer `on` (or has nothing pending). A flag change never
|
|
782
|
+
* takes a lane mid-drain.
|
|
783
|
+
*/
|
|
784
|
+
reconcileQueueWorkers(outbox) {
|
|
785
|
+
const pendingTargets = new Set(outbox.listPending().map(row => row.targetInstance));
|
|
786
|
+
for (const [target, worker] of this.queueWorkers) {
|
|
787
|
+
const keep = this.deliveryWorkerMode(target) === "on" && pendingTargets.has(target);
|
|
788
|
+
if (!keep && !worker.busy && !worker.inFlight)
|
|
789
|
+
this.queueWorkers.delete(target);
|
|
790
|
+
}
|
|
791
|
+
for (const target of pendingTargets) {
|
|
792
|
+
if (this.queueWorkers.has(target) || this.deliveryWorkerMode(target) !== "on")
|
|
793
|
+
continue;
|
|
794
|
+
const laneEmpty = !this.activeDurableTargets.has(target)
|
|
795
|
+
&& outbox.countForTarget(target, ["delivering", "submission_started"]) === 0;
|
|
796
|
+
if (laneEmpty)
|
|
797
|
+
this.queueWorkers.set(target, this.createQueueWorker(target));
|
|
798
|
+
}
|
|
799
|
+
for (const worker of this.queueWorkers.values())
|
|
800
|
+
void worker.drain();
|
|
801
|
+
}
|
|
802
|
+
createQueueWorker(target) {
|
|
803
|
+
const worker = new TargetQueueWorker(target, {
|
|
804
|
+
owns: () => this.queueWorkers.get(target) === worker,
|
|
805
|
+
wanted: () => this.deliveryWorkerMode(target) === "on",
|
|
806
|
+
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
807
|
+
blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
|
|
808
|
+
daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
|
|
809
|
+
claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
|
|
810
|
+
tryAcquireBudget: () => {
|
|
811
|
+
if (this.activeDurableTargets.size >= 8)
|
|
812
|
+
return false;
|
|
813
|
+
this.activeDurableTargets.add(target);
|
|
814
|
+
return true;
|
|
815
|
+
},
|
|
816
|
+
releaseBudget: () => { this.activeDurableTargets.delete(target); },
|
|
817
|
+
dispatch: async (claimed) => {
|
|
818
|
+
try {
|
|
819
|
+
await this.dispatchDurableDelivery(claimed, { holdLaneOnTransportError: true });
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
// The row's state event can fire before the hand-off settles; the
|
|
823
|
+
// pump pass it starts then still sees this worker busy and keeps it
|
|
824
|
+
// as owner. One kick after the dispatch has really settled lets the
|
|
825
|
+
// pump release an owner that is no longer wanted (or claim the next
|
|
826
|
+
// row). The no-claim path never kicks, so no retry hot loop.
|
|
827
|
+
this.scheduleDeliveryOutboxPump();
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
kickCoordinator: () => this.wakeCoordinator?.kick(),
|
|
831
|
+
logger: this.logger,
|
|
832
|
+
});
|
|
833
|
+
return worker;
|
|
834
|
+
}
|
|
835
|
+
async dispatchDurableDelivery(claimed, opts = {}) {
|
|
739
836
|
const outbox = this.deliveryOutbox;
|
|
740
837
|
if (!outbox)
|
|
741
838
|
return;
|
|
@@ -761,7 +858,7 @@ export class FleetManager {
|
|
|
761
858
|
content: payload.content,
|
|
762
859
|
delivery_id: claimed.deliveryId,
|
|
763
860
|
delivery_attempt: String(claimed.attemptNo),
|
|
764
|
-
}, { waitForIdle: false });
|
|
861
|
+
}, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
|
|
765
862
|
}
|
|
766
863
|
else {
|
|
767
864
|
payload.meta = {
|
|
@@ -776,6 +873,7 @@ export class FleetManager {
|
|
|
776
873
|
sent = await this.deliverToInstance(target, payload, {
|
|
777
874
|
isCrossInstance: true,
|
|
778
875
|
waitForIdle: claimed.kind !== "steer",
|
|
876
|
+
noInlineWake: this.deliveryWorkerMode(target) !== "off",
|
|
779
877
|
});
|
|
780
878
|
}
|
|
781
879
|
if (!sent) {
|
|
@@ -788,7 +886,10 @@ export class FleetManager {
|
|
|
788
886
|
catch (err) {
|
|
789
887
|
const latest = outbox.get(claimed.deliveryId);
|
|
790
888
|
const reason = err instanceof Error ? err.message : String(err);
|
|
791
|
-
if (latest?.state === "submission_started") {
|
|
889
|
+
if (opts.holdLaneOnTransportError && (latest?.state === "submission_started" || latest?.state === "reconciliation_pending")) {
|
|
890
|
+
this.logger.warn({ deliveryId: claimed.deliveryId, target, attempt, err: reason }, "Transport failed after the submission began; holding the lane for the daemon's verdict or its generation's reconciliation");
|
|
891
|
+
}
|
|
892
|
+
else if (latest?.state === "submission_started") {
|
|
792
893
|
outbox.complete(claimed.deliveryId, claimed.targetDaemonBootId, claimed.attemptNo, "uncertain", reason);
|
|
793
894
|
}
|
|
794
895
|
else {
|
|
@@ -1687,13 +1788,15 @@ export class FleetManager {
|
|
|
1687
1788
|
exclude,
|
|
1688
1789
|
isEvicting: name => this.warmCapEvicting.has(name),
|
|
1689
1790
|
isGeneral: name => isGeneralInstance(this.fleetConfig, name),
|
|
1690
|
-
|
|
1791
|
+
// A work lease (Phase 2a) keeps a just-woken or mid-submission instance
|
|
1792
|
+
// out of the eviction candidates; it is not idle in the sense that matters.
|
|
1793
|
+
isIdle: name => this.getInstanceExecutionState(name) === "idle" && !this.lifecycle.hasWorkLease(name),
|
|
1691
1794
|
lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
|
|
1692
1795
|
});
|
|
1693
1796
|
for (const victim of victims) {
|
|
1694
1797
|
this.warmCapEvicting.add(victim);
|
|
1695
1798
|
this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
|
|
1696
|
-
this.lifecycle.pause(victim)
|
|
1799
|
+
this.lifecycle.pause(victim, "warm_cap")
|
|
1697
1800
|
.catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
|
|
1698
1801
|
.finally(() => this.warmCapEvicting.delete(victim));
|
|
1699
1802
|
}
|
|
@@ -1729,7 +1832,10 @@ export class FleetManager {
|
|
|
1729
1832
|
const query = () => {
|
|
1730
1833
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
1731
1834
|
if (ipc?.connected) {
|
|
1732
|
-
|
|
1835
|
+
// The daemon owns the backend/TTY proof and its 10s startup-input
|
|
1836
|
+
// timer. A cache-only query can never discover a Context-less idle
|
|
1837
|
+
// pane, and a second fleet timer would stack another 10s wait.
|
|
1838
|
+
ipc.send({ type: "query_instance_state", requestId: `idle-gate-${Date.now()}`, deliveryIdle: true });
|
|
1733
1839
|
}
|
|
1734
1840
|
check();
|
|
1735
1841
|
};
|
|
@@ -1744,8 +1850,8 @@ export class FleetManager {
|
|
|
1744
1850
|
/**
|
|
1745
1851
|
* Ask the daemon to capture the pane before answering, then wait for any state
|
|
1746
1852
|
* report produced after this request. The ordinary query is intentionally
|
|
1747
|
-
* cache-only
|
|
1748
|
-
*
|
|
1853
|
+
* cache-only; delivery idle gates opt into their own pane/input proof, and
|
|
1854
|
+
* this refresh serves lifecycle decisions where a stale state would strand UI.
|
|
1749
1855
|
*/
|
|
1750
1856
|
refreshInstanceExecutionState(instanceName, timeoutMs) {
|
|
1751
1857
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -1783,16 +1889,22 @@ export class FleetManager {
|
|
|
1783
1889
|
finish(false);
|
|
1784
1890
|
});
|
|
1785
1891
|
}
|
|
1786
|
-
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch) {
|
|
1892
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
|
|
1787
1893
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1788
1894
|
return false;
|
|
1789
1895
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
1790
1896
|
if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1791
1897
|
return false;
|
|
1792
1898
|
let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
|
|
1899
|
+
if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
|
|
1900
|
+
// Paused after its row was claimed (an operator or auth pause): hand the
|
|
1901
|
+
// row back; the coordinator decides whether and when to wake it.
|
|
1902
|
+
this.wakeCoordinator?.kick();
|
|
1903
|
+
return false;
|
|
1904
|
+
}
|
|
1793
1905
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1794
1906
|
const wakeStartedAt = Date.now();
|
|
1795
|
-
await this.
|
|
1907
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1796
1908
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1797
1909
|
return false;
|
|
1798
1910
|
// Waking added one to the warm count — make room by evicting a different
|
|
@@ -1915,7 +2027,11 @@ export class FleetManager {
|
|
|
1915
2027
|
?? ((options.isCrossInstance ?? inferredCrossInstance) || payload.type === "fleet_schedule_trigger");
|
|
1916
2028
|
if (!waitForIdle) {
|
|
1917
2029
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
1918
|
-
|
|
2030
|
+
if (options.noInlineWake) {
|
|
2031
|
+
this.wakeCoordinator?.kick();
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
await this.explicitWake(instanceName, 30_000);
|
|
1919
2035
|
if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
|
|
1920
2036
|
return false;
|
|
1921
2037
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
@@ -1931,7 +2047,7 @@ export class FleetManager {
|
|
|
1931
2047
|
return true;
|
|
1932
2048
|
}
|
|
1933
2049
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
1934
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch));
|
|
2050
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
|
|
1935
2051
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
1936
2052
|
try {
|
|
1937
2053
|
return await delivery;
|
|
@@ -1947,9 +2063,117 @@ export class FleetManager {
|
|
|
1947
2063
|
const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
|
|
1948
2064
|
return allowed.some(entry => String(entry) === String(userId));
|
|
1949
2065
|
}
|
|
2066
|
+
/** Phase 2: delivery_worker for a target (instance override → fleet default → off). */
|
|
2067
|
+
deliveryWorkerMode(target) {
|
|
2068
|
+
return resolveDeliveryWorkerMode(this.fleetConfig, target);
|
|
2069
|
+
}
|
|
2070
|
+
/** Active instances mid-restart: they keep their warm slot through the stop → replacement gap. */
|
|
2071
|
+
restartsHoldingSlot = new Set();
|
|
2072
|
+
/**
|
|
2073
|
+
* Instances counted against the wake coordinator's hard cap: running
|
|
2074
|
+
* daemons, plus active instances whose restart is between stopping the old
|
|
2075
|
+
* daemon and publishing the replacement.
|
|
2076
|
+
*/
|
|
2077
|
+
warmInstanceNames() {
|
|
2078
|
+
const names = new Set([...this.daemons.keys()].filter(name => this.getInstanceStatus(name) === "running"));
|
|
2079
|
+
for (const name of this.restartsHoldingSlot)
|
|
2080
|
+
names.add(name);
|
|
2081
|
+
return [...names];
|
|
2082
|
+
}
|
|
2083
|
+
createWakeCoordinator() {
|
|
2084
|
+
return new WakeCoordinator({
|
|
2085
|
+
mode: target => this.deliveryWorkerMode(target),
|
|
2086
|
+
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
2087
|
+
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
2088
|
+
isPaused: target => this.lifecycle.isPaused(target),
|
|
2089
|
+
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
2090
|
+
isRestarting: target => this.isInstanceRestarting(target),
|
|
2091
|
+
wake: async (target) => {
|
|
2092
|
+
await this.lifecycle.wake(target, 30_000, undefined, { source: "coordinator" });
|
|
2093
|
+
// The soft cap still applies: evict a different, unleased idle instance.
|
|
2094
|
+
this.enforceWarmCap(target);
|
|
2095
|
+
},
|
|
2096
|
+
residentNames: () => this.warmInstanceNames(),
|
|
2097
|
+
warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
|
|
2098
|
+
warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
|
|
2099
|
+
wokeAt: target => this.lifecycle.wokeAtFor(target),
|
|
2100
|
+
notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
|
|
2101
|
+
notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
|
|
2102
|
+
notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
|
|
2103
|
+
kickPump: () => this.scheduleDeliveryOutboxPump(),
|
|
2104
|
+
now: () => Date.now(),
|
|
2105
|
+
logger: this.logger,
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
/** LifecycleContext: an operator or user wake clears the coordinator's park/backoff for it. */
|
|
2109
|
+
onExternalWake(name) {
|
|
2110
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Phase 2b shared warm-slot admission (design §1.3, §1.6) for every wake
|
|
2114
|
+
* that is not the coordinator's own: an operator's wake, a user's message,
|
|
2115
|
+
* an explicit start or a restart of a paused instance. With delivery_worker
|
|
2116
|
+
* off, or for a target that is not paused (an active restart is already
|
|
2117
|
+
* counted), nothing is reserved. A target already being woken is joined —
|
|
2118
|
+
* its flight holds the slot — rather than reserved twice. Otherwise a slot
|
|
2119
|
+
* is taken under the hard cap, an unleased idle instance is paused to make
|
|
2120
|
+
* one, and if none can be, the wake is refused instead of exceeding the cap.
|
|
2121
|
+
*/
|
|
2122
|
+
async acquireWakeSlot(name) {
|
|
2123
|
+
const coordinator = this.wakeCoordinator;
|
|
2124
|
+
if (!coordinator || this.deliveryWorkerMode(name) === "off" || !this.lifecycle.isPaused(name))
|
|
2125
|
+
return null;
|
|
2126
|
+
if (coordinator.isWaking(name))
|
|
2127
|
+
return null;
|
|
2128
|
+
let token = coordinator.tryReserve(name);
|
|
2129
|
+
if (!token) {
|
|
2130
|
+
const victim = this.lruUnleasedIdle(name);
|
|
2131
|
+
if (victim) {
|
|
2132
|
+
await this.lifecycle.pause(victim, "warm_cap");
|
|
2133
|
+
if (coordinator.isWaking(name))
|
|
2134
|
+
return null;
|
|
2135
|
+
token = coordinator.tryReserve(name);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
if (!token) {
|
|
2139
|
+
throw new Error(`No warm slot is free for '${name}' (warm_cap + overflow reached and no idle instance can be paused); try again when one goes idle`);
|
|
2140
|
+
}
|
|
2141
|
+
return token;
|
|
2142
|
+
}
|
|
2143
|
+
releaseWakeSlot(name, token) {
|
|
2144
|
+
if (!token || !this.wakeCoordinator)
|
|
2145
|
+
return;
|
|
2146
|
+
this.wakeCoordinator.release(name, token);
|
|
2147
|
+
this.wakeCoordinator.kick();
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* An operator's or user's wake (/wake, wake_instance, start_instance on a
|
|
2151
|
+
* paused instance, Settings, a channel message). With delivery_worker off it
|
|
2152
|
+
* is exactly the old lifecycle wake; otherwise it goes through the shared
|
|
2153
|
+
* slot admission and joins any wake already in flight for the target.
|
|
2154
|
+
*/
|
|
2155
|
+
async explicitWake(name, timeoutMs = 30_000) {
|
|
2156
|
+
const token = await this.acquireWakeSlot(name);
|
|
2157
|
+
try {
|
|
2158
|
+
await this.lifecycle.wake(name, timeoutMs);
|
|
2159
|
+
}
|
|
2160
|
+
finally {
|
|
2161
|
+
this.releaseWakeSlot(name, token);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
/** The least-recently-active idle, unleased, non-general running instance other than `exclude`. */
|
|
2165
|
+
lruUnleasedIdle(exclude) {
|
|
2166
|
+
return this.warmInstanceNames()
|
|
2167
|
+
.filter(name => name !== exclude
|
|
2168
|
+
&& !this.warmCapEvicting.has(name)
|
|
2169
|
+
&& !isGeneralInstance(this.fleetConfig, name)
|
|
2170
|
+
&& this.getInstanceExecutionState(name) === "idle"
|
|
2171
|
+
&& !this.lifecycle.hasWorkLease(name))
|
|
2172
|
+
.sort((a, b) => (readLastInboundAt(this.getInstanceDir(a)) ?? 0) - (readLastInboundAt(this.getInstanceDir(b)) ?? 0))[0];
|
|
2173
|
+
}
|
|
1950
2174
|
async changeInstancePauseState(name, action) {
|
|
1951
2175
|
if (action === "wake") {
|
|
1952
|
-
await this.
|
|
2176
|
+
await this.explicitWake(name, 30_000);
|
|
1953
2177
|
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
1954
2178
|
return "awake";
|
|
1955
2179
|
}
|
|
@@ -2068,12 +2292,14 @@ export class FleetManager {
|
|
|
2068
2292
|
* and reconcile calls leave this false so a persisted pause remains paused
|
|
2069
2293
|
* across a fleet restart.
|
|
2070
2294
|
*/
|
|
2071
|
-
resumePaused = false
|
|
2295
|
+
resumePaused = false,
|
|
2296
|
+
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
2297
|
+
transition) {
|
|
2072
2298
|
// Any start supersedes a pending automatic retry (it would otherwise fire
|
|
2073
2299
|
// into a running instance — harmless, but noisy — or race this start).
|
|
2074
2300
|
this.cancelStartupRetry(name);
|
|
2075
2301
|
if (resumePaused && this.lifecycle.isPaused(name)) {
|
|
2076
|
-
await this.
|
|
2302
|
+
await this.explicitWake(name, 30_000);
|
|
2077
2303
|
// A successful wake clears the persisted pause marker and produces a
|
|
2078
2304
|
// fresh instance_state snapshot. Drop any stale process error left by a
|
|
2079
2305
|
// pre-pause crash so /api/fleet and `agend ls` converge on running.
|
|
@@ -2100,6 +2326,10 @@ export class FleetManager {
|
|
|
2100
2326
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
2101
2327
|
return;
|
|
2102
2328
|
}
|
|
2329
|
+
// An explicit start (CLI/API) begins a fresh idle window; boot and
|
|
2330
|
+
// reconcile starts keep the persisted last-inbound seed (Phase 2a).
|
|
2331
|
+
if (resumePaused)
|
|
2332
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2103
2333
|
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
2104
2334
|
if (config.general_topic) {
|
|
2105
2335
|
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
@@ -2123,7 +2353,7 @@ export class FleetManager {
|
|
|
2123
2353
|
kind,
|
|
2124
2354
|
backend,
|
|
2125
2355
|
model: this.resolveInstanceModel(name).display,
|
|
2126
|
-
});
|
|
2356
|
+
}, transition);
|
|
2127
2357
|
// Only clear a stale process status after a real start succeeded. Clearing
|
|
2128
2358
|
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
2129
2359
|
// a falsely running instance when lifecycle.start() returns early.
|
|
@@ -2137,18 +2367,18 @@ export class FleetManager {
|
|
|
2137
2367
|
this.requestDiscordUsagePresenceRefresh();
|
|
2138
2368
|
}
|
|
2139
2369
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
2140
|
-
async startPersistedPausedInstance(name) {
|
|
2370
|
+
async startPersistedPausedInstance(name, transition) {
|
|
2141
2371
|
const topicMode = this.fleetConfig?.channel?.mode === "topic"
|
|
2142
2372
|
|| !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
|
|
2143
2373
|
const fleetConfig = this.fleetConfig?.instances[name];
|
|
2144
2374
|
if (fleetConfig) {
|
|
2145
|
-
await this.startInstance(name, fleetConfig, topicMode);
|
|
2375
|
+
await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
|
|
2146
2376
|
return;
|
|
2147
2377
|
}
|
|
2148
2378
|
const channel = this.classicChannels?.getAll().find(item => item.instanceName === name);
|
|
2149
2379
|
if (!channel || !this.classicChannels)
|
|
2150
2380
|
throw new Error(`Paused instance '${name}' is no longer configured`);
|
|
2151
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, 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));
|
|
2381
|
+
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, 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), transition);
|
|
2152
2382
|
}
|
|
2153
2383
|
/**
|
|
2154
2384
|
* Start instances with configurable concurrency and stagger delay.
|
|
@@ -2197,6 +2427,10 @@ export class FleetManager {
|
|
|
2197
2427
|
return this.daemons.has(name);
|
|
2198
2428
|
}
|
|
2199
2429
|
catch (err) {
|
|
2430
|
+
// Superseded by a stop/restart (Phase 2a): that transition owns the
|
|
2431
|
+
// outcome. Retrying would start an instance the operator just stopped.
|
|
2432
|
+
if (err instanceof SupersededStartError)
|
|
2433
|
+
return false;
|
|
2200
2434
|
this.logger.error({ err, name }, `Failed to start ${what}`);
|
|
2201
2435
|
this.scheduleStartupRetry(name, 0);
|
|
2202
2436
|
return false;
|
|
@@ -2318,6 +2552,9 @@ export class FleetManager {
|
|
|
2318
2552
|
}
|
|
2319
2553
|
}
|
|
2320
2554
|
catch (err) {
|
|
2555
|
+
// Superseded by a stop/restart: that transition owns the outcome.
|
|
2556
|
+
if (err instanceof SupersededStartError)
|
|
2557
|
+
return;
|
|
2321
2558
|
this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
|
|
2322
2559
|
this.scheduleStartupRetry(name, attempt + 1);
|
|
2323
2560
|
}
|
|
@@ -2358,6 +2595,8 @@ export class FleetManager {
|
|
|
2358
2595
|
return this.daemons.has(ch.instanceName);
|
|
2359
2596
|
}
|
|
2360
2597
|
catch (err) {
|
|
2598
|
+
if (err instanceof SupersededStartError)
|
|
2599
|
+
return false;
|
|
2361
2600
|
this.logger.warn({ err, instanceName: ch.instanceName }, `Failed to start ${what}`);
|
|
2362
2601
|
this.scheduleStartupRetry(ch.instanceName, 0);
|
|
2363
2602
|
return false;
|
|
@@ -2514,7 +2753,7 @@ export class FleetManager {
|
|
|
2514
2753
|
}
|
|
2515
2754
|
return false;
|
|
2516
2755
|
}
|
|
2517
|
-
async stopInstance(name) {
|
|
2756
|
+
async stopInstance(name, transition) {
|
|
2518
2757
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
2519
2758
|
this.cancelStartupRetry(name);
|
|
2520
2759
|
this.failoverActive.delete(name);
|
|
@@ -2533,7 +2772,7 @@ export class FleetManager {
|
|
|
2533
2772
|
// the hand-off snapshotted it).
|
|
2534
2773
|
const run = (async () => {
|
|
2535
2774
|
try {
|
|
2536
|
-
await this.lifecycle.stop(name);
|
|
2775
|
+
await this.lifecycle.stop(name, transition);
|
|
2537
2776
|
}
|
|
2538
2777
|
finally {
|
|
2539
2778
|
this.clearNoncePromptsForInstance(name);
|
|
@@ -2555,11 +2794,16 @@ export class FleetManager {
|
|
|
2555
2794
|
return inFlight;
|
|
2556
2795
|
}
|
|
2557
2796
|
const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
|
|
2558
|
-
|
|
2797
|
+
// Phase 2a: invalidate synchronously so a start still spawning does not
|
|
2798
|
+
// publish, then queue behind it as this instance's next transition. The
|
|
2799
|
+
// spawn gate is taken inside the transition, never the other way round, so
|
|
2800
|
+
// the start being waited for can still get its own gate slot.
|
|
2801
|
+
this.lifecycle.invalidate(name);
|
|
2802
|
+
const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
|
|
2559
2803
|
instanceName: name,
|
|
2560
2804
|
workingDirectory,
|
|
2561
2805
|
reason: "restart",
|
|
2562
|
-
}, () => this.doRestartSingleInstance(name, opts))
|
|
2806
|
+
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
2563
2807
|
.finally(() => {
|
|
2564
2808
|
this.restartsInFlight.delete(name);
|
|
2565
2809
|
this.scheduleDeliveryOutboxPump();
|
|
@@ -2570,19 +2814,73 @@ export class FleetManager {
|
|
|
2570
2814
|
isInstanceRestarting(name) {
|
|
2571
2815
|
return this.restartsInFlight.has(name);
|
|
2572
2816
|
}
|
|
2573
|
-
|
|
2817
|
+
/** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
|
|
2818
|
+
restartInFlight(name) {
|
|
2819
|
+
return this.restartsInFlight.get(name);
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Phase 2a: restarting a paused instance is a wake. The stop leaves the
|
|
2823
|
+
* pause marker in place, and the start used to skip on it ("Persisted
|
|
2824
|
+
* paused instance"), turning a resident pause into a marker-only one that
|
|
2825
|
+
* nothing would wake. Clear it before the start; if the start fails, put it
|
|
2826
|
+
* back so the instance stays wakeable instead of becoming plain stopped.
|
|
2827
|
+
*/
|
|
2828
|
+
async startAfterRestart(name, wasPaused, pausedAt, reason, start) {
|
|
2829
|
+
const instanceDir = this.getInstanceDir(name);
|
|
2830
|
+
if (wasPaused)
|
|
2831
|
+
clearPausedMarker(instanceDir);
|
|
2832
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
2833
|
+
try {
|
|
2834
|
+
await start();
|
|
2835
|
+
}
|
|
2836
|
+
catch (err) {
|
|
2837
|
+
if (wasPaused && !this.daemons.has(name))
|
|
2838
|
+
writePausedMarker(instanceDir, pausedAt ?? Date.now(), reason);
|
|
2839
|
+
throw err;
|
|
2840
|
+
}
|
|
2841
|
+
finally {
|
|
2842
|
+
this.lifecycle.clearActivitySeedNow(name);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
async doRestartSingleInstance(name, opts, transition) {
|
|
2574
2846
|
if (this.configPath) {
|
|
2575
2847
|
this.loadConfig(this.configPath);
|
|
2576
2848
|
this.routing.rebuild(this.fleetConfig);
|
|
2577
2849
|
this.reregisterClassicChannels();
|
|
2578
2850
|
}
|
|
2579
2851
|
const config = this.fleetConfig?.instances[name];
|
|
2852
|
+
const wasPaused = this.lifecycle.isPaused(name);
|
|
2853
|
+
// A paused instance's restart is a wake: it takes a warm slot like any other
|
|
2854
|
+
// (refused before anything is stopped when none can be had). An active
|
|
2855
|
+
// restart is already counted and reserves nothing.
|
|
2856
|
+
const slot = await this.acquireWakeSlot(name);
|
|
2857
|
+
// An active instance keeps the slot it already occupies across its own
|
|
2858
|
+
// stop → replacement gap: counted as warm until the replacement is
|
|
2859
|
+
// published or the restart settles, so no other target can take it.
|
|
2860
|
+
if (!wasPaused)
|
|
2861
|
+
this.restartsHoldingSlot.add(name);
|
|
2862
|
+
try {
|
|
2863
|
+
await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
|
|
2864
|
+
// An explicit restart of a paused instance is an operator wake (§1.6):
|
|
2865
|
+
// its success lifts the coordinator's park/backoff (the level stays).
|
|
2866
|
+
// Automatic restarts never do.
|
|
2867
|
+
if (wasPaused && opts?.explicit && this.daemons.has(name))
|
|
2868
|
+
this.wakeCoordinator?.noteExternalWake(name);
|
|
2869
|
+
}
|
|
2870
|
+
finally {
|
|
2871
|
+
this.restartsHoldingSlot.delete(name);
|
|
2872
|
+
this.releaseWakeSlot(name, slot);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
2876
|
+
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
2877
|
+
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
2580
2878
|
if (config) {
|
|
2581
|
-
await this.stopInstance(name);
|
|
2879
|
+
await this.stopInstance(name, transition);
|
|
2582
2880
|
if (opts?.freshStart)
|
|
2583
2881
|
this.writeFreshStartMarker(name);
|
|
2584
2882
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
2585
|
-
await this.startInstance(name, config, topicMode ?? false);
|
|
2883
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
|
|
2586
2884
|
this.stormWindow.markRecovered(name);
|
|
2587
2885
|
return;
|
|
2588
2886
|
}
|
|
@@ -2591,11 +2889,11 @@ export class FleetManager {
|
|
|
2591
2889
|
if (channelId) {
|
|
2592
2890
|
const fleetBackend = this.fleetConfig?.defaults?.backend;
|
|
2593
2891
|
const adapterId = this.classicChannels.getAdapterIdByInstance(name);
|
|
2594
|
-
await this.stopInstance(name);
|
|
2892
|
+
await this.stopInstance(name, transition);
|
|
2595
2893
|
await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
|
|
2596
2894
|
if (opts?.freshStart)
|
|
2597
2895
|
this.writeFreshStartMarker(name);
|
|
2598
|
-
await this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
2896
|
+
await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
|
|
2599
2897
|
this.stormWindow.markRecovered(name);
|
|
2600
2898
|
return;
|
|
2601
2899
|
}
|
|
@@ -3915,7 +4213,7 @@ export class FleetManager {
|
|
|
3915
4213
|
}
|
|
3916
4214
|
else if (data.command === "sysinfo") {
|
|
3917
4215
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
3918
|
-
await
|
|
4216
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
3919
4217
|
}
|
|
3920
4218
|
else if (data.command === "dashboard") {
|
|
3921
4219
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -4275,7 +4573,7 @@ export class FleetManager {
|
|
|
4275
4573
|
}
|
|
4276
4574
|
else if (data.command === "sysinfo") {
|
|
4277
4575
|
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4278
|
-
await
|
|
4576
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4279
4577
|
}
|
|
4280
4578
|
else if (data.command === "dashboard") {
|
|
4281
4579
|
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
@@ -5850,7 +6148,8 @@ export class FleetManager {
|
|
|
5850
6148
|
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
5851
6149
|
: type === "fleet_task" ? "fleet_task_response"
|
|
5852
6150
|
: type === "fleet_set_display_name" ? "fleet_display_name_response"
|
|
5853
|
-
: "
|
|
6151
|
+
: type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis" ? "fleet_persona_emoji_response"
|
|
6152
|
+
: "fleet_description_response";
|
|
5854
6153
|
ipc.send({ type: responseType, fleetRequestId, error: message });
|
|
5855
6154
|
}
|
|
5856
6155
|
dispatchTypedIpc(name, msg) {
|
|
@@ -5875,6 +6174,24 @@ export class FleetManager {
|
|
|
5875
6174
|
this.handleSetDescription(name, msg);
|
|
5876
6175
|
return;
|
|
5877
6176
|
}
|
|
6177
|
+
if (type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis") {
|
|
6178
|
+
this.handlePersonaEmoji(name, msg);
|
|
6179
|
+
return;
|
|
6180
|
+
}
|
|
6181
|
+
}
|
|
6182
|
+
/** `list_emojis` / `set_persona_emoji` / `preview_emojis` over IPC; the agent endpoint calls the same methods. */
|
|
6183
|
+
handlePersonaEmoji(instanceName, msg) {
|
|
6184
|
+
const fleetRequestId = msg.fleetRequestId;
|
|
6185
|
+
const payload = (msg.payload ?? {});
|
|
6186
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
6187
|
+
if (!ipc)
|
|
6188
|
+
return;
|
|
6189
|
+
const op = msg.type === "fleet_list_emojis" ? this.listEmojisFor(instanceName, payload.refresh === true)
|
|
6190
|
+
: msg.type === "fleet_preview_emojis" ? this.previewEmojis(instanceName, payload)
|
|
6191
|
+
: this.setPersonaEmoji(instanceName, payload);
|
|
6192
|
+
op.then((r) => typeof r.error === "string"
|
|
6193
|
+
? ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: r.error })
|
|
6194
|
+
: ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, result: r }), (err) => ipc.send({ type: "fleet_persona_emoji_response", fleetRequestId, error: err.message }));
|
|
5878
6195
|
}
|
|
5879
6196
|
handleScheduleCrud(instanceName, msg) {
|
|
5880
6197
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -5979,10 +6296,15 @@ export class FleetManager {
|
|
|
5979
6296
|
?? this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName)?.displayName
|
|
5980
6297
|
?? instanceName;
|
|
5981
6298
|
}
|
|
6299
|
+
/** Inherited keys such as __proto__ must never resolve to a mutable instance. */
|
|
6300
|
+
ownInstanceConfig(instanceName) {
|
|
6301
|
+
const instances = this.fleetConfig?.instances;
|
|
6302
|
+
return instances && Object.hasOwn(instances, instanceName) ? instances[instanceName] : undefined;
|
|
6303
|
+
}
|
|
5982
6304
|
/** Persist identity to the instance's actual config store. Classic instances
|
|
5983
6305
|
* are registry rows in classicBot.yaml, not fleet.yaml instance entries. */
|
|
5984
6306
|
setInstanceDisplayName(instanceName, displayName) {
|
|
5985
|
-
const fleetInstance = this.
|
|
6307
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
5986
6308
|
if (fleetInstance) {
|
|
5987
6309
|
fleetInstance.display_name = displayName;
|
|
5988
6310
|
this.saveFleetConfig();
|
|
@@ -5991,7 +6313,7 @@ export class FleetManager {
|
|
|
5991
6313
|
return this.classicChannels?.setDisplayNameByInstance(instanceName, displayName) ?? false;
|
|
5992
6314
|
}
|
|
5993
6315
|
setInstanceDescription(instanceName, description) {
|
|
5994
|
-
const fleetInstance = this.
|
|
6316
|
+
const fleetInstance = this.ownInstanceConfig(instanceName);
|
|
5995
6317
|
if (fleetInstance) {
|
|
5996
6318
|
fleetInstance.description = description;
|
|
5997
6319
|
this.saveFleetConfig();
|
|
@@ -6217,6 +6539,15 @@ export class FleetManager {
|
|
|
6217
6539
|
return { error: `Instance '${instance}' not found` };
|
|
6218
6540
|
return { display_name: name };
|
|
6219
6541
|
}
|
|
6542
|
+
async handleListEmojisHttp(instance, refresh) {
|
|
6543
|
+
return this.listEmojisFor(instance, refresh);
|
|
6544
|
+
}
|
|
6545
|
+
async handleSetPersonaEmojiHttp(instance, args) {
|
|
6546
|
+
return this.setPersonaEmoji(instance, args);
|
|
6547
|
+
}
|
|
6548
|
+
async handlePreviewEmojisHttp(instance, args) {
|
|
6549
|
+
return this.previewEmojis(instance, args);
|
|
6550
|
+
}
|
|
6220
6551
|
async handleSetDescriptionHttp(instance, description) {
|
|
6221
6552
|
if (!this.fleetConfig)
|
|
6222
6553
|
return { error: "Fleet config not available" };
|
|
@@ -6834,6 +7165,8 @@ export class FleetManager {
|
|
|
6834
7165
|
deliveryStatusChains = new Map();
|
|
6835
7166
|
/** #725: in-flight CLI env probes, keyed by backend name. Coalesces concurrent /model requests. */
|
|
6836
7167
|
pendingCliEnvProbes = new Map();
|
|
7168
|
+
/** Forced vendor catalog refreshes are separate flights, but sysinfo joins either flight. */
|
|
7169
|
+
pendingVendorCliEnvProbes = new Map();
|
|
6837
7170
|
/**
|
|
6838
7171
|
* The status emojis for an instance (#1005): its own `status_emojis`, then
|
|
6839
7172
|
* its channel's `options.status_emojis`, then the platform built-in. An
|
|
@@ -6864,10 +7197,15 @@ export class FleetManager {
|
|
|
6864
7197
|
}
|
|
6865
7198
|
warnedStatusEmojis = new Set();
|
|
6866
7199
|
/**
|
|
6867
|
-
* A Discord connection's server emojis for the Settings picker (#1005)
|
|
6868
|
-
*
|
|
6869
|
-
*
|
|
6870
|
-
*
|
|
7200
|
+
* A Discord connection's server emojis for the Settings picker (#1005), from
|
|
7201
|
+
* every server its bot can draw on (#1021): the primary `group_id` server,
|
|
7202
|
+
* then each other server the bot is a member of that ClassicBot's
|
|
7203
|
+
* `allowed_guilds` admits (unset = all, as for classic routing). A bot can
|
|
7204
|
+
* react with a custom emoji from any server it is in. Cached per server — a
|
|
7205
|
+
* server's emoji set changes rarely — and one fetch per server at a time;
|
|
7206
|
+
* `refresh` forces new ones. One server refusing (e.g. Missing Access) is
|
|
7207
|
+
* reported on that server and does not hide the others. Uses the running
|
|
7208
|
+
* adapter, so the bot token never leaves the server.
|
|
6871
7209
|
*/
|
|
6872
7210
|
async listGuildEmojis(channelId, refresh = false) {
|
|
6873
7211
|
const world = this.worlds.get(channelId);
|
|
@@ -6878,28 +7216,268 @@ export class FleetManager {
|
|
|
6878
7216
|
const adapter = world.adapter;
|
|
6879
7217
|
if (!adapter.listGuildEmojis)
|
|
6880
7218
|
return { ok: false, error: "this adapter cannot list server emojis" };
|
|
6881
|
-
const
|
|
6882
|
-
|
|
6883
|
-
return { ok: true, ...cached };
|
|
6884
|
-
let pending = this.guildEmojiFetches.get(channelId);
|
|
6885
|
-
if (!pending) {
|
|
6886
|
-
pending = adapter.listGuildEmojis().then(emojis => {
|
|
6887
|
-
const entry = { fetched_at: Date.now(), emojis };
|
|
6888
|
-
this.guildEmojiCache.set(channelId, entry);
|
|
6889
|
-
return entry;
|
|
6890
|
-
}).finally(() => this.guildEmojiFetches.delete(channelId));
|
|
6891
|
-
this.guildEmojiFetches.set(channelId, pending);
|
|
6892
|
-
}
|
|
7219
|
+
const listEmojis = adapter.listGuildEmojis.bind(adapter);
|
|
7220
|
+
let servers;
|
|
6893
7221
|
try {
|
|
6894
|
-
|
|
7222
|
+
servers = adapter.listMemberGuilds
|
|
7223
|
+
? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
|
|
7224
|
+
: [{ id: "", name: "", primary: true }];
|
|
6895
7225
|
}
|
|
6896
7226
|
catch (e) {
|
|
6897
|
-
return { ok: false, error: `Discord refused the
|
|
6898
|
-
}
|
|
7227
|
+
return { ok: false, error: `Discord refused the server list: ${e.message}` };
|
|
7228
|
+
}
|
|
7229
|
+
const guilds = await Promise.all(servers.map(async (g) => {
|
|
7230
|
+
const key = `${channelId}\0${g.id}`;
|
|
7231
|
+
const cached = this.guildEmojiCache.get(key);
|
|
7232
|
+
if (!refresh && cached && Date.now() - cached.fetched_at < FleetManager.GUILD_EMOJI_TTL_MS)
|
|
7233
|
+
return { ...g, ...cached };
|
|
7234
|
+
let pending = this.guildEmojiFetches.get(key);
|
|
7235
|
+
if (!pending) {
|
|
7236
|
+
pending = (g.id ? listEmojis(g.id) : listEmojis()).then(emojis => {
|
|
7237
|
+
const entry = { fetched_at: Date.now(), emojis };
|
|
7238
|
+
this.guildEmojiCache.set(key, entry);
|
|
7239
|
+
return entry;
|
|
7240
|
+
}).finally(() => this.guildEmojiFetches.delete(key));
|
|
7241
|
+
this.guildEmojiFetches.set(key, pending);
|
|
7242
|
+
}
|
|
7243
|
+
try {
|
|
7244
|
+
return { ...g, ...(await pending) };
|
|
7245
|
+
}
|
|
7246
|
+
catch (e) {
|
|
7247
|
+
return { ...g, error: `Discord refused the emoji list: ${e.message}` };
|
|
7248
|
+
}
|
|
7249
|
+
}));
|
|
7250
|
+
const listed = guilds.filter((g) => !!g.emojis);
|
|
7251
|
+
if (!listed.length)
|
|
7252
|
+
return { ok: false, error: guilds[0]?.error ?? "no server to list emojis from" };
|
|
7253
|
+
return {
|
|
7254
|
+
ok: true,
|
|
7255
|
+
fetched_at: Math.min(...listed.map(g => g.fetched_at)),
|
|
7256
|
+
emojis: listed.flatMap(g => g.emojis),
|
|
7257
|
+
guilds,
|
|
7258
|
+
};
|
|
6899
7259
|
}
|
|
6900
7260
|
static GUILD_EMOJI_TTL_MS = 10 * 60_000;
|
|
7261
|
+
/** Keyed `<connection>\0<server id>`. */
|
|
6901
7262
|
guildEmojiCache = new Map();
|
|
6902
7263
|
guildEmojiFetches = new Map();
|
|
7264
|
+
/**
|
|
7265
|
+
* What `instanceName` may pick as its persona emoji (the `list_emojis`
|
|
7266
|
+
* tool): its current status set with where each value comes from, the
|
|
7267
|
+
* standard emojis its platform takes, and on Discord the server emojis its
|
|
7268
|
+
* bot can draw on — the same lists, and the same resolution, as the
|
|
7269
|
+
* Settings picker (#1005/#1021), so an agent and an operator see one truth.
|
|
7270
|
+
*/
|
|
7271
|
+
async listEmojisFor(instanceName, refresh = false) {
|
|
7272
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7273
|
+
if (!self)
|
|
7274
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7275
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7276
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7277
|
+
const channel = (worldId ? this.worlds.get(worldId)?.channelConfig : undefined) ?? this.getChannelConfig(worldId ?? undefined);
|
|
7278
|
+
const current = previewStatusEmojis({ platform, platformConfig: channel?.options?.status_emojis, instanceConfig: self.status_emojis });
|
|
7279
|
+
const out = {
|
|
7280
|
+
platform: platform ?? null,
|
|
7281
|
+
statuses: current.entries.map(e => ({ status: e.key, value: e.value, source: e.source })),
|
|
7282
|
+
standard: platform === "telegram"
|
|
7283
|
+
? { reactions: [...TELEGRAM_REACTION_EMOJIS], note: "Telegram reacts only with these; progress_prefix may be any single emoji" }
|
|
7284
|
+
: { suggestions: STATUS_EMOJI_SUGGESTIONS, note: "any single emoji works" },
|
|
7285
|
+
};
|
|
7286
|
+
if (platform === "discord" && worldId) {
|
|
7287
|
+
const listed = await this.listGuildEmojis(worldId, refresh);
|
|
7288
|
+
out.server_emojis = listed.ok
|
|
7289
|
+
? listed.guilds.map(g => g.emojis
|
|
7290
|
+
? {
|
|
7291
|
+
server: g.name || g.id, primary: g.primary,
|
|
7292
|
+
emojis: g.emojis.filter(e => e.available).map(e => {
|
|
7293
|
+
const value = customEmojiValue(e);
|
|
7294
|
+
return { value, image_url: emojiImageUrl(value) };
|
|
7295
|
+
}),
|
|
7296
|
+
}
|
|
7297
|
+
: { server: g.name || g.id, primary: g.primary, error: g.error })
|
|
7298
|
+
: { error: listed.error };
|
|
7299
|
+
}
|
|
7300
|
+
return out;
|
|
7301
|
+
}
|
|
7302
|
+
/**
|
|
7303
|
+
* `preview_emojis` (#1040): download a few Discord server emojis so an agent
|
|
7304
|
+
* can look at them before it picks one — a name and an id say nothing about
|
|
7305
|
+
* what an emoji looks like. The fleet fetches, not the agent (not every
|
|
7306
|
+
* backend can), and returns a local path per emoji to Read, as
|
|
7307
|
+
* download_attachment does for an attachment.
|
|
7308
|
+
*
|
|
7309
|
+
* SSRF-safe by construction: nothing from the caller reaches a URL. The
|
|
7310
|
+
* caller names emojis; each must be an available one in a server this bot
|
|
7311
|
+
* can draw on (the list set_persona_emoji checks), and the CDN URL is built
|
|
7312
|
+
* from that listed emoji's numeric id. Static PNG, size-capped, cached by id.
|
|
7313
|
+
*/
|
|
7314
|
+
async previewEmojis(instanceName, args) {
|
|
7315
|
+
const self = this.fleetConfig?.instances[instanceName];
|
|
7316
|
+
if (!self)
|
|
7317
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7318
|
+
const wanted = args.emojis;
|
|
7319
|
+
if (!Array.isArray(wanted) || wanted.length === 0 || wanted.some(e => typeof e !== "string")) {
|
|
7320
|
+
return { error: "emojis is required: a list of <:name:id> values from list_emojis" };
|
|
7321
|
+
}
|
|
7322
|
+
if (wanted.length > FleetManager.EMOJI_PREVIEW_MAX) {
|
|
7323
|
+
return { error: `at most ${FleetManager.EMOJI_PREVIEW_MAX} at a time: narrow them down by name first` };
|
|
7324
|
+
}
|
|
7325
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7326
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7327
|
+
if (platform !== "discord" || !worldId) {
|
|
7328
|
+
return { error: "only Discord server emojis need a preview; standard emojis are what they look like" };
|
|
7329
|
+
}
|
|
7330
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7331
|
+
if (!listed.ok)
|
|
7332
|
+
return { error: `cannot list this bot's server emojis: ${listed.error}` };
|
|
7333
|
+
const previews = [];
|
|
7334
|
+
const errors = [];
|
|
7335
|
+
for (const raw of wanted) {
|
|
7336
|
+
const e = normalizeEmoji(raw);
|
|
7337
|
+
if (e?.kind !== "custom") {
|
|
7338
|
+
errors.push({ emoji: raw, error: "not a server emoji (a standard emoji needs no preview)" });
|
|
7339
|
+
continue;
|
|
7340
|
+
}
|
|
7341
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7342
|
+
if (!found?.available) {
|
|
7343
|
+
errors.push({ emoji: raw, error: "not a server emoji this bot can use" });
|
|
7344
|
+
continue;
|
|
7345
|
+
}
|
|
7346
|
+
try {
|
|
7347
|
+
previews.push({ emoji: customEmojiValue(found), path: await this.fetchEmojiPreview(found.id) });
|
|
7348
|
+
}
|
|
7349
|
+
catch (err) {
|
|
7350
|
+
errors.push({ emoji: raw, error: `download failed: ${err.message}` });
|
|
7351
|
+
}
|
|
7352
|
+
}
|
|
7353
|
+
return { previews, errors, note: "Read each path to see the emoji (a static PNG)." };
|
|
7354
|
+
}
|
|
7355
|
+
static EMOJI_PREVIEW_MAX = 8;
|
|
7356
|
+
static EMOJI_PREVIEW_MAX_BYTES = 256 * 1024;
|
|
7357
|
+
static EMOJI_PREVIEW_TTL_MS = 24 * 60 * 60_000;
|
|
7358
|
+
/** One emoji's static PNG under the inbox, reused while fresh; stale ones are pruned. */
|
|
7359
|
+
async fetchEmojiPreview(id) {
|
|
7360
|
+
if (!/^\d{15,25}$/.test(id))
|
|
7361
|
+
throw new Error("not a Discord emoji id");
|
|
7362
|
+
const dir = join(this.dataDir, "inbox", "emoji-previews");
|
|
7363
|
+
mkdirSync(dir, { recursive: true });
|
|
7364
|
+
const now = Date.now();
|
|
7365
|
+
for (const name of readdirSync(dir)) {
|
|
7366
|
+
try {
|
|
7367
|
+
if (now - statSync(join(dir, name)).mtimeMs > FleetManager.EMOJI_PREVIEW_TTL_MS)
|
|
7368
|
+
unlinkSync(join(dir, name));
|
|
7369
|
+
}
|
|
7370
|
+
catch { /* raced */ }
|
|
7371
|
+
}
|
|
7372
|
+
const path = join(dir, `${id}.png`);
|
|
7373
|
+
if (existsSync(path))
|
|
7374
|
+
return path;
|
|
7375
|
+
const response = await fetch(`https://cdn.discordapp.com/emojis/${id}.png?size=96`, { signal: AbortSignal.timeout(10_000), redirect: "error" });
|
|
7376
|
+
if (!response.ok)
|
|
7377
|
+
throw new Error(`HTTP ${response.status}`);
|
|
7378
|
+
if (!/^image\/png\b/.test(response.headers.get("content-type") ?? ""))
|
|
7379
|
+
throw new Error("not a PNG");
|
|
7380
|
+
const reader = response.body?.getReader();
|
|
7381
|
+
if (!reader)
|
|
7382
|
+
throw new Error("empty response");
|
|
7383
|
+
const chunks = [];
|
|
7384
|
+
let size = 0;
|
|
7385
|
+
for (;;) {
|
|
7386
|
+
const { done, value } = await reader.read();
|
|
7387
|
+
if (done)
|
|
7388
|
+
break;
|
|
7389
|
+
size += value.byteLength;
|
|
7390
|
+
if (size > FleetManager.EMOJI_PREVIEW_MAX_BYTES) {
|
|
7391
|
+
await reader.cancel();
|
|
7392
|
+
throw new Error("image too large");
|
|
7393
|
+
}
|
|
7394
|
+
chunks.push(value);
|
|
7395
|
+
}
|
|
7396
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
7397
|
+
writeFileSync(tmp, Buffer.concat(chunks));
|
|
7398
|
+
renameSync(tmp, path);
|
|
7399
|
+
return path;
|
|
7400
|
+
}
|
|
7401
|
+
/**
|
|
7402
|
+
* `set_persona_emoji`: `instanceName` sets one of its own status emojis —
|
|
7403
|
+
* `delivered` unless it names another — in its per-instance `status_emojis`
|
|
7404
|
+
* override (#1005 addendum 2), so its stamp is told apart from other bots'
|
|
7405
|
+
* on a shared message. The value is judged exactly as Settings and the
|
|
7406
|
+
* react path judge it (statusEmojiProblem on the instance's platform), and
|
|
7407
|
+
* a Discord server emoji must be one its bot can use: an emoji from a
|
|
7408
|
+
* server it is not in would be stored, preview fine, and fail every react.
|
|
7409
|
+
* An empty emoji removes that status's override.
|
|
7410
|
+
*/
|
|
7411
|
+
async setPersonaEmoji(instanceName, args) {
|
|
7412
|
+
const self = this.ownInstanceConfig(instanceName);
|
|
7413
|
+
if (!self)
|
|
7414
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7415
|
+
const status = (args.status ?? "delivered");
|
|
7416
|
+
if (!STATUS_EMOJI_CONFIG_KEYS.includes(status)) {
|
|
7417
|
+
return { error: `status must be one of ${STATUS_EMOJI_CONFIG_KEYS.join(", ")}` };
|
|
7418
|
+
}
|
|
7419
|
+
// Only an explicit "" removes the override. The agent endpoint, typed IPC
|
|
7420
|
+
// and agent-cli do not run the MCP schema, so a call missing `emoji` must
|
|
7421
|
+
// be refused here, never read as "clear my delivered stamp".
|
|
7422
|
+
if (typeof args.emoji !== "string") {
|
|
7423
|
+
return { error: 'emoji is required: one emoji, a <:name:id> from list_emojis, or "" to remove your override' };
|
|
7424
|
+
}
|
|
7425
|
+
const raw = args.emoji.trim();
|
|
7426
|
+
// Whitespace is not an explicit "": a stray " " must not clear the stamp either.
|
|
7427
|
+
if (!raw && args.emoji !== "") {
|
|
7428
|
+
return { error: 'emoji is blank: pass one emoji, a <:name:id> from list_emojis, or exactly "" to remove your override' };
|
|
7429
|
+
}
|
|
7430
|
+
let value;
|
|
7431
|
+
if (raw) {
|
|
7432
|
+
const { platform } = this.resolveStatusEmojisFor(instanceName);
|
|
7433
|
+
const problem = statusEmojiProblem(platform, status, raw);
|
|
7434
|
+
if (problem)
|
|
7435
|
+
return { error: `${problem}. Call list_emojis for what ${platform ?? "this channel"} accepts.` };
|
|
7436
|
+
const e = normalizeEmoji(raw);
|
|
7437
|
+
if (e.kind === "custom") {
|
|
7438
|
+
const worldId = this.getInstanceAdapterId(instanceName);
|
|
7439
|
+
if (!worldId)
|
|
7440
|
+
return { error: "no connection to check that server emoji against" };
|
|
7441
|
+
const listed = await this.listGuildEmojis(worldId);
|
|
7442
|
+
if (!listed.ok)
|
|
7443
|
+
return { error: `cannot check that server emoji: ${listed.error}` };
|
|
7444
|
+
const found = listed.emojis.find(g => g.id === e.id);
|
|
7445
|
+
if (!found?.available) {
|
|
7446
|
+
return { error: `${raw} is not a server emoji this bot can use${found ? " (Discord marks it unavailable)" : ""}. Call list_emojis for the ones it can.` };
|
|
7447
|
+
}
|
|
7448
|
+
value = customEmojiValue(found);
|
|
7449
|
+
}
|
|
7450
|
+
else {
|
|
7451
|
+
value = e.value;
|
|
7452
|
+
}
|
|
7453
|
+
}
|
|
7454
|
+
// Re-read after the validation above, which may have waited on Discord:
|
|
7455
|
+
// another set_persona_emoji or a Settings save can have changed another
|
|
7456
|
+
// status, or replaced this instance's config object, meanwhile. Change
|
|
7457
|
+
// only this one key on what is there now. Ownership must still hold:
|
|
7458
|
+
// removal during the await must not fall through to an inherited entry.
|
|
7459
|
+
const current = this.ownInstanceConfig(instanceName);
|
|
7460
|
+
if (!current)
|
|
7461
|
+
return { error: this.personaEmojiMissing(instanceName) };
|
|
7462
|
+
const map = { ...(current.status_emojis ?? {}) };
|
|
7463
|
+
if (value)
|
|
7464
|
+
map[status] = value;
|
|
7465
|
+
else
|
|
7466
|
+
delete map[status];
|
|
7467
|
+
if (Object.keys(map).length)
|
|
7468
|
+
current.status_emojis = map;
|
|
7469
|
+
else
|
|
7470
|
+
delete current.status_emojis;
|
|
7471
|
+
this.saveFleetConfig();
|
|
7472
|
+
this.logger.info({ instanceName, status, value: value ?? null }, value ? "Persona emoji set" : "Persona emoji cleared");
|
|
7473
|
+
const resolved = this.resolveStatusEmojisFor(instanceName);
|
|
7474
|
+
return { status, value: value ?? null, now: resolved[status], status_emojis: current.status_emojis ?? null };
|
|
7475
|
+
}
|
|
7476
|
+
personaEmojiMissing(instanceName) {
|
|
7477
|
+
return this.classicChannels?.getAll().some(c => c.instanceName === instanceName)
|
|
7478
|
+
? "ClassicBot instances have no per-instance status emojis; an operator sets the connection's in Settings"
|
|
7479
|
+
: `Instance '${instanceName}' not found`;
|
|
7480
|
+
}
|
|
6903
7481
|
/** What the instructions tell `instanceName` not to react with (its own status set). */
|
|
6904
7482
|
statusEmojiAvoidList(instanceName) {
|
|
6905
7483
|
return statusAvoidList(this.resolveStatusEmojisFor(instanceName));
|
|
@@ -6956,6 +7534,16 @@ export class FleetManager {
|
|
|
6956
7534
|
const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
|
|
6957
7535
|
return reactionForm(resolved.platform, resolved.received);
|
|
6958
7536
|
}
|
|
7537
|
+
/**
|
|
7538
|
+
* The stamp for an inbound photo / file a classic bot saved (#1080): the
|
|
7539
|
+
* instance's `status_emojis.photo|attachment`, then its connection's, then
|
|
7540
|
+
* the built-in (📸/📎, or 👌/👍 on Telegram — what these paths always stamped).
|
|
7541
|
+
* Anything else a saved attachment can be counts as a file.
|
|
7542
|
+
*/
|
|
7543
|
+
savedAttachmentReactionFor(instanceName, adapter, adapterId, kind) {
|
|
7544
|
+
const resolved = this.resolveStatusEmojisFor(instanceName, adapterId, adapter);
|
|
7545
|
+
return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
|
|
7546
|
+
}
|
|
6959
7547
|
reactMessageStatus(instanceName, chatId, messageId, status, threadId) {
|
|
6960
7548
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
6961
7549
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
@@ -9405,10 +9993,16 @@ export class FleetManager {
|
|
|
9405
9993
|
}
|
|
9406
9994
|
// The installer may only have added the binary to a profile PATH; a
|
|
9407
9995
|
// fresh login shell sees that, the fleet process's PATH may not.
|
|
9408
|
-
|
|
9996
|
+
const installedAt = this.locateBinaryOnLoginShell(info.binary);
|
|
9997
|
+
if (!installedAt) {
|
|
9409
9998
|
await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
|
|
9410
9999
|
return;
|
|
9411
10000
|
}
|
|
10001
|
+
// #1059: make it reachable from this process too. `/login` lists what
|
|
10002
|
+
// `which` finds on the fleet's own PATH, and instances resolve their
|
|
10003
|
+
// binary the same way, so a binary only a login shell could see was
|
|
10004
|
+
// installed, reported as verified, and then offered nowhere.
|
|
10005
|
+
this.adoptBinaryDirectory(installedAt, backend);
|
|
9412
10006
|
if (!LOGIN_FLOWS[backend]) {
|
|
9413
10007
|
await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
|
|
9414
10008
|
return;
|
|
@@ -9468,15 +10062,35 @@ export class FleetManager {
|
|
|
9468
10062
|
return t("install.cancelled", backend);
|
|
9469
10063
|
}
|
|
9470
10064
|
/** `command -v` on a login shell, so PATH additions from rc files count. */
|
|
9471
|
-
|
|
10065
|
+
/**
|
|
10066
|
+
* The absolute path a fresh login shell resolves `binary` to, or null. Only
|
|
10067
|
+
* a real executable file counts: `command -v` also answers with an alias
|
|
10068
|
+
* definition or a function name, neither of which a spawn could run.
|
|
10069
|
+
*/
|
|
10070
|
+
locateBinaryOnLoginShell(binary) {
|
|
9472
10071
|
try {
|
|
9473
|
-
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe" });
|
|
9474
|
-
|
|
10072
|
+
const result = spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" });
|
|
10073
|
+
if (result.status !== 0)
|
|
10074
|
+
return null;
|
|
10075
|
+
const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
|
|
10076
|
+
if (!isAbsolute(path))
|
|
10077
|
+
return null;
|
|
10078
|
+
accessSync(path, fsConstants.X_OK);
|
|
10079
|
+
return statSync(path).isFile() ? path : null;
|
|
9475
10080
|
}
|
|
9476
10081
|
catch {
|
|
9477
|
-
return
|
|
10082
|
+
return null;
|
|
9478
10083
|
}
|
|
9479
10084
|
}
|
|
10085
|
+
/** Put an installed binary's directory on the fleet's PATH (#1059), once. */
|
|
10086
|
+
adoptBinaryDirectory(binaryPath, backend) {
|
|
10087
|
+
const dir = dirname(binaryPath);
|
|
10088
|
+
const entries = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
10089
|
+
if (entries.includes(dir))
|
|
10090
|
+
return;
|
|
10091
|
+
process.env.PATH = [dir, ...entries].join(delimiter);
|
|
10092
|
+
this.logger.info({ backend, dir }, "Added the installed CLI's directory to the fleet PATH");
|
|
10093
|
+
}
|
|
9480
10094
|
/** "Sign in now?" button after a successful install. */
|
|
9481
10095
|
async handleInstallLoginConfirm(data, callbackAdapterId, receivingAdapter) {
|
|
9482
10096
|
const claimed = this.consumeNonceCallback(INSTALL_LOGIN_CALLBACK_PREFIX, /^install-login:([0-9a-f]+):(go|later)$/, data, callbackAdapterId, receivingAdapter);
|
|
@@ -10047,9 +10661,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10047
10661
|
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
10048
10662
|
const noMentionReactChatId = msg.threadId ?? msg.chatId;
|
|
10049
10663
|
if (reactAdapter && noMentionReactChatId && msg.messageId) {
|
|
10050
|
-
const emoji = msg.
|
|
10051
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10052
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10664
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
|
|
10053
10665
|
reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
|
|
10054
10666
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10055
10667
|
}
|
|
@@ -10080,9 +10692,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10080
10692
|
return;
|
|
10081
10693
|
// Attachments already saved at the top of the collab block.
|
|
10082
10694
|
if (saved && classicAdapter && collabReactChatId && msg.messageId) {
|
|
10083
|
-
const emoji = msg.
|
|
10084
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10085
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10695
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, classicAdapter, msg.adapterId, saved.kind);
|
|
10086
10696
|
classicAdapter.react(collabReactChatId, msg.messageId, emoji)
|
|
10087
10697
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10088
10698
|
}
|
|
@@ -10130,10 +10740,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10130
10740
|
const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
10131
10741
|
const reactChatId = msg.threadId ?? msg.chatId;
|
|
10132
10742
|
if (saved && reactAdapter && reactChatId && msg.messageId) {
|
|
10133
|
-
|
|
10134
|
-
const emoji = msg.source === "telegram"
|
|
10135
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10136
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10743
|
+
const emoji = this.savedAttachmentReactionFor(instanceName, reactAdapter, msg.adapterId, saved.kind);
|
|
10137
10744
|
reactAdapter.react(reactChatId, msg.messageId, emoji)
|
|
10138
10745
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10139
10746
|
}
|
|
@@ -10179,9 +10786,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10179
10786
|
classicMsgAdapter.react(reactChatId, msg.messageId, this.receivedReactionFor(instanceName, classicMsgAdapter, msg.adapterId))
|
|
10180
10787
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10181
10788
|
if (saved) {
|
|
10182
|
-
const savedEmoji = msg.
|
|
10183
|
-
? (saved.kind === "photo" ? "👌" : "👍")
|
|
10184
|
-
: (saved.kind === "photo" ? "📸" : "📎");
|
|
10789
|
+
const savedEmoji = this.savedAttachmentReactionFor(instanceName, classicMsgAdapter, msg.adapterId, saved.kind);
|
|
10185
10790
|
classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
|
|
10186
10791
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
10187
10792
|
}
|
|
@@ -10325,6 +10930,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10325
10930
|
catch { /* missing / stale / corrupt */ }
|
|
10326
10931
|
return null;
|
|
10327
10932
|
}
|
|
10933
|
+
/** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
|
|
10934
|
+
getBackendCliVersionSnapshot() {
|
|
10935
|
+
const snapshot = {};
|
|
10936
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10937
|
+
const cached = this.readCliEnv(backend);
|
|
10938
|
+
const needsRefresh = this.cliEnvNeedsRefresh(cached);
|
|
10939
|
+
const version = typeof cached?.version === "string" && cached.version.trim()
|
|
10940
|
+
? cached.version.trim()
|
|
10941
|
+
: null;
|
|
10942
|
+
snapshot[backend] = {
|
|
10943
|
+
version,
|
|
10944
|
+
probing: needsRefresh && version === null,
|
|
10945
|
+
};
|
|
10946
|
+
}
|
|
10947
|
+
return snapshot;
|
|
10948
|
+
}
|
|
10949
|
+
/** Called only after the user-visible sysinfo send/respond has settled. */
|
|
10950
|
+
refreshBackendCliVersions() {
|
|
10951
|
+
for (const backend of SYSINFO_BACKEND_IDS) {
|
|
10952
|
+
if (!this.cliEnvNeedsRefresh(this.readCliEnv(backend)))
|
|
10953
|
+
continue;
|
|
10954
|
+
void this.probeBackendBounded(backend, { nonBlocking: true }).catch(err => {
|
|
10955
|
+
this.logger.debug({ err, backend }, "Background CLI version refresh failed");
|
|
10956
|
+
});
|
|
10957
|
+
}
|
|
10958
|
+
}
|
|
10328
10959
|
/** True when a cached CLI env is old enough that `/model` should re-probe. */
|
|
10329
10960
|
cliEnvNeedsRefresh(env) {
|
|
10330
10961
|
return !env || typeof env.probedAt !== "number" || Date.now() - env.probedAt >= CLI_ENV_FRESH_MS;
|
|
@@ -10335,33 +10966,97 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10335
10966
|
* previous list, never stall the command that asked for it.
|
|
10336
10967
|
*/
|
|
10337
10968
|
async probeBackendBounded(backend, opts = {}) {
|
|
10338
|
-
// #725: single-flight per backend
|
|
10339
|
-
//
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
if (
|
|
10343
|
-
|
|
10344
|
-
|
|
10345
|
-
|
|
10346
|
-
|
|
10347
|
-
const work = this.probeBackend(backend, opts);
|
|
10969
|
+
// #725: single-flight per backend. Explicit vendor refreshes remain a
|
|
10970
|
+
// separate flight from an ordinary probe, while sysinfo can join either.
|
|
10971
|
+
const regularFlight = this.pendingCliEnvProbes.get(backend);
|
|
10972
|
+
const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
|
|
10973
|
+
if (opts.refreshVendorCatalog && vendorFlight)
|
|
10974
|
+
return vendorFlight;
|
|
10975
|
+
if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
|
|
10976
|
+
return vendorFlight ?? regularFlight;
|
|
10977
|
+
const worker = opts.nonBlocking ? this.startCliEnvProbeWorker(backend, opts) : undefined;
|
|
10978
|
+
const work = worker?.promise ?? this.probeBackend(backend, opts);
|
|
10348
10979
|
work.catch(() => { });
|
|
10349
10980
|
let timer;
|
|
10350
10981
|
const deadline = new Promise(resolve => {
|
|
10351
10982
|
timer = setTimeout(() => {
|
|
10983
|
+
worker?.terminate();
|
|
10352
10984
|
this.logger.warn({ backend, deadlineMs: CLI_ENV_PROBE_DEADLINE_MS }, "CLI env live probe exceeded its deadline — serving the cached model list");
|
|
10353
10985
|
resolve(null);
|
|
10354
10986
|
}, CLI_ENV_PROBE_DEADLINE_MS);
|
|
10355
10987
|
});
|
|
10356
10988
|
const bounded = Promise.race([work, deadline]).finally(() => {
|
|
10357
10989
|
clearTimeout(timer);
|
|
10358
|
-
|
|
10359
|
-
|
|
10990
|
+
const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
|
|
10991
|
+
if (flights.get(backend) === bounded)
|
|
10992
|
+
flights.delete(backend);
|
|
10360
10993
|
});
|
|
10361
|
-
|
|
10362
|
-
this.pendingCliEnvProbes.set(backend, bounded);
|
|
10994
|
+
(opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
|
|
10363
10995
|
return bounded;
|
|
10364
10996
|
}
|
|
10997
|
+
startCliEnvProbeWorker(backend, opts) {
|
|
10998
|
+
const workerData = {
|
|
10999
|
+
backend,
|
|
11000
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
11001
|
+
config: {
|
|
11002
|
+
workingDirectory: "",
|
|
11003
|
+
instanceDir: join(getAgendHome(), "cli-env"),
|
|
11004
|
+
instanceName: `probe-${backend}`,
|
|
11005
|
+
mcpServers: {},
|
|
11006
|
+
},
|
|
11007
|
+
refreshVendorCatalog: opts.refreshVendorCatalog === true,
|
|
11008
|
+
};
|
|
11009
|
+
// `tsx src/cli.ts` needs its TS module resolver inside the separate isolate.
|
|
11010
|
+
// Published builds load the compiled worker and require no tsx dependency.
|
|
11011
|
+
const worker = import.meta.url.endsWith(".ts")
|
|
11012
|
+
? new Worker(`const { workerData } = require("node:worker_threads"); import("tsx/esm/api").then(({ tsImport }) => tsImport(workerData.entry, workerData.entry));`, { eval: true, execArgv: [], workerData: { ...workerData, entry: new URL("./backend/cli-env-probe-worker.ts", import.meta.url).href } })
|
|
11013
|
+
: new Worker(new URL("./backend/cli-env-probe-worker.js", import.meta.url), { execArgv: [], workerData });
|
|
11014
|
+
let settled = false;
|
|
11015
|
+
let resolveProbe;
|
|
11016
|
+
const promise = new Promise(resolve => { resolveProbe = resolve; });
|
|
11017
|
+
const finish = (value) => {
|
|
11018
|
+
if (settled)
|
|
11019
|
+
return;
|
|
11020
|
+
settled = true;
|
|
11021
|
+
resolveProbe(value);
|
|
11022
|
+
void worker.terminate().catch(err => {
|
|
11023
|
+
this.logger.debug({ backend, err }, "CLI env worker termination failed");
|
|
11024
|
+
});
|
|
11025
|
+
};
|
|
11026
|
+
worker.once("message", (message) => {
|
|
11027
|
+
if (settled)
|
|
11028
|
+
return;
|
|
11029
|
+
if (!message.ok) {
|
|
11030
|
+
this.logger.warn({ backend, error: message.error }, "CLI env worker failed");
|
|
11031
|
+
finish(null);
|
|
11032
|
+
}
|
|
11033
|
+
else if (!message.result) {
|
|
11034
|
+
finish(null);
|
|
11035
|
+
}
|
|
11036
|
+
else {
|
|
11037
|
+
try {
|
|
11038
|
+
finish(this.persistCliEnvProbeResult(backend, message.result));
|
|
11039
|
+
}
|
|
11040
|
+
catch (err) {
|
|
11041
|
+
this.logger.warn({ backend, err }, "CLI env cache write failed");
|
|
11042
|
+
finish(null);
|
|
11043
|
+
}
|
|
11044
|
+
}
|
|
11045
|
+
});
|
|
11046
|
+
worker.once("error", err => {
|
|
11047
|
+
if (settled)
|
|
11048
|
+
return;
|
|
11049
|
+
this.logger.warn({ backend, err }, "CLI env worker crashed");
|
|
11050
|
+
finish(null);
|
|
11051
|
+
});
|
|
11052
|
+
worker.once("exit", code => {
|
|
11053
|
+
if (settled)
|
|
11054
|
+
return;
|
|
11055
|
+
this.logger.warn({ backend, code }, "CLI env worker exited without a result");
|
|
11056
|
+
finish(null);
|
|
11057
|
+
});
|
|
11058
|
+
return { promise, terminate: () => { finish(null); } };
|
|
11059
|
+
}
|
|
10365
11060
|
/**
|
|
10366
11061
|
* Resolve the effective model for a fleet or ClassicBot instance, plus where it
|
|
10367
11062
|
* came from. Single source of truth for `/model` and `/ctx` — precedence:
|
|
@@ -10428,35 +11123,39 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10428
11123
|
if (opts.refreshVendorCatalog && be.refreshModelCatalog)
|
|
10429
11124
|
await be.refreshModelCatalog();
|
|
10430
11125
|
const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
|
|
10431
|
-
|
|
10432
|
-
// An empty result must never overwrite a catalog we already have. Some
|
|
10433
|
-
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
10434
|
-
// returns [] — and writing that would blank the list for the whole 24h
|
|
10435
|
-
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
10436
|
-
// antigravity cache replaced by an empty one. Keep the known models and
|
|
10437
|
-
// let the fresher currentModel/version through.
|
|
10438
|
-
if (!env.models?.length) {
|
|
10439
|
-
const previous = this.readCliEnv(backend);
|
|
10440
|
-
if (previous?.models?.length)
|
|
10441
|
-
env.models = previous.models;
|
|
10442
|
-
}
|
|
10443
|
-
// Same protection for the extended catalog: one offline moment must not
|
|
10444
|
-
// blank a good account list for the whole cache TTL.
|
|
10445
|
-
if (!env.apiModels?.length) {
|
|
10446
|
-
const previous = this.readCliEnv(backend);
|
|
10447
|
-
if (previous?.apiModels?.length)
|
|
10448
|
-
env.apiModels = previous.apiModels;
|
|
10449
|
-
}
|
|
10450
|
-
const path = this.cliEnvPath(backend);
|
|
10451
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
10452
|
-
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
10453
|
-
return env;
|
|
11126
|
+
return this.persistCliEnvProbeResult(backend, probed);
|
|
10454
11127
|
}
|
|
10455
11128
|
catch (err) {
|
|
10456
11129
|
this.logger.warn({ err, backend }, "CLI env probe failed");
|
|
10457
11130
|
return null;
|
|
10458
11131
|
}
|
|
10459
11132
|
}
|
|
11133
|
+
/** Preserve the existing cache merge rules for both in-process and worker probes. */
|
|
11134
|
+
persistCliEnvProbeResult(backend, probed) {
|
|
11135
|
+
const env = { backend, probedAt: Date.now(), ...probed };
|
|
11136
|
+
// An empty result must never overwrite a catalog we already have. Some
|
|
11137
|
+
// probes hit the network (`agy models` fetches, 5s cap), so a slow moment
|
|
11138
|
+
// returns [] — and writing that would blank the list for the whole 24h
|
|
11139
|
+
// TTL, long after the CLI recovered. Observed live: a good 11-model
|
|
11140
|
+
// antigravity cache replaced by an empty one. Keep the known models and
|
|
11141
|
+
// let the fresher currentModel/version through.
|
|
11142
|
+
if (!env.models?.length) {
|
|
11143
|
+
const previous = this.readCliEnv(backend);
|
|
11144
|
+
if (previous?.models?.length)
|
|
11145
|
+
env.models = previous.models;
|
|
11146
|
+
}
|
|
11147
|
+
// Same protection for the extended catalog: one offline moment must not
|
|
11148
|
+
// blank a good account list for the whole cache TTL.
|
|
11149
|
+
if (!env.apiModels?.length) {
|
|
11150
|
+
const previous = this.readCliEnv(backend);
|
|
11151
|
+
if (previous?.apiModels?.length)
|
|
11152
|
+
env.apiModels = previous.apiModels;
|
|
11153
|
+
}
|
|
11154
|
+
const path = this.cliEnvPath(backend);
|
|
11155
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
11156
|
+
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
11157
|
+
return env;
|
|
11158
|
+
}
|
|
10460
11159
|
/** Background-probe every distinct backend in use at startup (non-blocking). */
|
|
10461
11160
|
probeCliEnvs() {
|
|
10462
11161
|
const backends = new Set();
|
|
@@ -11429,7 +12128,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11429
12128
|
await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
|
|
11430
12129
|
}
|
|
11431
12130
|
/** Start a classic channel instance with lightweight config */
|
|
11432
|
-
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter) {
|
|
12131
|
+
async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
|
|
11433
12132
|
if (this.daemons.has(instanceName))
|
|
11434
12133
|
return;
|
|
11435
12134
|
const workDir = join(getAgendHome(), "workspaces", instanceName);
|
|
@@ -11456,7 +12155,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11456
12155
|
...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
|
|
11457
12156
|
};
|
|
11458
12157
|
const topicMode = this.fleetConfig?.channel?.mode === "topic";
|
|
11459
|
-
await this.startInstance(instanceName, config, topicMode, "classic");
|
|
12158
|
+
await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
|
|
11460
12159
|
}
|
|
11461
12160
|
/** Handle /start slash command — register classic channel */
|
|
11462
12161
|
async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
|
|
@@ -11568,6 +12267,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11568
12267
|
clearInterval(this.replyObligationTimer);
|
|
11569
12268
|
this.replyObligationTimer = null;
|
|
11570
12269
|
}
|
|
12270
|
+
this.wakeCoordinator?.stop();
|
|
11571
12271
|
if (this.logRotateTimer) {
|
|
11572
12272
|
clearInterval(this.logRotateTimer);
|
|
11573
12273
|
this.logRotateTimer = null;
|
|
@@ -13629,7 +14329,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
13629
14329
|
this.logger.info({ name }, "Instance restart requested via HTTP");
|
|
13630
14330
|
(async () => {
|
|
13631
14331
|
try {
|
|
13632
|
-
await this.restartSingleInstance(name);
|
|
14332
|
+
await this.restartSingleInstance(name, { explicit: true });
|
|
13633
14333
|
this.logger.info({ name }, "Instance restarted");
|
|
13634
14334
|
this.emitSseEvent("status", this.getUiStatus());
|
|
13635
14335
|
res.writeHead(200);
|