@songsid/agend 2.1.2-beta.15 → 2.1.2-beta.17
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/access-path.js +15 -6
- package/dist/access-path.js.map +1 -1
- package/dist/backend/claude-code.d.ts +37 -0
- package/dist/backend/claude-code.js +43 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +22 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.d.ts +23 -0
- package/dist/backend/grok.js +25 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +20 -0
- package/dist/backend/kiro.js +59 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js +1 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +15 -0
- package/dist/channel/adapters/discord.js +91 -2
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +21 -0
- package/dist/channel/adapters/telegram.js +82 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +9 -1
- package/dist/channel/ipc-bridge.js +12 -3
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/ipc-timeouts.d.ts +40 -0
- package/dist/channel/ipc-timeouts.js +58 -0
- package/dist/channel/ipc-timeouts.js.map +1 -0
- package/dist/channel/mcp-server.js +25 -14
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +4 -2
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/reconnect-backoff.d.ts +17 -0
- package/dist/channel/reconnect-backoff.js +21 -0
- package/dist/channel/reconnect-backoff.js.map +1 -0
- package/dist/channel/types.d.ts +29 -0
- package/dist/classic-channel-manager.js +1 -5
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +129 -50
- package/dist/cli.js.map +1 -1
- package/dist/completion.d.ts +27 -0
- package/dist/completion.js +121 -0
- package/dist/completion.js.map +1 -0
- package/dist/config-validator.js +9 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/cost-guard.d.ts +3 -1
- package/dist/cost-guard.js +3 -1
- package/dist/cost-guard.js.map +1 -1
- package/dist/daemon.d.ts +164 -5
- package/dist/daemon.js +711 -290
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +31 -0
- package/dist/event-log.js +96 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +188 -1
- package/dist/fleet-manager.js +713 -61
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +1 -1
- package/dist/hang-detector.d.ts +19 -13
- package/dist/hang-detector.js +19 -49
- package/dist/hang-detector.js.map +1 -1
- package/dist/instance-lifecycle.js +25 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +2 -1
- package/dist/locale.js.map +1 -1
- package/dist/mcp-liveness.d.ts +21 -0
- package/dist/mcp-liveness.js +27 -0
- package/dist/mcp-liveness.js.map +1 -0
- package/dist/outbound-handlers.d.ts +7 -0
- package/dist/outbound-handlers.js +79 -5
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +7 -2
- package/dist/outbound-schemas.js +13 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-write-lock.d.ts +48 -0
- package/dist/pane-write-lock.js +73 -0
- package/dist/pane-write-lock.js.map +1 -0
- package/dist/process-memory.d.ts +31 -0
- package/dist/process-memory.js +79 -0
- package/dist/process-memory.js.map +1 -0
- package/dist/quickstart.js +17 -16
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.js +3 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/sd-notify.d.ts +27 -0
- package/dist/sd-notify.js +33 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/secret-file.d.ts +33 -0
- package/dist/secret-file.js +36 -0
- package/dist/secret-file.js.map +1 -0
- package/dist/setup-wizard.js +9 -7
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tmux-control.d.ts +58 -5
- package/dist/tmux-control.js +102 -14
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +31 -1
- package/dist/tmux-manager.js +40 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +30 -11
- package/dist/topic-commands.js +164 -66
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/ui/view.html +123 -2
- package/dist/usage/format-rich.d.ts +11 -0
- package/dist/usage/format-rich.js +108 -0
- package/dist/usage/format-rich.js.map +1 -0
- package/dist/usage/providers.d.ts +50 -0
- package/dist/usage/providers.js +611 -0
- package/dist/usage/providers.js.map +1 -0
- package/dist/usage/usage-api.d.ts +50 -0
- package/dist/usage/usage-api.js +111 -0
- package/dist/usage/usage-api.js.map +1 -0
- package/dist/view-api.js +1 -0
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.js +5 -2
- package/dist/web-api.js.map +1 -1
- package/package.json +4 -1
- package/dist/channel/tool-tracker.d.ts +0 -13
- package/dist/channel/tool-tracker.js +0 -58
- package/dist/channel/tool-tracker.js.map +0 -1
- package/dist/daemon-entry.d.ts +0 -1
- package/dist/daemon-entry.js +0 -30
- package/dist/daemon-entry.js.map +0 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -4,7 +4,8 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { join, dirname, basename } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
|
-
import { sdNotify } from "./sd-notify.js";
|
|
7
|
+
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
8
|
+
import { readFleetMemory } from "./process-memory.js";
|
|
8
9
|
import { isScalar, parseDocument } from "yaml";
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -21,12 +22,12 @@ import { IpcClient } from "./channel/ipc-bridge.js";
|
|
|
21
22
|
import { createAdapter } from "./channel/factory.js";
|
|
22
23
|
import { createBackend } from "./backend/factory.js";
|
|
23
24
|
import { isModelCompatible } from "./backend/types.js";
|
|
24
|
-
import { createLogger } from "./logger.js";
|
|
25
|
+
import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
25
26
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
26
27
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
27
28
|
import { Scheduler } from "./scheduler/index.js";
|
|
28
29
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
29
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext } from "./topic-commands.js";
|
|
30
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
|
|
30
31
|
import { DailySummary } from "./daily-summary.js";
|
|
31
32
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
32
33
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
@@ -38,6 +39,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
|
38
39
|
import { outboundHandlers } from "./outbound-handlers.js";
|
|
39
40
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
40
41
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
42
|
+
import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
|
|
41
43
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
42
44
|
import { setLocale, detectLocale, t } from "./locale.js";
|
|
43
45
|
import { handleAgentRequest } from "./agent-endpoint.js";
|
|
@@ -83,6 +85,37 @@ const CANCEL_BTN_MAX_RETRIES = 3;
|
|
|
83
85
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
84
86
|
* reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
|
|
85
87
|
const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
88
|
+
/**
|
|
89
|
+
* How often the cancel button's text is refreshed with elapsed working time.
|
|
90
|
+
*
|
|
91
|
+
* One edit per working instance per interval — at 60s that is trivial for both
|
|
92
|
+
* platforms' rate limits, and it reads as a live counter rather than a stale
|
|
93
|
+
* snapshot. Nothing new is posted, so the channel is never spammed: there is
|
|
94
|
+
* exactly one progress message per turn, and it is the cancel button itself.
|
|
95
|
+
*/
|
|
96
|
+
const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
|
|
97
|
+
/** Elapsed time is only shown once work has clearly outlasted a quick answer. */
|
|
98
|
+
const PROGRESS_MIN_ELAPSED_MS = 2 * 60_000;
|
|
99
|
+
/** How much of a tool summary the progress line will show before eliding. */
|
|
100
|
+
const PROGRESS_ACTIVITY_MAX_CHARS = 48;
|
|
101
|
+
/**
|
|
102
|
+
* Emoji AgEnD itself stamps on messages as machine signals: the delivery-status
|
|
103
|
+
* ladder (⏳ queued, 👀 delivered, ✅ confirmed, ❌ failed) and the 🫡 ack. They
|
|
104
|
+
* are indicators, not opinions, so they never belong in the reactions queue —
|
|
105
|
+
* regardless of who they arrive from, because the adapter-level bot filter can
|
|
106
|
+
* miss (Discord partial users may not carry the `bot` flag). The cost is that a
|
|
107
|
+
* human genuinely reacting ✅ is also dropped; with four bot-stamped meanings
|
|
108
|
+
* already attached to these glyphs, that reading is ambiguous anyway.
|
|
109
|
+
*/
|
|
110
|
+
const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌", "🫡"]);
|
|
111
|
+
/**
|
|
112
|
+
* How long a delivery waits out a disconnected instance IPC before giving up.
|
|
113
|
+
*
|
|
114
|
+
* Sized for a daemon restart (socket close → respawn → CLI ready), which is the
|
|
115
|
+
* event this exists for. Past it the delivery fails loudly as it always did.
|
|
116
|
+
*/
|
|
117
|
+
const IPC_RECONNECT_GRACE_MS = 30_000;
|
|
118
|
+
const IPC_RECONNECT_POLL_MS = 250;
|
|
86
119
|
const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
|
|
87
120
|
const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
|
|
88
121
|
const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
|
|
@@ -93,7 +126,7 @@ export class FleetManager {
|
|
|
93
126
|
static sighupHandlerInstalled = false;
|
|
94
127
|
children = new Map();
|
|
95
128
|
lifecycle;
|
|
96
|
-
/**
|
|
129
|
+
/** Live view of lifecycle.daemons — used throughout; not deprecated. */
|
|
97
130
|
get daemons() { return this.lifecycle.daemons; }
|
|
98
131
|
fleetConfig = null;
|
|
99
132
|
rawFleetConfig = {};
|
|
@@ -151,6 +184,10 @@ export class FleetManager {
|
|
|
151
184
|
// reply, on cancel, or when a newer button supersedes it for the same
|
|
152
185
|
// instance. Per-button tracking means a failed delete never strands a button.
|
|
153
186
|
cancelButtons = new Map();
|
|
187
|
+
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
188
|
+
instanceActivity = new Map();
|
|
189
|
+
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
190
|
+
ipcWaitTails = new Map();
|
|
154
191
|
// Last user message delivered to each instance — used to react ✅ on completion.
|
|
155
192
|
lastInboundMsg = new Map();
|
|
156
193
|
topicArchiver;
|
|
@@ -176,6 +213,10 @@ export class FleetManager {
|
|
|
176
213
|
healthServer = null;
|
|
177
214
|
healthPortRetried = false;
|
|
178
215
|
updateCheckTimer = null;
|
|
216
|
+
eventLogPruneTimer = null;
|
|
217
|
+
logRotateTimer = null;
|
|
218
|
+
/** Days of event/activity history to keep. */
|
|
219
|
+
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
179
220
|
watchdogTimer = null;
|
|
180
221
|
startedAt = 0;
|
|
181
222
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
@@ -214,7 +255,13 @@ export class FleetManager {
|
|
|
214
255
|
}
|
|
215
256
|
this.reloadPending = false;
|
|
216
257
|
this.reconcileInFlight = this.reconcileInstances()
|
|
217
|
-
.catch(err =>
|
|
258
|
+
.catch(err => {
|
|
259
|
+
// Almost always a YAML parse error. Log-only meant the user edited
|
|
260
|
+
// fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
|
|
261
|
+
this.logger.error({ err }, "SIGHUP config reload failed");
|
|
262
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
263
|
+
this.notifyFleetError(`⚠️ fleet.yaml reload FAILED — ${message}\nThe previous configuration is still running.`);
|
|
264
|
+
})
|
|
218
265
|
.finally(() => {
|
|
219
266
|
this.reconcileInFlight = null;
|
|
220
267
|
if (this.reloadPending && this.startupComplete) {
|
|
@@ -477,8 +524,12 @@ export class FleetManager {
|
|
|
477
524
|
// warm_cap: a fresh transition into idle may free this instance for eviction,
|
|
478
525
|
// or (more usefully) reveal that the fleet is now over cap. Only fire on the
|
|
479
526
|
// edge into idle, not on every idle heartbeat.
|
|
480
|
-
if (state === "idle" && previous?.state !== "idle")
|
|
527
|
+
if (state === "idle" && previous?.state !== "idle") {
|
|
481
528
|
this.enforceWarmCap();
|
|
529
|
+
// The turn is genuinely over — retire the cancel/progress button now rather
|
|
530
|
+
// than waiting for the 5-minute idle backstop to notice.
|
|
531
|
+
this.retireInstanceButtons(name);
|
|
532
|
+
}
|
|
482
533
|
}
|
|
483
534
|
cacheInstanceProcessStatus(name, status) {
|
|
484
535
|
if (status === "running") {
|
|
@@ -583,12 +634,70 @@ export class FleetManager {
|
|
|
583
634
|
if (!idle) {
|
|
584
635
|
this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
|
|
585
636
|
}
|
|
586
|
-
|
|
587
|
-
if (!ipc?.connected)
|
|
588
|
-
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
589
|
-
ipc.send(payload);
|
|
637
|
+
await this.sendWhenConnected(instanceName, payload);
|
|
590
638
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
591
639
|
}
|
|
640
|
+
/**
|
|
641
|
+
* Hand a payload to an instance's IPC, waiting out a *transient* disconnect.
|
|
642
|
+
*
|
|
643
|
+
* A daemon that is restarting — `/restart`, crash recovery, a model switch —
|
|
644
|
+
* drops its socket for a few seconds. Any message arriving in that window used
|
|
645
|
+
* to fail instantly: the caller logged a warning, put ❌ on the user's message,
|
|
646
|
+
* and the message was gone. The user had to notice the ❌ and retype it. That is
|
|
647
|
+
* the "instance 訊息不容易掉" goal failing on the most predictable event there is.
|
|
648
|
+
*
|
|
649
|
+
* The wait is bounded. If the instance is genuinely down, this still throws and
|
|
650
|
+
* the ❌ still appears — just for a real failure rather than a restart.
|
|
651
|
+
*
|
|
652
|
+
* Ordering is preserved by serialising behind any waiter already queued for this
|
|
653
|
+
* instance, *including* when the socket happens to be up: otherwise a message
|
|
654
|
+
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
655
|
+
*/
|
|
656
|
+
async sendWhenConnected(instanceName, payload) {
|
|
657
|
+
const queued = this.ipcWaitTails.get(instanceName);
|
|
658
|
+
if (!queued) {
|
|
659
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
660
|
+
if (ipc?.connected && ipc.send(payload))
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const attempt = (queued ?? Promise.resolve())
|
|
664
|
+
.catch(() => { })
|
|
665
|
+
.then(() => this.sendAfterIpcReturns(instanceName, payload));
|
|
666
|
+
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
667
|
+
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
668
|
+
const tail = attempt.catch(() => { });
|
|
669
|
+
this.ipcWaitTails.set(instanceName, tail);
|
|
670
|
+
try {
|
|
671
|
+
await attempt;
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
// Only the last waiter clears the chain; while a queue is still draining the
|
|
675
|
+
// map must keep pointing at it or ordering is lost.
|
|
676
|
+
if (this.ipcWaitTails.get(instanceName) === tail) {
|
|
677
|
+
this.ipcWaitTails.delete(instanceName);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
682
|
+
async sendAfterIpcReturns(instanceName, payload) {
|
|
683
|
+
const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
684
|
+
let warned = false;
|
|
685
|
+
for (;;) {
|
|
686
|
+
// Re-read every round: a reconnect replaces the IpcClient object entirely,
|
|
687
|
+
// so a cached reference would stay dead forever.
|
|
688
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
689
|
+
if (ipc?.connected && ipc.send(payload))
|
|
690
|
+
return;
|
|
691
|
+
if (Date.now() >= deadline) {
|
|
692
|
+
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
693
|
+
}
|
|
694
|
+
if (!warned) {
|
|
695
|
+
warned = true;
|
|
696
|
+
this.logger.info({ instanceName }, "Instance IPC is down — holding delivery until it reconnects");
|
|
697
|
+
}
|
|
698
|
+
await new Promise(resolve => setTimeout(resolve, IPC_RECONNECT_POLL_MS));
|
|
699
|
+
}
|
|
700
|
+
}
|
|
592
701
|
/** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
|
|
593
702
|
async deliverToInstance(instanceName, payload, options = {}) {
|
|
594
703
|
const meta = payload.meta && typeof payload.meta === "object"
|
|
@@ -604,10 +713,7 @@ export class FleetManager {
|
|
|
604
713
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
605
714
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
606
715
|
}
|
|
607
|
-
|
|
608
|
-
if (!ipc?.connected)
|
|
609
|
-
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
610
|
-
ipc.send(payload);
|
|
716
|
+
await this.sendWhenConnected(instanceName, payload);
|
|
611
717
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
612
718
|
// working must not trust the stale idle snapshot from before the send.
|
|
613
719
|
this.lastDeliveryAt.set(instanceName, Date.now());
|
|
@@ -853,7 +959,6 @@ export class FleetManager {
|
|
|
853
959
|
this.configPath = configPath;
|
|
854
960
|
this.loadEnvFile();
|
|
855
961
|
// Rotate fleet.log if oversized (before any logging)
|
|
856
|
-
const { rotateLogIfNeeded } = await import("./logger.js");
|
|
857
962
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
858
963
|
const fleet = this.loadConfig(configPath);
|
|
859
964
|
setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
|
|
@@ -897,7 +1002,7 @@ export class FleetManager {
|
|
|
897
1002
|
}
|
|
898
1003
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
899
1004
|
writeFileSync(pidPath, String(process.pid), "utf-8");
|
|
900
|
-
this.eventLog =
|
|
1005
|
+
this.eventLog = this.openEventLog();
|
|
901
1006
|
// Initialize classic channel manager. The primary adapter (channels[0])
|
|
902
1007
|
// migrates legacy single-bot entries and names without a suffix. Classic
|
|
903
1008
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
@@ -986,11 +1091,10 @@ export class FleetManager {
|
|
|
986
1091
|
// Rotate fleet.log daily too (besides the startup size check above), so a
|
|
987
1092
|
// long-running fleet doesn't accumulate an unbounded log.
|
|
988
1093
|
rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
|
|
989
|
-
// Instance output.log is pipe-pane (TUI ANSI). Daemon health ticks
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
1094
|
+
// Instance output.log is pipe-pane (TUI ANSI). Daemon health ticks rotate a
|
|
1095
|
+
// running instance's own log; this sweep is the safety net for every other
|
|
1096
|
+
// kind. One implementation, so the two cannot cover different sets.
|
|
1097
|
+
this.rotateAllInstanceLogs();
|
|
994
1098
|
}, () => {
|
|
995
1099
|
const instances = Object.keys(this.fleetConfig?.instances ?? {});
|
|
996
1100
|
const costMap = new Map();
|
|
@@ -1130,9 +1234,29 @@ export class FleetManager {
|
|
|
1130
1234
|
}
|
|
1131
1235
|
}
|
|
1132
1236
|
}
|
|
1133
|
-
//
|
|
1134
|
-
|
|
1237
|
+
// The systemd watchdog answers exactly one question: is this process still
|
|
1238
|
+
// turning its event loop? Pinging from a timer proves that, and after the
|
|
1239
|
+
// blocking child-process calls were made async it is a meaningful signal —
|
|
1240
|
+
// a deadlocked or frozen fleet stops pinging and systemd restarts it.
|
|
1241
|
+
//
|
|
1242
|
+
// It deliberately does NOT gate on fleet health. "No adapter connected" or
|
|
1243
|
+
// "an instance crashed" must not kill the process: the fleet would be restarted
|
|
1244
|
+
// into the same broken state, and a user who has legitimately stopped every
|
|
1245
|
+
// instance would get a restart loop. Those conditions surface through /health
|
|
1246
|
+
// (which now returns 503) and through the General-topic notifications instead.
|
|
1135
1247
|
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
1248
|
+
// EventLog.prune() existed but was never called, so `events` and `activity`
|
|
1249
|
+
// grew without bound for the life of the install. Prune once at startup and
|
|
1250
|
+
// daily after that; the timer is unref'd so it never holds the loop open.
|
|
1251
|
+
this.pruneEventLog();
|
|
1252
|
+
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
1253
|
+
this.eventLogPruneTimer.unref?.();
|
|
1254
|
+
// Same shape for pipe-pane logs, and for the same reason: the only sweep that
|
|
1255
|
+
// covered them lived inside the daily-summary callback, so it did not run at
|
|
1256
|
+
// all when summaries were off.
|
|
1257
|
+
this.rotateAllInstanceLogs();
|
|
1258
|
+
this.logRotateTimer = setInterval(() => this.rotateAllInstanceLogs(), 24 * 60 * 60_000);
|
|
1259
|
+
this.logRotateTimer.unref?.();
|
|
1136
1260
|
// Phase 2: Start remaining instances with staggered concurrency
|
|
1137
1261
|
if (others.length > 0) {
|
|
1138
1262
|
await this.startInstancesWithConcurrency(others, topicMode);
|
|
@@ -1268,6 +1392,15 @@ export class FleetManager {
|
|
|
1268
1392
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
1269
1393
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
1270
1394
|
this.finishStartup();
|
|
1395
|
+
// Tell systemd we are ready only now. This used to fire right after the
|
|
1396
|
+
// generals started — before adapters, classic instances, topic creation and the
|
|
1397
|
+
// health server — so `systemctl start` returned success while the fleet was
|
|
1398
|
+
// still deaf: no path existed for a user message to arrive.
|
|
1399
|
+
sdNotify("READY=1");
|
|
1400
|
+
const health = this.getFleetHealth();
|
|
1401
|
+
if (health.status !== "ok") {
|
|
1402
|
+
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
1403
|
+
}
|
|
1271
1404
|
}
|
|
1272
1405
|
/**
|
|
1273
1406
|
* Delete inbox files older than retentionDays (by mtime). Cleans the shared
|
|
@@ -1384,6 +1517,67 @@ export class FleetManager {
|
|
|
1384
1517
|
getAdapterStates() {
|
|
1385
1518
|
return this.adapterState;
|
|
1386
1519
|
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Real, checkable fleet health for `/health` and the operator.
|
|
1522
|
+
*
|
|
1523
|
+
* `status` is:
|
|
1524
|
+
* - `ok` — at least one adapter connected and every configured instance
|
|
1525
|
+
* that should be running is running
|
|
1526
|
+
* - `degraded` — reachable, but something the operator should look at (an
|
|
1527
|
+
* adapter retrying, an instance crashed or stopped)
|
|
1528
|
+
* - `down` — the fleet cannot do its job: no adapter is connected, so no
|
|
1529
|
+
* message can arrive or be answered
|
|
1530
|
+
*
|
|
1531
|
+
* Deliberately does NOT gate the systemd watchdog — see the comment at the
|
|
1532
|
+
* WATCHDOG timer for why.
|
|
1533
|
+
*/
|
|
1534
|
+
getFleetHealth() {
|
|
1535
|
+
const names = Object.keys(this.fleetConfig?.instances ?? {});
|
|
1536
|
+
const counts = { configured: names.length, running: 0, crashed: 0, paused: 0, stopped: 0 };
|
|
1537
|
+
for (const name of names) {
|
|
1538
|
+
const state = this.getInstanceStatus(name);
|
|
1539
|
+
if (state === "running")
|
|
1540
|
+
counts.running++;
|
|
1541
|
+
else if (state === "crashed")
|
|
1542
|
+
counts.crashed++;
|
|
1543
|
+
else if (state === "paused")
|
|
1544
|
+
counts.paused++;
|
|
1545
|
+
else
|
|
1546
|
+
counts.stopped++;
|
|
1547
|
+
}
|
|
1548
|
+
const states = {};
|
|
1549
|
+
let connected = 0;
|
|
1550
|
+
for (const [id, state] of this.adapterState) {
|
|
1551
|
+
states[id] = state.status;
|
|
1552
|
+
if (state.status === "connected")
|
|
1553
|
+
connected++;
|
|
1554
|
+
}
|
|
1555
|
+
const problems = [];
|
|
1556
|
+
if (this.adapterState.size > 0 && connected === 0)
|
|
1557
|
+
problems.push("no channel adapter is connected");
|
|
1558
|
+
if (counts.crashed > 0)
|
|
1559
|
+
problems.push(`${counts.crashed} instance(s) crashed`);
|
|
1560
|
+
for (const [id, state] of this.adapterState) {
|
|
1561
|
+
if (state.status !== "connected")
|
|
1562
|
+
problems.push(`adapter ${id} is ${state.status}`);
|
|
1563
|
+
}
|
|
1564
|
+
if (!this.startupComplete)
|
|
1565
|
+
problems.push("startup has not completed");
|
|
1566
|
+
// "down" is reserved for "cannot receive or answer a message at all". A fleet
|
|
1567
|
+
// with adapters configured but none connected is exactly that.
|
|
1568
|
+
const status = this.adapterState.size > 0 && connected === 0
|
|
1569
|
+
? "down"
|
|
1570
|
+
: problems.length > 0 ? "degraded" : "ok";
|
|
1571
|
+
return {
|
|
1572
|
+
status,
|
|
1573
|
+
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
1574
|
+
instances: counts,
|
|
1575
|
+
adapters: { total: this.adapterState.size, connected, states },
|
|
1576
|
+
startupComplete: this.startupComplete,
|
|
1577
|
+
memory: readFleetMemory(),
|
|
1578
|
+
problems,
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1387
1581
|
/** Start the primary adapter (backward-compatible, sets this.adapter) */
|
|
1388
1582
|
async startSingleAdapter(fleet, channelConfig) {
|
|
1389
1583
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
@@ -1410,6 +1604,9 @@ export class FleetManager {
|
|
|
1410
1604
|
this.adapter.on("message", safeHandler(async (msg) => {
|
|
1411
1605
|
await this.handleInboundMessage(msg);
|
|
1412
1606
|
}, this.logger, "adapter.message"));
|
|
1607
|
+
this.adapter.on("reaction", safeHandler(async (r) => {
|
|
1608
|
+
await this.handleInboundReaction(r);
|
|
1609
|
+
}, this.logger, "adapter.reaction"));
|
|
1413
1610
|
this.adapter.on("callback_query", safeHandler(async (data) => {
|
|
1414
1611
|
if (await this.handleClassicBackendSelection(data))
|
|
1415
1612
|
return;
|
|
@@ -1584,19 +1781,29 @@ export class FleetManager {
|
|
|
1584
1781
|
await data.respond(t("not_authorized"));
|
|
1585
1782
|
return;
|
|
1586
1783
|
}
|
|
1784
|
+
await data.respond(await this.runBackendDoctor());
|
|
1785
|
+
}
|
|
1786
|
+
else if (data.command === "usage") {
|
|
1787
|
+
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
1788
|
+
// the adapter defers non-chat commands that way — so it never spams the
|
|
1789
|
+
// channel either way.
|
|
1587
1790
|
try {
|
|
1588
|
-
const {
|
|
1589
|
-
const
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
await data.respond(clean || "No output");
|
|
1791
|
+
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
1792
|
+
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
1793
|
+
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
1794
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
1593
1795
|
}
|
|
1594
1796
|
catch (err) {
|
|
1595
|
-
|
|
1596
|
-
await data.respond(output);
|
|
1797
|
+
await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
|
|
1597
1798
|
}
|
|
1598
1799
|
}
|
|
1599
1800
|
else if (data.command === "status") {
|
|
1801
|
+
// Admin-gated (like the topic path): the merged table shows every
|
|
1802
|
+
// instance's cost and IPC health.
|
|
1803
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
1804
|
+
await data.respond(t("cmd.admin_required", "/status"));
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1600
1807
|
const text = await this.topicCommands.getStatusText();
|
|
1601
1808
|
await data.respond(text);
|
|
1602
1809
|
}
|
|
@@ -1705,6 +1912,9 @@ export class FleetManager {
|
|
|
1705
1912
|
adapter.on("message", safeHandler(async (msg) => {
|
|
1706
1913
|
await this.handleInboundMessage(msg);
|
|
1707
1914
|
}, this.logger, `adapter[${adapterId}].message`));
|
|
1915
|
+
adapter.on("reaction", safeHandler(async (r) => {
|
|
1916
|
+
await this.handleInboundReaction(r);
|
|
1917
|
+
}, this.logger, `adapter[${adapterId}].reaction`));
|
|
1708
1918
|
adapter.on("callback_query", safeHandler(async (data) => {
|
|
1709
1919
|
if (await this.handleClassicBackendSelection(data))
|
|
1710
1920
|
return;
|
|
@@ -1866,19 +2076,29 @@ export class FleetManager {
|
|
|
1866
2076
|
await data.respond(t("not_authorized"));
|
|
1867
2077
|
return;
|
|
1868
2078
|
}
|
|
2079
|
+
await data.respond(await this.runBackendDoctor());
|
|
2080
|
+
}
|
|
2081
|
+
else if (data.command === "usage") {
|
|
2082
|
+
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
2083
|
+
// the adapter defers non-chat commands that way — so it never spams the
|
|
2084
|
+
// channel either way.
|
|
1869
2085
|
try {
|
|
1870
|
-
const {
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
await data.respond(clean || "No output");
|
|
2086
|
+
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2087
|
+
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2088
|
+
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2089
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot()));
|
|
1875
2090
|
}
|
|
1876
2091
|
catch (err) {
|
|
1877
|
-
|
|
1878
|
-
await data.respond(output);
|
|
2092
|
+
await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
|
|
1879
2093
|
}
|
|
1880
2094
|
}
|
|
1881
2095
|
else if (data.command === "status") {
|
|
2096
|
+
// Admin-gated (like the topic path): the merged table shows every
|
|
2097
|
+
// instance's cost and IPC health.
|
|
2098
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
2099
|
+
await data.respond(t("cmd.admin_required", "/status"));
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
1882
2102
|
const text = await this.topicCommands.getStatusText();
|
|
1883
2103
|
await data.respond(text);
|
|
1884
2104
|
}
|
|
@@ -2034,6 +2254,9 @@ export class FleetManager {
|
|
|
2034
2254
|
else if (msg.type === "instance_process_state") {
|
|
2035
2255
|
this.cacheInstanceProcessStatus(name, msg.status);
|
|
2036
2256
|
}
|
|
2257
|
+
else if (msg.type === "instance_activity") {
|
|
2258
|
+
this.cacheInstanceActivity(name, msg.activity);
|
|
2259
|
+
}
|
|
2037
2260
|
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
2038
2261
|
this.cacheInstanceExecutionState(name, msg);
|
|
2039
2262
|
if (msg.type === "instance_state_response") {
|
|
@@ -2104,9 +2327,22 @@ export class FleetManager {
|
|
|
2104
2327
|
if (existsSync(windowIdPath)) {
|
|
2105
2328
|
const windowId = readFileSync(windowIdPath, "utf-8").trim();
|
|
2106
2329
|
if (windowId) {
|
|
2330
|
+
// Async with an explicit timeout: this was execSync with NO timeout at
|
|
2331
|
+
// all, so a wedged tmux server blocked the whole fleet event loop
|
|
2332
|
+
// indefinitely — while we were here to diagnose a lost connection.
|
|
2333
|
+
// A timeout is also the correct signal: an unresponsive tmux server
|
|
2334
|
+
// means we cannot verify the pane, which is treated as dead (the same
|
|
2335
|
+
// conclusion the old code reached only by throwing).
|
|
2107
2336
|
try {
|
|
2108
|
-
const {
|
|
2109
|
-
|
|
2337
|
+
const { execFile } = await import("node:child_process");
|
|
2338
|
+
const { promisify } = await import("node:util");
|
|
2339
|
+
const { getTmuxSocketName } = await import("./paths.js");
|
|
2340
|
+
// Honour socket isolation: without -L this queried the user's default
|
|
2341
|
+
// tmux server instead of the fleet's, so under a custom AGEND_HOME the
|
|
2342
|
+
// check was meaningless (it reported every pane dead).
|
|
2343
|
+
const socket = getTmuxSocketName();
|
|
2344
|
+
const args = socket ? ["-L", socket, "list-panes", "-t", windowId] : ["list-panes", "-t", windowId];
|
|
2345
|
+
await promisify(execFile)("tmux", args, { timeout: 5_000 });
|
|
2110
2346
|
}
|
|
2111
2347
|
catch {
|
|
2112
2348
|
// Pane dead — respawn
|
|
@@ -2127,6 +2363,12 @@ export class FleetManager {
|
|
|
2127
2363
|
if (this.adapterRestarting.has(id))
|
|
2128
2364
|
return;
|
|
2129
2365
|
this.adapterRestarting.add(id);
|
|
2366
|
+
// Reflect reality in adapterState throughout. This loop used to leave the state
|
|
2367
|
+
// untouched, so getAdapterStates() — and therefore /health and the dashboard —
|
|
2368
|
+
// kept reporting "connected" for an adapter that had been down for hours. An
|
|
2369
|
+
// adapter's true status was simply not knowable from inside the process.
|
|
2370
|
+
const previous = this.adapterState.get(id);
|
|
2371
|
+
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: previous?.lastError });
|
|
2130
2372
|
try {
|
|
2131
2373
|
for (let attempt = 1;; attempt++) {
|
|
2132
2374
|
if (this.ipcStoppingInstances.has("__fleet_stopping__"))
|
|
@@ -2139,9 +2381,16 @@ export class FleetManager {
|
|
|
2139
2381
|
await adapter.stop().catch(() => { });
|
|
2140
2382
|
await adapter.start();
|
|
2141
2383
|
this.logger.info({ id, attempt }, "Adapter restarted successfully");
|
|
2384
|
+
this.adapterState.set(id, { status: "connected", retryCount: 0 });
|
|
2142
2385
|
return;
|
|
2143
2386
|
}
|
|
2144
|
-
catch {
|
|
2387
|
+
catch (err) {
|
|
2388
|
+
this.adapterState.set(id, {
|
|
2389
|
+
status: "retrying",
|
|
2390
|
+
retryCount: attempt,
|
|
2391
|
+
lastError: err?.message ?? String(err),
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2145
2394
|
if (attempt % 10 === 0) {
|
|
2146
2395
|
this.logger.warn({ id, attempt }, "Adapter restart still failing");
|
|
2147
2396
|
}
|
|
@@ -2177,6 +2426,49 @@ export class FleetManager {
|
|
|
2177
2426
|
}
|
|
2178
2427
|
return generals[0];
|
|
2179
2428
|
}
|
|
2429
|
+
/**
|
|
2430
|
+
* A user reacted to one of the bot's messages (#408).
|
|
2431
|
+
*
|
|
2432
|
+
* A reaction is context, not a message (#432, reworking #413): it never triggers
|
|
2433
|
+
* an agent turn and never wakes anything. It is queued in the event log and rides
|
|
2434
|
+
* into the instance's NEXT real message as one compact leading line —
|
|
2435
|
+
* `[Recent reactions: 👍×2 from hanhanv]` — after which it is marked consumed.
|
|
2436
|
+
* No pending reactions → no line → zero context spent, which is the common case.
|
|
2437
|
+
*/
|
|
2438
|
+
async handleInboundReaction(r) {
|
|
2439
|
+
const instanceName = this.resolveSlashTarget(r.threadId ?? r.chatId, r.adapterId);
|
|
2440
|
+
if (!instanceName) {
|
|
2441
|
+
this.logger.debug({ emoji: r.emoji, chatId: r.chatId }, "Reaction in an unrouted channel — ignoring");
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
if (DELIVERY_STATUS_EMOJIS.has(r.emoji)) {
|
|
2445
|
+
this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
|
|
2449
|
+
if (r.action === "add") {
|
|
2450
|
+
this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
2451
|
+
}
|
|
2452
|
+
else {
|
|
2453
|
+
// Withdrawn before anyone saw it → it never happened. See removeReaction.
|
|
2454
|
+
this.eventLog?.removeReaction(instanceName, r.messageId, r.username, r.emoji);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* The queued-reaction summary for an instance's next real message, or {} when
|
|
2459
|
+
* nothing is pending (the common case must add zero context). The consume
|
|
2460
|
+
* callback is separate from the fetch so reactions are only marked once the
|
|
2461
|
+
* message actually went out — a failed delivery keeps them queued.
|
|
2462
|
+
*/
|
|
2463
|
+
pendingReactionsMeta(instanceName) {
|
|
2464
|
+
const pending = this.eventLog?.pendingReactions(instanceName);
|
|
2465
|
+
if (!pending)
|
|
2466
|
+
return { meta: {}, consume: () => { } };
|
|
2467
|
+
return {
|
|
2468
|
+
meta: { pending_reactions: pending.summary },
|
|
2469
|
+
consume: () => this.eventLog?.markReactionsConsumed(instanceName, pending.maxId),
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2180
2472
|
async handleInboundMessage(msg) {
|
|
2181
2473
|
const threadId = msg.threadId || undefined;
|
|
2182
2474
|
this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
|
|
@@ -2514,6 +2806,7 @@ export class FleetManager {
|
|
|
2514
2806
|
}
|
|
2515
2807
|
this.warnIfRateLimited(generalInstance, msg);
|
|
2516
2808
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
2809
|
+
const generalReactions = this.pendingReactionsMeta(generalInstance);
|
|
2517
2810
|
try {
|
|
2518
2811
|
await this.deliverToInstance(generalInstance, {
|
|
2519
2812
|
type: "fleet_inbound",
|
|
@@ -2529,9 +2822,11 @@ export class FleetManager {
|
|
|
2529
2822
|
adapter_id: msg.adapterId,
|
|
2530
2823
|
source: msg.source,
|
|
2531
2824
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2825
|
+
...generalReactions.meta,
|
|
2532
2826
|
...extraMeta,
|
|
2533
2827
|
},
|
|
2534
2828
|
});
|
|
2829
|
+
generalReactions.consume();
|
|
2535
2830
|
this.lastInboundUser.set(generalInstance, msg.username);
|
|
2536
2831
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
2537
2832
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
|
|
@@ -2607,6 +2902,7 @@ export class FleetManager {
|
|
|
2607
2902
|
this.setTopicIcon(instanceName, "blue");
|
|
2608
2903
|
this.warnIfRateLimited(instanceName, msg);
|
|
2609
2904
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
2905
|
+
const reactions = this.pendingReactionsMeta(instanceName);
|
|
2610
2906
|
try {
|
|
2611
2907
|
await this.deliverToInstance(instanceName, {
|
|
2612
2908
|
type: "fleet_inbound",
|
|
@@ -2622,9 +2918,13 @@ export class FleetManager {
|
|
|
2622
2918
|
adapter_id: msg.adapterId,
|
|
2623
2919
|
source: msg.source,
|
|
2624
2920
|
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2921
|
+
...reactions.meta,
|
|
2625
2922
|
...extraMeta,
|
|
2626
2923
|
},
|
|
2627
2924
|
});
|
|
2925
|
+
// Only after the message actually went out. A failed delivery keeps the
|
|
2926
|
+
// reactions queued for the retry / the next message.
|
|
2927
|
+
reactions.consume();
|
|
2628
2928
|
}
|
|
2629
2929
|
catch (err) {
|
|
2630
2930
|
this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
|
|
@@ -2716,8 +3016,18 @@ export class FleetManager {
|
|
|
2716
3016
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
2717
3017
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
2718
3018
|
if (tool === "reply") {
|
|
2719
|
-
//
|
|
2720
|
-
|
|
3019
|
+
// A reply is NOT proof the turn is over: on multi-step work an agent
|
|
3020
|
+
// replies ("starting…") and keeps going for many minutes. Retiring the
|
|
3021
|
+
// button here left the channel looking idle with no way to cancel and no
|
|
3022
|
+
// sign anything was happening (#410). Idle state owns retirement now; if the
|
|
3023
|
+
// instance is still working, move the button below the new reply so it stays
|
|
3024
|
+
// the last thing in the channel.
|
|
3025
|
+
if (this.getInstanceIdle(instanceName)) {
|
|
3026
|
+
this.clearCancelButton(instanceName);
|
|
3027
|
+
}
|
|
3028
|
+
else {
|
|
3029
|
+
void this.sendCancelButton(instanceName);
|
|
3030
|
+
}
|
|
2721
3031
|
this.reactDone(instanceName);
|
|
2722
3032
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
2723
3033
|
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
@@ -3348,6 +3658,9 @@ export class FleetManager {
|
|
|
3348
3658
|
}
|
|
3349
3659
|
}
|
|
3350
3660
|
async removeInstance(name) {
|
|
3661
|
+
// Drop cached pane context — the map is keyed by instance name and nothing
|
|
3662
|
+
// else evicted deleted entries, so it grew for the life of the process.
|
|
3663
|
+
forgetInstanceContext(name);
|
|
3351
3664
|
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
3352
3665
|
const config = this.fleetConfig?.instances[name];
|
|
3353
3666
|
if (this.scheduler && config?.topic_id) {
|
|
@@ -3453,6 +3766,171 @@ export class FleetManager {
|
|
|
3453
3766
|
this.collabInstances.add(instanceName);
|
|
3454
3767
|
return true;
|
|
3455
3768
|
}
|
|
3769
|
+
/**
|
|
3770
|
+
* Open the event log, tolerating a corrupt file.
|
|
3771
|
+
*
|
|
3772
|
+
* `events.db` holds history only — event rows and the activity feed. Nothing the
|
|
3773
|
+
* fleet needs to run depends on it, and every consumer already uses
|
|
3774
|
+
* `this.eventLog?.`. An unguarded `new EventLog(...)` here meant a corrupt or
|
|
3775
|
+
* unreadable history file (a truncated WAL after a hard kill, a full disk)
|
|
3776
|
+
* threw during startAll and the WHOLE FLEET FAILED TO BOOT — trading every
|
|
3777
|
+
* running agent for a file whose only job is reporting.
|
|
3778
|
+
*
|
|
3779
|
+
* So: try, move a bad file aside and retry once with a fresh one, and if even
|
|
3780
|
+
* that fails carry on without an event log.
|
|
3781
|
+
*/
|
|
3782
|
+
/**
|
|
3783
|
+
* Run `agend backend doctor` for the fleet's default backend and return its
|
|
3784
|
+
* cleaned output.
|
|
3785
|
+
*
|
|
3786
|
+
* Async on purpose: this was `execSync` with a 30s timeout, reachable by any
|
|
3787
|
+
* allowlisted user through `/doctor`. While it ran, the entire fleet event loop
|
|
3788
|
+
* was frozen — no IPC, no adapter, no message delivery, no health responses,
|
|
3789
|
+
* and critically no WATCHDOG ping, so a slow doctor could push past
|
|
3790
|
+
* WatchdogSec and have systemd SIGABRT the fleet.
|
|
3791
|
+
*/
|
|
3792
|
+
async runBackendDoctor() {
|
|
3793
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
3794
|
+
const backend = this.fleetConfig?.defaults?.backend || "claude-code";
|
|
3795
|
+
try {
|
|
3796
|
+
const { execFile } = await import("node:child_process");
|
|
3797
|
+
const { promisify } = await import("node:util");
|
|
3798
|
+
// execFile with an argv array — no shell, so the backend name cannot be
|
|
3799
|
+
// interpreted as a command even if config is malformed.
|
|
3800
|
+
const { stdout } = await promisify(execFile)("agend", ["backend", "doctor", backend], {
|
|
3801
|
+
timeout: 30_000,
|
|
3802
|
+
encoding: "utf-8",
|
|
3803
|
+
});
|
|
3804
|
+
return stripAnsi(stdout) || "No output";
|
|
3805
|
+
}
|
|
3806
|
+
catch (err) {
|
|
3807
|
+
const e = err;
|
|
3808
|
+
return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
3812
|
+
/**
|
|
3813
|
+
* Cap every instance's pipe-pane log, walking the instances **directory** rather
|
|
3814
|
+
* than the config.
|
|
3815
|
+
*
|
|
3816
|
+
* A running instance rotates its own log on each health tick, so the ones that
|
|
3817
|
+
* need this are the ones nothing else looks at:
|
|
3818
|
+
*
|
|
3819
|
+
* - deleted instances, whose directory outlives the config entry. Nothing ever
|
|
3820
|
+
* touched these again. On the machine this was found on, one held 122 MB and
|
|
3821
|
+
* another 74 MB, out of 622 MB of pipe-pane logs in total.
|
|
3822
|
+
* - classic instances, which live in classicChannels, not fleetConfig.instances,
|
|
3823
|
+
* and so were never in the old config-driven loop at all.
|
|
3824
|
+
* - stopped instances, which have no health tick running.
|
|
3825
|
+
*
|
|
3826
|
+
* pipe-pane writes raw TUI output, so a wedged splash screen can emit ANSI frames
|
|
3827
|
+
* at animation rate. Unbounded growth here fills the disk, which takes the whole
|
|
3828
|
+
* fleet down rather than one instance.
|
|
3829
|
+
*/
|
|
3830
|
+
rotateAllInstanceLogs() {
|
|
3831
|
+
const root = join(this.dataDir, "instances");
|
|
3832
|
+
let entries;
|
|
3833
|
+
try {
|
|
3834
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
3835
|
+
}
|
|
3836
|
+
catch {
|
|
3837
|
+
return; // no instances directory yet
|
|
3838
|
+
}
|
|
3839
|
+
for (const entry of entries) {
|
|
3840
|
+
if (!entry.isDirectory())
|
|
3841
|
+
continue;
|
|
3842
|
+
// rotateLogIfNeeded is already best-effort and returns early on a missing
|
|
3843
|
+
// file, so a directory without a pipe-pane log costs one stat.
|
|
3844
|
+
rotateLogIfNeeded(join(root, entry.name, "output.log"));
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
pruneEventLog() {
|
|
3848
|
+
try {
|
|
3849
|
+
this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
|
|
3850
|
+
}
|
|
3851
|
+
catch (err) {
|
|
3852
|
+
this.logger.warn({ err }, "Event log prune failed");
|
|
3853
|
+
}
|
|
3854
|
+
}
|
|
3855
|
+
openEventLog() {
|
|
3856
|
+
const dbPath = join(this.dataDir, "events.db");
|
|
3857
|
+
try {
|
|
3858
|
+
return new EventLog(dbPath);
|
|
3859
|
+
}
|
|
3860
|
+
catch (err) {
|
|
3861
|
+
this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
|
|
3862
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3863
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
3864
|
+
try {
|
|
3865
|
+
renameSync(`${dbPath}${suffix}`, `${dbPath}${suffix}.corrupt-${stamp}`);
|
|
3866
|
+
}
|
|
3867
|
+
catch { /* may not exist */ }
|
|
3868
|
+
}
|
|
3869
|
+
try {
|
|
3870
|
+
return new EventLog(dbPath);
|
|
3871
|
+
}
|
|
3872
|
+
catch (retryErr) {
|
|
3873
|
+
// History is worth losing; a fleet that won't start is not.
|
|
3874
|
+
this.logger.error({ err: retryErr, dbPath }, "Could not open a fresh events.db — continuing without event logging");
|
|
3875
|
+
return null;
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
/**
|
|
3880
|
+
* Report a fleet-level fault (not attributable to one instance) to the General
|
|
3881
|
+
* topic, so the operator learns about it without reading daemon.log.
|
|
3882
|
+
*
|
|
3883
|
+
* Throttled per distinct message: an unhandled rejection typically comes from a
|
|
3884
|
+
* loop (a poller, a repeating timer), and one channel message per occurrence
|
|
3885
|
+
* would bury the topic — which is worse than silence. First occurrence goes out
|
|
3886
|
+
* immediately, repeats are suppressed for THROTTLE_MS and then re-sent with a
|
|
3887
|
+
* count.
|
|
3888
|
+
*
|
|
3889
|
+
* The log line is written by the caller regardless: if every adapter is down,
|
|
3890
|
+
* the only notification path is the one that is broken.
|
|
3891
|
+
*/
|
|
3892
|
+
notifyFleetError(text) {
|
|
3893
|
+
const now = Date.now();
|
|
3894
|
+
const key = text.slice(0, 200);
|
|
3895
|
+
const seen = this.fleetErrorNotices.get(key);
|
|
3896
|
+
if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
|
|
3897
|
+
seen.suppressed++;
|
|
3898
|
+
return;
|
|
3899
|
+
}
|
|
3900
|
+
const suppressed = seen?.suppressed ?? 0;
|
|
3901
|
+
this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
|
|
3902
|
+
// Bound the map: it is keyed by message text, and a message with a varying
|
|
3903
|
+
// suffix (a path, an id) would otherwise grow it without limit.
|
|
3904
|
+
if (this.fleetErrorNotices.size > 100) {
|
|
3905
|
+
const oldest = this.fleetErrorNotices.keys().next().value;
|
|
3906
|
+
if (oldest !== undefined)
|
|
3907
|
+
this.fleetErrorNotices.delete(oldest);
|
|
3908
|
+
}
|
|
3909
|
+
const body = suppressed > 0
|
|
3910
|
+
? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
|
|
3911
|
+
: text;
|
|
3912
|
+
// Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
|
|
3913
|
+
// and a fleet-level fault is exactly when the General may be down. The topic
|
|
3914
|
+
// itself still exists, and notifyInstanceTopic only needs adapter + group +
|
|
3915
|
+
// topic_id to post into it.
|
|
3916
|
+
const general = Object.entries(this.fleetConfig?.instances ?? {})
|
|
3917
|
+
.find(([, config]) => config.general_topic === true)?.[0];
|
|
3918
|
+
if (general) {
|
|
3919
|
+
this.notifyInstanceTopic(general, body);
|
|
3920
|
+
return;
|
|
3921
|
+
}
|
|
3922
|
+
// No General instance — fall back to the primary channel's group.
|
|
3923
|
+
const channelCfg = this.getChannelConfig();
|
|
3924
|
+
const groupId = channelCfg?.group_id;
|
|
3925
|
+
if (this.adapter && groupId) {
|
|
3926
|
+
this.adapter.sendText(String(groupId), body)
|
|
3927
|
+
.catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
|
|
3928
|
+
return;
|
|
3929
|
+
}
|
|
3930
|
+
this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
|
|
3931
|
+
}
|
|
3932
|
+
static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
|
|
3933
|
+
fleetErrorNotices = new Map();
|
|
3456
3934
|
notifyInstanceTopic(instanceName, text, extraOpts) {
|
|
3457
3935
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
3458
3936
|
if (!adapter)
|
|
@@ -3582,7 +4060,12 @@ export class FleetManager {
|
|
|
3582
4060
|
threadId: sent.threadId ?? threadId,
|
|
3583
4061
|
correlationId,
|
|
3584
4062
|
retryCount: 0,
|
|
4063
|
+
// Elapsed time is measured from when this button was posted — i.e. from
|
|
4064
|
+
// when the work was handed over — not from the pane's working transition,
|
|
4065
|
+
// which resets if the CLI blips idle mid-turn.
|
|
4066
|
+
startedAt: Date.now(),
|
|
3585
4067
|
};
|
|
4068
|
+
this.startProgressTicker(entry);
|
|
3586
4069
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
3587
4070
|
// button. Covers turns that end without hitting a clear trigger (reply /
|
|
3588
4071
|
// cancel / correlation). Cleared in discardButton when the entry is removed.
|
|
@@ -3603,6 +4086,97 @@ export class FleetManager {
|
|
|
3603
4086
|
this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
|
|
3604
4087
|
}
|
|
3605
4088
|
}
|
|
4089
|
+
/**
|
|
4090
|
+
* The cancel button's text for a given elapsed time.
|
|
4091
|
+
*
|
|
4092
|
+
* Below the threshold it keeps the original wording, so a normal quick answer
|
|
4093
|
+
* looks exactly as it did before. Past it, the button doubles as the live
|
|
4094
|
+
* progress indicator (#409) — the channel showed nothing at all during long work,
|
|
4095
|
+
* and once the agent had replied once there was no sign it was still going.
|
|
4096
|
+
*/
|
|
4097
|
+
static progressText(elapsedMs, activity) {
|
|
4098
|
+
if (elapsedMs < PROGRESS_MIN_ELAPSED_MS)
|
|
4099
|
+
return "👀 處理中…";
|
|
4100
|
+
const totalSeconds = Math.floor(elapsedMs / 1000);
|
|
4101
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
4102
|
+
const seconds = totalSeconds % 60;
|
|
4103
|
+
const elapsed = minutes >= 60
|
|
4104
|
+
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
|
4105
|
+
: `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
4106
|
+
const detail = FleetManager.sanitizeActivity(activity);
|
|
4107
|
+
return detail
|
|
4108
|
+
? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
|
|
4109
|
+
: `⏳ 處理中… (已進行 ${elapsed})`;
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* Make a tool summary safe to paste into a channel message.
|
|
4113
|
+
*
|
|
4114
|
+
* The text is agent-controlled (it is built from tool inputs — file paths,
|
|
4115
|
+
* shell commands), so it gets flattened to one line, capped, and stripped of
|
|
4116
|
+
* the two Discord mass-mention triggers. Neither channel renders it with a
|
|
4117
|
+
* parse mode, so no markup escaping is needed beyond that.
|
|
4118
|
+
*/
|
|
4119
|
+
static sanitizeActivity(activity) {
|
|
4120
|
+
if (!activity)
|
|
4121
|
+
return null;
|
|
4122
|
+
const flat = activity.replace(/\s+/g, " ").replace(/@(everyone|here)/g, "@$1").trim();
|
|
4123
|
+
if (!flat)
|
|
4124
|
+
return null;
|
|
4125
|
+
return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
|
|
4126
|
+
? `${flat.slice(0, PROGRESS_ACTIVITY_MAX_CHARS - 1)}…`
|
|
4127
|
+
: flat;
|
|
4128
|
+
}
|
|
4129
|
+
/**
|
|
4130
|
+
* Remember what an instance is currently doing, for the progress line.
|
|
4131
|
+
*
|
|
4132
|
+
* Best-effort by design: only backends that expose a live activity feed report
|
|
4133
|
+
* anything, and the progress line simply omits the detail for the rest. It is
|
|
4134
|
+
* never used to decide anything — purely what the user is shown.
|
|
4135
|
+
*/
|
|
4136
|
+
cacheInstanceActivity(name, activity) {
|
|
4137
|
+
if (activity)
|
|
4138
|
+
this.instanceActivity.set(name, activity);
|
|
4139
|
+
else
|
|
4140
|
+
this.instanceActivity.delete(name);
|
|
4141
|
+
}
|
|
4142
|
+
/**
|
|
4143
|
+
* Refresh the button's text in place while the instance keeps working.
|
|
4144
|
+
*
|
|
4145
|
+
* Uses `editAlert`, NOT `editMessage`: on Telegram the latter omits reply_markup,
|
|
4146
|
+
* and the Bot API treats that as "clear the keyboard" — so editing with it would
|
|
4147
|
+
* delete the very cancel button this is trying to keep alive.
|
|
4148
|
+
*/
|
|
4149
|
+
startProgressTicker(entry) {
|
|
4150
|
+
entry.progressTimer = setInterval(() => {
|
|
4151
|
+
if (!this.cancelButtons.has(entry.messageId)) {
|
|
4152
|
+
clearInterval(entry.progressTimer);
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
// Idle means the turn ended; the idle-edge handler retires the button.
|
|
4156
|
+
if (this.getInstanceIdle(entry.instanceName))
|
|
4157
|
+
return;
|
|
4158
|
+
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
|
|
4159
|
+
if (text === entry.lastProgressText)
|
|
4160
|
+
return; // nothing changed — skip the API call
|
|
4161
|
+
const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
|
|
4162
|
+
if (!adapter?.editAlert)
|
|
4163
|
+
return;
|
|
4164
|
+
entry.lastProgressText = text;
|
|
4165
|
+
adapter.editAlert(entry.chatId, entry.messageId, {
|
|
4166
|
+
type: "cancel",
|
|
4167
|
+
instanceName: entry.instanceName,
|
|
4168
|
+
message: text,
|
|
4169
|
+
choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
|
|
4170
|
+
}, entry.threadId ? { threadId: entry.threadId } : undefined)
|
|
4171
|
+
.catch(err => {
|
|
4172
|
+
// A failed progress edit must never escalate: the button still works and
|
|
4173
|
+
// the next tick retries. Common causes are a deleted message or a
|
|
4174
|
+
// rate limit.
|
|
4175
|
+
this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
|
|
4176
|
+
});
|
|
4177
|
+
}, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4178
|
+
entry.progressTimer.unref?.();
|
|
4179
|
+
}
|
|
3606
4180
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
3607
4181
|
retireInstanceButtons(instanceName) {
|
|
3608
4182
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -3634,6 +4208,8 @@ export class FleetManager {
|
|
|
3634
4208
|
clearTimeout(entry.retryTimer);
|
|
3635
4209
|
if (entry.idleCheckTimer)
|
|
3636
4210
|
clearInterval(entry.idleCheckTimer);
|
|
4211
|
+
if (entry.progressTimer)
|
|
4212
|
+
clearInterval(entry.progressTimer);
|
|
3637
4213
|
this.cancelButtons.delete(entry.messageId);
|
|
3638
4214
|
}
|
|
3639
4215
|
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
@@ -4299,6 +4875,12 @@ When users create specialized instances, suggest these configurations:
|
|
|
4299
4875
|
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
4300
4876
|
}
|
|
4301
4877
|
}
|
|
4878
|
+
// Classic channels queue reactions like everyone else (#432 stored them, but
|
|
4879
|
+
// this path never attached them — reactions in a ClassicBot channel went into
|
|
4880
|
+
// the DB and were never seen again). Same contract as the topic paths:
|
|
4881
|
+
// consumed only after the delivery succeeded.
|
|
4882
|
+
const reactions = this.pendingReactionsMeta(instanceName);
|
|
4883
|
+
Object.assign(meta, reactions.meta);
|
|
4302
4884
|
try {
|
|
4303
4885
|
await this.deliverToInstance(instanceName, {
|
|
4304
4886
|
type: "fleet_inbound",
|
|
@@ -4306,6 +4888,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4306
4888
|
targetSession: instanceName,
|
|
4307
4889
|
meta,
|
|
4308
4890
|
});
|
|
4891
|
+
reactions.consume();
|
|
4309
4892
|
}
|
|
4310
4893
|
catch (err) {
|
|
4311
4894
|
this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
|
|
@@ -4580,9 +5163,19 @@ When users create specialized instances, suggest these configurations:
|
|
|
4580
5163
|
// DC path: respond immediately with progress text
|
|
4581
5164
|
await pending.respond(progressText).catch(() => { });
|
|
4582
5165
|
}
|
|
4583
|
-
// Apply model in background — don't await here (keeps callback handler fast)
|
|
5166
|
+
// Apply model in background — don't await here (keeps callback handler fast).
|
|
5167
|
+
// Guarded: applyModel() restarts the instance, and an unguarded rejection here
|
|
5168
|
+
// meant a user picking from the /model menu could take the whole fleet down.
|
|
5169
|
+
// On failure the user gets told, rather than the click silently doing nothing.
|
|
4584
5170
|
void (async () => {
|
|
4585
|
-
|
|
5171
|
+
let result;
|
|
5172
|
+
try {
|
|
5173
|
+
result = await this.applyModel(pending.instanceName, model);
|
|
5174
|
+
}
|
|
5175
|
+
catch (err) {
|
|
5176
|
+
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
5177
|
+
result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
5178
|
+
}
|
|
4586
5179
|
if (pending.adapter && pending.adapterChatId) {
|
|
4587
5180
|
if (progressMsgId) {
|
|
4588
5181
|
pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
|
|
@@ -4862,11 +5455,24 @@ When users create specialized instances, suggest these configurations:
|
|
|
4862
5455
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
4863
5456
|
return t("classic.stopped");
|
|
4864
5457
|
}
|
|
4865
|
-
|
|
5458
|
+
/**
|
|
5459
|
+
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
5460
|
+
* uncaughtException path calls this too, so overlapping runs were possible —
|
|
5461
|
+
* each snapshotting the daemon map and calling stop() on the same daemons
|
|
5462
|
+
* concurrently. Deliberately NOT `async`, so callers receive the same promise
|
|
5463
|
+
* object rather than a fresh wrapper around it. The latch clears when the run
|
|
5464
|
+
* settles, so a later genuine stop (after a restart) still does the work.
|
|
5465
|
+
*/
|
|
5466
|
+
stopAll() {
|
|
5467
|
+
this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
|
|
5468
|
+
return this.stopAllInFlight;
|
|
5469
|
+
}
|
|
5470
|
+
stopAllInFlight = null;
|
|
5471
|
+
async doStopAll() {
|
|
4866
5472
|
this.startupComplete = false;
|
|
4867
5473
|
this.reloadPending = false;
|
|
4868
5474
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
4869
|
-
|
|
5475
|
+
sdNotifyBlocking("STOPPING=1");
|
|
4870
5476
|
if (this.watchdogTimer) {
|
|
4871
5477
|
clearInterval(this.watchdogTimer);
|
|
4872
5478
|
this.watchdogTimer = null;
|
|
@@ -4886,6 +5492,26 @@ When users create specialized instances, suggest these configurations:
|
|
|
4886
5492
|
clearInterval(this.updateCheckTimer);
|
|
4887
5493
|
this.updateCheckTimer = null;
|
|
4888
5494
|
}
|
|
5495
|
+
if (this.eventLogPruneTimer) {
|
|
5496
|
+
clearInterval(this.eventLogPruneTimer);
|
|
5497
|
+
this.eventLogPruneTimer = null;
|
|
5498
|
+
}
|
|
5499
|
+
if (this.logRotateTimer) {
|
|
5500
|
+
clearInterval(this.logRotateTimer);
|
|
5501
|
+
this.logRotateTimer = null;
|
|
5502
|
+
}
|
|
5503
|
+
// Cancel-button timers were never cleared here. The idle-check interval is not
|
|
5504
|
+
// unref'd, so it held the event loop open past shutdown and kept retrying
|
|
5505
|
+
// deletes against an adapter that was already gone.
|
|
5506
|
+
for (const entry of [...this.cancelButtons.values()]) {
|
|
5507
|
+
if (entry.retryTimer)
|
|
5508
|
+
clearTimeout(entry.retryTimer);
|
|
5509
|
+
if (entry.idleCheckTimer)
|
|
5510
|
+
clearInterval(entry.idleCheckTimer);
|
|
5511
|
+
if (entry.progressTimer)
|
|
5512
|
+
clearInterval(entry.progressTimer);
|
|
5513
|
+
}
|
|
5514
|
+
this.cancelButtons.clear();
|
|
4889
5515
|
if (this.topicCleanupTimer) {
|
|
4890
5516
|
clearInterval(this.topicCleanupTimer);
|
|
4891
5517
|
this.topicCleanupTimer = null;
|
|
@@ -5104,6 +5730,15 @@ When users create specialized instances, suggest these configurations:
|
|
|
5104
5730
|
removedRatio,
|
|
5105
5731
|
validationErrors: validation.errors,
|
|
5106
5732
|
}, "Refusing unsafe fleet config reload; running configuration was kept");
|
|
5733
|
+
// Tell the operator. A silently ignored config edit is the most confusing
|
|
5734
|
+
// possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
|
|
5735
|
+
// with no explanation anywhere they are looking.
|
|
5736
|
+
const why = !validation.valid
|
|
5737
|
+
? `validation failed:\n${validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n")}`
|
|
5738
|
+
: unsafeEmpty
|
|
5739
|
+
? `it removed every instance (${oldCount} → 0)`
|
|
5740
|
+
: `it removed more than half the instances (${oldCount} → ${newCount})`;
|
|
5741
|
+
this.notifyFleetError(`⚠️ fleet.yaml reload REJECTED — ${why}\nThe previous configuration is still running.`);
|
|
5107
5742
|
return;
|
|
5108
5743
|
}
|
|
5109
5744
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -5303,10 +5938,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
5303
5938
|
// ── Update check ────────────────────────────────────────────────────
|
|
5304
5939
|
async checkForUpdates() {
|
|
5305
5940
|
try {
|
|
5306
|
-
|
|
5941
|
+
// Both npm lookups are async: as execSync they froze the fleet event loop for
|
|
5942
|
+
// up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
|
|
5943
|
+
// past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
|
|
5944
|
+
// for a background version check.
|
|
5945
|
+
const { execFile } = await import("node:child_process");
|
|
5946
|
+
const { promisify } = await import("node:util");
|
|
5947
|
+
const execFileP = promisify(execFile);
|
|
5948
|
+
const npmVersion = async (spec) => {
|
|
5949
|
+
const { stdout } = await execFileP("npm", ["view", spec, "version"], { timeout: 15_000 });
|
|
5950
|
+
return stdout.toString().trim();
|
|
5951
|
+
};
|
|
5307
5952
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
5308
5953
|
const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
|
|
5309
|
-
const latest =
|
|
5954
|
+
const latest = await npmVersion("@songsid/agend");
|
|
5310
5955
|
let target = latest;
|
|
5311
5956
|
if (currentVersion.includes("-beta")) {
|
|
5312
5957
|
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
@@ -5314,7 +5959,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5314
5959
|
// of beta/latest is the newest.
|
|
5315
5960
|
let beta = "";
|
|
5316
5961
|
try {
|
|
5317
|
-
beta =
|
|
5962
|
+
beta = await npmVersion("@songsid/agend@beta");
|
|
5318
5963
|
}
|
|
5319
5964
|
catch { /* no beta tag */ }
|
|
5320
5965
|
target = beta || latest;
|
|
@@ -5421,6 +6066,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
5421
6066
|
// /view routes accept the read-only view.token (or web.token) and do
|
|
5422
6067
|
// their own per-method auth in view-api.ts — skip the web-token gate.
|
|
5423
6068
|
}
|
|
6069
|
+
else if (isUsagePath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
|
|
6070
|
+
// /api/ai-usage is read-only GET data for the /view Usage panel — open
|
|
6071
|
+
// like the other /view data routes (usage-api.ts rejects non-GET).
|
|
6072
|
+
}
|
|
5424
6073
|
else {
|
|
5425
6074
|
// All other endpoints require a valid token (query ?token= or X-Agend-Token header).
|
|
5426
6075
|
// /ui/* will also re-check in web-api.ts, which is harmless.
|
|
@@ -5435,15 +6084,13 @@ When users create specialized instances, suggest these configurations:
|
|
|
5435
6084
|
}
|
|
5436
6085
|
}
|
|
5437
6086
|
if (req.method === "GET" && req.url === "/health") {
|
|
5438
|
-
const
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
5446
|
-
}));
|
|
6087
|
+
const health = this.getFleetHealth();
|
|
6088
|
+
// 503 when the fleet cannot do its job, so an external monitor sees it.
|
|
6089
|
+
// This used to always answer 200 "ok" with a count of CONFIGURED instances,
|
|
6090
|
+
// so every agent could be dead and every adapter down and it still looked
|
|
6091
|
+
// green.
|
|
6092
|
+
res.writeHead(health.status === "ok" ? 200 : 503);
|
|
6093
|
+
res.end(JSON.stringify(health));
|
|
5447
6094
|
return;
|
|
5448
6095
|
}
|
|
5449
6096
|
if (req.method === "GET" && req.url === "/status") {
|
|
@@ -5579,7 +6226,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
5579
6226
|
res.writeHead(500);
|
|
5580
6227
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
5581
6228
|
}
|
|
5582
|
-
|
|
6229
|
+
// The inner catch can itself throw (writeHead after a successful
|
|
6230
|
+
// writeHead is ERR_HTTP_HEADERS_SENT), and that rejection escapes the
|
|
6231
|
+
// IIFE. Same for the two handlers below.
|
|
6232
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP start handler failed"));
|
|
5583
6233
|
return;
|
|
5584
6234
|
}
|
|
5585
6235
|
// Instance restart (immediate, no idle wait)
|
|
@@ -5600,7 +6250,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5600
6250
|
res.writeHead(status);
|
|
5601
6251
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
5602
6252
|
}
|
|
5603
|
-
})();
|
|
6253
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP restart handler failed"));
|
|
5604
6254
|
return;
|
|
5605
6255
|
}
|
|
5606
6256
|
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
@@ -5623,7 +6273,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5623
6273
|
res.writeHead(500);
|
|
5624
6274
|
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
5625
6275
|
}
|
|
5626
|
-
})();
|
|
6276
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP stop handler failed"));
|
|
5627
6277
|
return;
|
|
5628
6278
|
}
|
|
5629
6279
|
// ── Agent CLI endpoint ─────
|
|
@@ -5635,6 +6285,8 @@ When users create specialized instances, suggest these configurations:
|
|
|
5635
6285
|
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
5636
6286
|
if (handleViewRequest(req, res, url, this))
|
|
5637
6287
|
return;
|
|
6288
|
+
if (handleUsageRequest(req, res, url, this))
|
|
6289
|
+
return;
|
|
5638
6290
|
if (handleSettingsRequest(req, res, url, this))
|
|
5639
6291
|
return;
|
|
5640
6292
|
if (handleWebRequest(req, res, url, this))
|