@suzuke/agend 1.2.0 → 1.3.0
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/daemon.d.ts +1 -1
- package/dist/daemon.js +3 -3
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +21 -0
- package/dist/fleet-context.js.map +1 -1
- package/dist/fleet-manager.d.ts +27 -23
- package/dist/fleet-manager.js +80 -589
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +46 -0
- package/dist/instance-lifecycle.js +283 -0
- package/dist/instance-lifecycle.js.map +1 -0
- package/dist/outbound-handlers.d.ts +30 -0
- package/dist/outbound-handlers.js +206 -0
- package/dist/outbound-handlers.js.map +1 -0
- package/dist/routing-engine.d.ts +22 -0
- package/dist/routing-engine.js +44 -0
- package/dist/routing-engine.js.map +1 -0
- package/dist/statusline-watcher.d.ts +32 -0
- package/dist/statusline-watcher.js +64 -0
- package/dist/statusline-watcher.js.map +1 -0
- package/dist/topic-archiver.d.ts +33 -0
- package/dist/topic-archiver.js +69 -0
- package/dist/topic-archiver.js.map +1 -0
- package/dist/topic-commands.d.ts +1 -0
- package/dist/topic-commands.js +33 -0
- package/dist/topic-commands.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import { access } from "node:fs/promises";
|
|
3
2
|
import { createServer } from "node:http";
|
|
4
|
-
import { join, dirname
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
5
4
|
import { homedir } from "node:os";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
6
|
import yaml from "js-yaml";
|
|
@@ -20,11 +19,16 @@ import { processAttachments } from "./channel/attachment-handler.js";
|
|
|
20
19
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
21
20
|
import { Scheduler } from "./scheduler/index.js";
|
|
22
21
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
23
|
-
import { TopicCommands
|
|
22
|
+
import { TopicCommands } from "./topic-commands.js";
|
|
24
23
|
import { DailySummary } from "./daily-summary.js";
|
|
25
24
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
26
25
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
27
26
|
import { safeHandler } from "./safe-async.js";
|
|
27
|
+
import { RoutingEngine } from "./routing-engine.js";
|
|
28
|
+
import { InstanceLifecycle } from "./instance-lifecycle.js";
|
|
29
|
+
import { TopicArchiver } from "./topic-archiver.js";
|
|
30
|
+
import { StatuslineWatcher } from "./statusline-watcher.js";
|
|
31
|
+
import { outboundHandlers } from "./outbound-handlers.js";
|
|
28
32
|
const TMUX_SESSION = "agend";
|
|
29
33
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
30
34
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
@@ -38,10 +42,13 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
|
38
42
|
export class FleetManager {
|
|
39
43
|
dataDir;
|
|
40
44
|
children = new Map();
|
|
41
|
-
|
|
45
|
+
lifecycle;
|
|
46
|
+
/** @deprecated Use lifecycle.daemons — kept for backward compat */
|
|
47
|
+
get daemons() { return this.lifecycle.daemons; }
|
|
42
48
|
fleetConfig = null;
|
|
43
49
|
adapter = null;
|
|
44
|
-
|
|
50
|
+
routing = new RoutingEngine();
|
|
51
|
+
get routingTable() { return this.routing.map; }
|
|
45
52
|
instanceIpcClients = new Map();
|
|
46
53
|
scheduler = null;
|
|
47
54
|
configPath = "";
|
|
@@ -51,25 +58,52 @@ export class FleetManager {
|
|
|
51
58
|
sessionRegistry = new Map();
|
|
52
59
|
eventLog = null;
|
|
53
60
|
costGuard = null;
|
|
54
|
-
|
|
55
|
-
instanceRateLimits = new Map();
|
|
61
|
+
statuslineWatcher;
|
|
56
62
|
dailySummary = null;
|
|
57
63
|
webhookEmitter = null;
|
|
58
64
|
// Topic icon + auto-archive state
|
|
59
65
|
topicIcons = {};
|
|
60
66
|
lastActivity = new Map();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
static ARCHIVE_IDLE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
67
|
+
topicArchiver;
|
|
68
|
+
controlClient = null;
|
|
64
69
|
// Model failover state
|
|
65
70
|
failoverActive = new Map(); // instance → current failover model
|
|
66
|
-
controlClient = null;
|
|
67
71
|
// Health endpoint
|
|
68
72
|
healthServer = null;
|
|
69
73
|
startedAt = 0;
|
|
70
74
|
constructor(dataDir) {
|
|
71
75
|
this.dataDir = dataDir;
|
|
76
|
+
this.lifecycle = new InstanceLifecycle(this);
|
|
72
77
|
this.topicCommands = new TopicCommands(this);
|
|
78
|
+
this.topicArchiver = new TopicArchiver(this);
|
|
79
|
+
this.statuslineWatcher = new StatuslineWatcher(this);
|
|
80
|
+
}
|
|
81
|
+
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
82
|
+
lastActivityMs(name) {
|
|
83
|
+
return this.lastActivity.get(name) ?? 0;
|
|
84
|
+
}
|
|
85
|
+
// ── LifecycleContext bridge methods ──────────────────────────────────────
|
|
86
|
+
webhookEmit(event, name) {
|
|
87
|
+
this.webhookEmitter?.emit(event, name);
|
|
88
|
+
}
|
|
89
|
+
// ── SysInfo ────────────────────────────────────────────────────────────
|
|
90
|
+
getSysInfo() {
|
|
91
|
+
const mem = process.memoryUsage();
|
|
92
|
+
const toMB = (b) => Math.round(b / 1024 / 1024 * 10) / 10;
|
|
93
|
+
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
|
|
94
|
+
name,
|
|
95
|
+
status: this.getInstanceStatus(name),
|
|
96
|
+
ipc: this.instanceIpcClients.has(name),
|
|
97
|
+
costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
|
|
98
|
+
rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
|
|
99
|
+
}));
|
|
100
|
+
return {
|
|
101
|
+
uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
102
|
+
memory_mb: { rss: toMB(mem.rss), heapUsed: toMB(mem.heapUsed), heapTotal: toMB(mem.heapTotal) },
|
|
103
|
+
instances,
|
|
104
|
+
fleet_cost_cents: this.costGuard?.getFleetTotalCents() ?? 0,
|
|
105
|
+
fleet_cost_limit_cents: this.costGuard?.getLimitCents() ?? 0,
|
|
106
|
+
};
|
|
73
107
|
}
|
|
74
108
|
/** Load fleet.yaml and build routing table */
|
|
75
109
|
loadConfig(configPath) {
|
|
@@ -78,18 +112,10 @@ export class FleetManager {
|
|
|
78
112
|
}
|
|
79
113
|
/** Build topic routing table: { topicId -> RouteTarget } */
|
|
80
114
|
buildRoutingTable() {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return table;
|
|
84
|
-
for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
|
|
85
|
-
if (inst.topic_id != null) {
|
|
86
|
-
table.set(String(inst.topic_id), {
|
|
87
|
-
kind: inst.general_topic ? "general" : "instance",
|
|
88
|
-
name,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
115
|
+
if (this.fleetConfig) {
|
|
116
|
+
this.routing.rebuild(this.fleetConfig);
|
|
91
117
|
}
|
|
92
|
-
return
|
|
118
|
+
return this.routing.map;
|
|
93
119
|
}
|
|
94
120
|
getInstanceDir(name) {
|
|
95
121
|
return join(this.dataDir, "instances", name);
|
|
@@ -108,65 +134,11 @@ export class FleetManager {
|
|
|
108
134
|
}
|
|
109
135
|
}
|
|
110
136
|
async startInstance(name, config, topicMode) {
|
|
111
|
-
|
|
112
|
-
this.logger.info({ name }, "Instance already running, skipping");
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
if (!existsSync(config.working_directory)) {
|
|
116
|
-
this.logger.error({ name, working_directory: config.working_directory }, "Working directory does not exist — skipping instance");
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
const instanceDir = this.getInstanceDir(name);
|
|
120
|
-
mkdirSync(instanceDir, { recursive: true });
|
|
121
|
-
const { Daemon } = await import("./daemon.js");
|
|
122
|
-
const { createBackend } = await import("./backend/factory.js");
|
|
123
|
-
const backendName = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
124
|
-
const backend = createBackend(backendName, instanceDir);
|
|
125
|
-
const daemon = new Daemon(name, config, instanceDir, topicMode, backend, this.controlClient ?? undefined);
|
|
126
|
-
await daemon.start();
|
|
127
|
-
this.daemons.set(name, daemon);
|
|
128
|
-
daemon.on("restart_complete", safeHandler((data) => {
|
|
129
|
-
this.eventLog?.insert(name, "context_rotation", data);
|
|
130
|
-
this.logger.info({ name, ...data }, "Context restart completed");
|
|
131
|
-
}, this.logger, `daemon.restart_complete[${name}]`));
|
|
132
|
-
const hangDetector = daemon.getHangDetector();
|
|
133
|
-
if (hangDetector) {
|
|
134
|
-
hangDetector.on("hang", safeHandler(async () => {
|
|
135
|
-
this.eventLog?.insert(name, "hang_detected", {});
|
|
136
|
-
this.logger.warn({ name }, "Instance appears hung");
|
|
137
|
-
await this.sendHangNotification(name);
|
|
138
|
-
this.webhookEmitter?.emit("hang", name);
|
|
139
|
-
}, this.logger, `hangDetector[${name}]`));
|
|
140
|
-
}
|
|
141
|
-
daemon.on("crash_loop", safeHandler(() => {
|
|
142
|
-
this.eventLog?.insert(name, "crash_loop", {});
|
|
143
|
-
this.logger.error({ name }, "Instance in crash loop — respawn paused");
|
|
144
|
-
this.notifyInstanceTopic(name, `🔴 ${name} keeps crashing shortly after launch — respawn paused. Check rate limits or run \`agend fleet restart\`.`);
|
|
145
|
-
this.setTopicIcon(name, "red");
|
|
146
|
-
}, this.logger, `daemon.crash_loop[${name}]`));
|
|
147
|
-
this.setTopicIcon(name, "green");
|
|
148
|
-
this.touchActivity(name);
|
|
137
|
+
return this.lifecycle.start(name, config, topicMode);
|
|
149
138
|
}
|
|
150
139
|
async stopInstance(name) {
|
|
151
|
-
this.setTopicIcon(name, "remove");
|
|
152
140
|
this.failoverActive.delete(name);
|
|
153
|
-
|
|
154
|
-
if (daemon) {
|
|
155
|
-
await daemon.stop();
|
|
156
|
-
this.daemons.delete(name);
|
|
157
|
-
}
|
|
158
|
-
else {
|
|
159
|
-
const pidPath = join(this.getInstanceDir(name), "daemon.pid");
|
|
160
|
-
if (existsSync(pidPath)) {
|
|
161
|
-
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
162
|
-
try {
|
|
163
|
-
process.kill(pid, "SIGTERM");
|
|
164
|
-
}
|
|
165
|
-
catch (e) {
|
|
166
|
-
this.logger.debug({ err: e, pid }, "SIGTERM failed for stale process");
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
141
|
+
return this.lifecycle.stop(name);
|
|
170
142
|
}
|
|
171
143
|
/** Load .env file from data dir into process.env */
|
|
172
144
|
loadEnvFile() {
|
|
@@ -310,12 +282,11 @@ export class FleetManager {
|
|
|
310
282
|
await this.startSharedAdapter(fleet);
|
|
311
283
|
// Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
|
|
312
284
|
await this.topicCommands.autoCreateTopics();
|
|
313
|
-
|
|
314
|
-
const routeSummary = [...this.routingTable.entries()].map(([tid, target]) => `#${tid}→${target.name}`).join(", ");
|
|
285
|
+
const routeSummary = this.routing.rebuild(this.fleetConfig);
|
|
315
286
|
this.logger.info(`Routes: ${routeSummary}`);
|
|
316
287
|
// Resolve topic icon emoji IDs and start idle archive poller
|
|
317
288
|
await this.resolveTopicIcons();
|
|
318
|
-
this.
|
|
289
|
+
this.topicArchiver.startPoller();
|
|
319
290
|
await new Promise(r => setTimeout(r, 3000));
|
|
320
291
|
await this.connectToInstances(fleet);
|
|
321
292
|
for (const name of Object.keys(fleet.instances)) {
|
|
@@ -353,7 +324,7 @@ export class FleetManager {
|
|
|
353
324
|
};
|
|
354
325
|
process.once("SIGUSR1", onFullRestart);
|
|
355
326
|
}
|
|
356
|
-
/** Start the shared
|
|
327
|
+
/** Start the shared channel adapter for topic mode */
|
|
357
328
|
async startSharedAdapter(fleet) {
|
|
358
329
|
const channelConfig = fleet.channel;
|
|
359
330
|
const botToken = process.env[channelConfig.bot_token_env];
|
|
@@ -399,7 +370,7 @@ export class FleetManager {
|
|
|
399
370
|
}, this.logger, "adapter.callback_query"));
|
|
400
371
|
this.adapter.on("topic_closed", safeHandler(async (data) => {
|
|
401
372
|
// Skip unbind if we archived this topic ourselves
|
|
402
|
-
if (this.
|
|
373
|
+
if (this.topicArchiver.isArchived(data.threadId))
|
|
403
374
|
return;
|
|
404
375
|
await this.topicCommands.handleTopicDeleted(data.threadId);
|
|
405
376
|
}, this.logger, "adapter.topic_closed"));
|
|
@@ -477,8 +448,8 @@ export class FleetManager {
|
|
|
477
448
|
// (fixes race condition where mcp_ready was broadcast before fleet manager connected)
|
|
478
449
|
ipc.send({ type: "query_sessions" });
|
|
479
450
|
this.logger.debug({ name }, "Connected to instance IPC");
|
|
480
|
-
if (!this.
|
|
481
|
-
this.
|
|
451
|
+
if (!this.statuslineWatcher.has(name)) {
|
|
452
|
+
this.statuslineWatcher.watch(name);
|
|
482
453
|
}
|
|
483
454
|
}
|
|
484
455
|
catch (err) {
|
|
@@ -534,15 +505,15 @@ export class FleetManager {
|
|
|
534
505
|
}
|
|
535
506
|
return;
|
|
536
507
|
}
|
|
537
|
-
const target = this.
|
|
508
|
+
const target = this.routing.resolve(threadId);
|
|
538
509
|
if (!target) {
|
|
539
510
|
this.topicCommands.handleUnboundTopic(msg);
|
|
540
511
|
return;
|
|
541
512
|
}
|
|
542
513
|
const instanceName = target.name;
|
|
543
514
|
// Reopen archived topic before routing
|
|
544
|
-
if (this.
|
|
545
|
-
await this.
|
|
515
|
+
if (this.topicArchiver.isArchived(threadId)) {
|
|
516
|
+
await this.topicArchiver.reopen(threadId, instanceName);
|
|
546
517
|
}
|
|
547
518
|
this.touchActivity(instanceName);
|
|
548
519
|
this.setTopicIcon(instanceName, "blue");
|
|
@@ -561,7 +532,7 @@ export class FleetManager {
|
|
|
561
532
|
ipc.send({
|
|
562
533
|
type: "fleet_inbound",
|
|
563
534
|
content: text,
|
|
564
|
-
targetSession: instanceName, //
|
|
535
|
+
targetSession: instanceName, // Channel messages → instance's own session
|
|
565
536
|
meta: {
|
|
566
537
|
chat_id: msg.chatId,
|
|
567
538
|
message_id: msg.messageId,
|
|
@@ -577,7 +548,7 @@ export class FleetManager {
|
|
|
577
548
|
}
|
|
578
549
|
/** Handle outbound tool calls from a daemon instance */
|
|
579
550
|
replyIfRateLimited(instanceName, msg) {
|
|
580
|
-
const rl = this.
|
|
551
|
+
const rl = this.statuslineWatcher.getRateLimits(instanceName);
|
|
581
552
|
if (!rl || rl.seven_day_pct < 100)
|
|
582
553
|
return false;
|
|
583
554
|
if (this.adapter && msg.chatId) {
|
|
@@ -618,376 +589,13 @@ export class FleetManager {
|
|
|
618
589
|
}
|
|
619
590
|
return;
|
|
620
591
|
}
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
break;
|
|
629
|
-
}
|
|
630
|
-
if (!message) {
|
|
631
|
-
respond(null, "send_to_instance: missing required argument 'message'");
|
|
632
|
-
break;
|
|
633
|
-
}
|
|
634
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
635
|
-
const isExternalSender = senderSessionName != null && senderSessionName !== instanceName;
|
|
636
|
-
// Resolve target: could be an instance name or an external session name
|
|
637
|
-
let targetIpc = this.instanceIpcClients.get(targetName);
|
|
638
|
-
let targetSession = targetName; // default: target is the instance itself
|
|
639
|
-
let targetInstanceName = targetName;
|
|
640
|
-
if (!targetIpc) {
|
|
641
|
-
// Check if target is an external session
|
|
642
|
-
const hostInstance = this.sessionRegistry.get(targetName);
|
|
643
|
-
if (hostInstance) {
|
|
644
|
-
targetIpc = this.instanceIpcClients.get(hostInstance);
|
|
645
|
-
targetSession = targetName; // deliver to the external session
|
|
646
|
-
targetInstanceName = hostInstance;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
if (!targetIpc) {
|
|
650
|
-
// Check if instance exists in config but is stopped
|
|
651
|
-
const existsInConfig = targetName in (this.fleetConfig?.instances ?? {});
|
|
652
|
-
if (existsInConfig) {
|
|
653
|
-
respond(null, `Instance '${targetName}' is stopped. Use start_instance('${targetName}') to start it first.`);
|
|
654
|
-
}
|
|
655
|
-
else {
|
|
656
|
-
respond(null, `Instance or session not found: ${targetName}`);
|
|
657
|
-
}
|
|
658
|
-
break;
|
|
659
|
-
}
|
|
660
|
-
// Build structured metadata (Phase 2)
|
|
661
|
-
const correlationId = args.correlation_id || `cid-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
662
|
-
const meta = {
|
|
663
|
-
chat_id: "",
|
|
664
|
-
message_id: `xmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
665
|
-
user: `instance:${senderLabel}`,
|
|
666
|
-
user_id: `instance:${senderLabel}`,
|
|
667
|
-
ts: new Date().toISOString(),
|
|
668
|
-
thread_id: "",
|
|
669
|
-
from_instance: senderLabel,
|
|
670
|
-
correlation_id: correlationId,
|
|
671
|
-
};
|
|
672
|
-
if (args.request_kind)
|
|
673
|
-
meta.request_kind = args.request_kind;
|
|
674
|
-
if (args.requires_reply != null)
|
|
675
|
-
meta.requires_reply = String(args.requires_reply);
|
|
676
|
-
if (args.task_summary)
|
|
677
|
-
meta.task_summary = args.task_summary;
|
|
678
|
-
if (args.working_directory)
|
|
679
|
-
meta.working_directory = args.working_directory;
|
|
680
|
-
if (args.branch)
|
|
681
|
-
meta.branch = args.branch;
|
|
682
|
-
targetIpc.send({
|
|
683
|
-
type: "fleet_inbound",
|
|
684
|
-
targetSession,
|
|
685
|
-
content: message,
|
|
686
|
-
meta,
|
|
687
|
-
});
|
|
688
|
-
// Post to Telegram topics for visibility
|
|
689
|
-
const groupId = this.fleetConfig?.channel?.group_id;
|
|
690
|
-
if (groupId && this.adapter) {
|
|
691
|
-
const senderTopicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
692
|
-
const targetTopicId = this.fleetConfig?.instances[targetInstanceName]?.topic_id;
|
|
693
|
-
// Post full message to topics — adapter handles 4096-char chunking
|
|
694
|
-
// Only post to sender topic if sender is the instance itself (not external)
|
|
695
|
-
if (senderTopicId && !isExternalSender) {
|
|
696
|
-
this.adapter.sendText(String(groupId), `→ ${targetName}:\n${message}`, {
|
|
697
|
-
threadId: String(senderTopicId),
|
|
698
|
-
}).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
|
|
699
|
-
}
|
|
700
|
-
// Only post to target topic if target is an instance (not external session)
|
|
701
|
-
if (targetTopicId && !this.sessionRegistry.has(targetName)) {
|
|
702
|
-
this.adapter.sendText(String(groupId), `← ${senderLabel}:\n${message}`, {
|
|
703
|
-
threadId: String(targetTopicId),
|
|
704
|
-
}).catch(e => this.logger.warn({ err: e }, "Failed to post cross-instance notification"));
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
this.logger.info(`✉ ${senderLabel} → ${targetName}: ${(message ?? "").slice(0, 100)}`);
|
|
708
|
-
respond({ sent: true, target: targetName, correlation_id: correlationId });
|
|
709
|
-
break;
|
|
710
|
-
}
|
|
711
|
-
case "list_instances": {
|
|
712
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
713
|
-
const allInstances = Object.entries(this.fleetConfig?.instances ?? {})
|
|
714
|
-
.filter(([name]) => name !== instanceName && name !== senderLabel)
|
|
715
|
-
.map(([name, config]) => ({
|
|
716
|
-
name,
|
|
717
|
-
type: "instance",
|
|
718
|
-
status: this.daemons.has(name) ? "running" : "stopped",
|
|
719
|
-
working_directory: config.working_directory,
|
|
720
|
-
topic_id: config.topic_id ?? null,
|
|
721
|
-
description: config.description ?? null,
|
|
722
|
-
tags: config.tags ?? [],
|
|
723
|
-
last_activity: this.lastActivity.get(name) ? new Date(this.lastActivity.get(name)).toISOString() : null,
|
|
724
|
-
}));
|
|
725
|
-
// Include external sessions (excluding self)
|
|
726
|
-
const externalSessions = [...this.sessionRegistry.entries()]
|
|
727
|
-
.filter(([sessName]) => sessName !== senderLabel)
|
|
728
|
-
.map(([sessName, hostInstance]) => ({
|
|
729
|
-
name: sessName, type: "session", host: hostInstance,
|
|
730
|
-
}));
|
|
731
|
-
respond({ instances: allInstances, external_sessions: externalSessions });
|
|
732
|
-
break;
|
|
733
|
-
}
|
|
734
|
-
// Phase 3: High-level collaboration tools (wrappers around send_to_instance)
|
|
735
|
-
case "request_information": {
|
|
736
|
-
const targetName = args.target_instance;
|
|
737
|
-
const question = args.question;
|
|
738
|
-
const context = args.context;
|
|
739
|
-
const body = context ? `${question}\n\nContext: ${context}` : question;
|
|
740
|
-
// Re-dispatch as send_to_instance with structured metadata (new object to avoid mutating input)
|
|
741
|
-
const queryArgs = { ...args, instance_name: targetName, message: body, request_kind: "query", requires_reply: true, task_summary: question.slice(0, 120) };
|
|
742
|
-
return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: queryArgs, requestId, fleetRequestId, senderSessionName });
|
|
743
|
-
}
|
|
744
|
-
case "delegate_task": {
|
|
745
|
-
const targetName = args.target_instance;
|
|
746
|
-
const task = args.task;
|
|
747
|
-
const criteria = args.success_criteria;
|
|
748
|
-
const context = args.context;
|
|
749
|
-
let body = task;
|
|
750
|
-
if (criteria)
|
|
751
|
-
body += `\n\nSuccess criteria: ${criteria}`;
|
|
752
|
-
if (context)
|
|
753
|
-
body += `\n\nContext: ${context}`;
|
|
754
|
-
const taskArgs = { ...args, instance_name: targetName, message: body, request_kind: "task", requires_reply: true, task_summary: task.slice(0, 120) };
|
|
755
|
-
return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: taskArgs, requestId, fleetRequestId, senderSessionName });
|
|
756
|
-
}
|
|
757
|
-
case "report_result": {
|
|
758
|
-
const targetName = args.target_instance;
|
|
759
|
-
const summary = args.summary;
|
|
760
|
-
const artifacts = args.artifacts;
|
|
761
|
-
if (!args.correlation_id) {
|
|
762
|
-
this.logger.warn({ instanceName, targetName }, "report_result called without correlation_id — recipient cannot match this to an original request");
|
|
763
|
-
}
|
|
764
|
-
let body = summary;
|
|
765
|
-
if (artifacts)
|
|
766
|
-
body += `\n\nArtifacts: ${artifacts}`;
|
|
767
|
-
const reportArgs = { ...args, instance_name: targetName, message: body, request_kind: "report", requires_reply: false, task_summary: summary.slice(0, 120) };
|
|
768
|
-
return this.handleOutboundFromInstance(instanceName, { tool: "send_to_instance", args: reportArgs, requestId, fleetRequestId, senderSessionName });
|
|
769
|
-
}
|
|
770
|
-
// Phase 4: Capability discovery
|
|
771
|
-
case "describe_instance": {
|
|
772
|
-
const targetName = args.name;
|
|
773
|
-
const config = this.fleetConfig?.instances[targetName];
|
|
774
|
-
if (config) {
|
|
775
|
-
respond({
|
|
776
|
-
name: targetName,
|
|
777
|
-
type: "instance",
|
|
778
|
-
description: config.description ?? null,
|
|
779
|
-
tags: config.tags ?? [],
|
|
780
|
-
working_directory: config.working_directory,
|
|
781
|
-
status: this.daemons.has(targetName) ? "running" : "stopped",
|
|
782
|
-
topic_id: config.topic_id ?? null,
|
|
783
|
-
model: config.model ?? null,
|
|
784
|
-
last_activity: this.lastActivity.get(targetName) ? new Date(this.lastActivity.get(targetName)).toISOString() : null,
|
|
785
|
-
worktree_source: config.worktree_source ?? null,
|
|
786
|
-
});
|
|
787
|
-
break;
|
|
788
|
-
}
|
|
789
|
-
// Check if it's a known external session
|
|
790
|
-
const hostInstance = this.sessionRegistry.get(targetName);
|
|
791
|
-
if (hostInstance) {
|
|
792
|
-
respond({
|
|
793
|
-
name: targetName,
|
|
794
|
-
type: "session",
|
|
795
|
-
host: hostInstance,
|
|
796
|
-
status: "running",
|
|
797
|
-
});
|
|
798
|
-
break;
|
|
799
|
-
}
|
|
800
|
-
respond(null, `Instance or session '${targetName}' not found`);
|
|
801
|
-
break;
|
|
802
|
-
}
|
|
803
|
-
case "start_instance": {
|
|
804
|
-
const targetName = args.name;
|
|
805
|
-
// Already running?
|
|
806
|
-
if (this.daemons.has(targetName)) {
|
|
807
|
-
respond({ success: true, status: "already_running" });
|
|
808
|
-
break;
|
|
809
|
-
}
|
|
810
|
-
// Exists in config?
|
|
811
|
-
const targetConfig = this.fleetConfig?.instances[targetName];
|
|
812
|
-
if (!targetConfig) {
|
|
813
|
-
respond(null, `Instance '${targetName}' not found in fleet config`);
|
|
814
|
-
break;
|
|
815
|
-
}
|
|
816
|
-
try {
|
|
817
|
-
await this.startInstance(targetName, targetConfig, true);
|
|
818
|
-
await this.connectIpcToInstance(targetName);
|
|
819
|
-
respond({ success: true, status: "started" });
|
|
820
|
-
}
|
|
821
|
-
catch (err) {
|
|
822
|
-
respond(null, `Failed to start instance '${targetName}': ${err.message}`);
|
|
823
|
-
}
|
|
824
|
-
break;
|
|
825
|
-
}
|
|
826
|
-
case "create_instance": {
|
|
827
|
-
const directory = args.directory.replace(/^~/, process.env.HOME || "~");
|
|
828
|
-
const topicName = args.topic_name || basename(directory);
|
|
829
|
-
const description = args.description;
|
|
830
|
-
const branch = args.branch;
|
|
831
|
-
// Validate directory exists
|
|
832
|
-
try {
|
|
833
|
-
await access(directory);
|
|
834
|
-
}
|
|
835
|
-
catch {
|
|
836
|
-
respond(null, `Directory does not exist: ${directory}`);
|
|
837
|
-
break;
|
|
838
|
-
}
|
|
839
|
-
// Check for duplicate early (before worktree creation) — only when no branch
|
|
840
|
-
if (!branch) {
|
|
841
|
-
const expandHome = (p) => p.replace(/^~/, process.env.HOME || "~");
|
|
842
|
-
const existingInstance = Object.entries(this.fleetConfig?.instances ?? {})
|
|
843
|
-
.find(([_, config]) => expandHome(config.working_directory) === directory);
|
|
844
|
-
if (existingInstance) {
|
|
845
|
-
const [eName, eConfig] = existingInstance;
|
|
846
|
-
respond({
|
|
847
|
-
success: true,
|
|
848
|
-
status: "already_exists",
|
|
849
|
-
name: eName,
|
|
850
|
-
topic_id: eConfig.topic_id,
|
|
851
|
-
running: this.daemons.has(eName),
|
|
852
|
-
});
|
|
853
|
-
break;
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// If branch specified, create git worktree
|
|
857
|
-
let workDir = directory;
|
|
858
|
-
let worktreePath;
|
|
859
|
-
if (branch) {
|
|
860
|
-
try {
|
|
861
|
-
const { execFile: execFileCb } = await import("node:child_process");
|
|
862
|
-
const { promisify } = await import("node:util");
|
|
863
|
-
const execFileAsync = promisify(execFileCb);
|
|
864
|
-
// Verify it's a git repo
|
|
865
|
-
await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd: directory });
|
|
866
|
-
// Determine worktree path: sibling directory named repo-branch
|
|
867
|
-
const repoName = basename(directory);
|
|
868
|
-
const safeBranch = branch.replace(/\//g, "-");
|
|
869
|
-
worktreePath = join(dirname(directory), `${repoName}-${safeBranch}`);
|
|
870
|
-
// Check if branch exists
|
|
871
|
-
let branchExists = false;
|
|
872
|
-
try {
|
|
873
|
-
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd: directory });
|
|
874
|
-
branchExists = true;
|
|
875
|
-
}
|
|
876
|
-
catch { /* branch doesn't exist */ }
|
|
877
|
-
if (branchExists) {
|
|
878
|
-
await execFileAsync("git", ["worktree", "add", worktreePath, branch], { cwd: directory });
|
|
879
|
-
}
|
|
880
|
-
else {
|
|
881
|
-
await execFileAsync("git", ["worktree", "add", worktreePath, "-b", branch], { cwd: directory });
|
|
882
|
-
}
|
|
883
|
-
this.logger.info({ worktreePath, branch, repo: directory }, "Created git worktree for instance");
|
|
884
|
-
workDir = worktreePath;
|
|
885
|
-
}
|
|
886
|
-
catch (err) {
|
|
887
|
-
respond(null, `Failed to create worktree: ${err.message}`);
|
|
888
|
-
break;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
// Check worktree path for duplicates (branch case only — non-branch already checked above)
|
|
892
|
-
if (worktreePath) {
|
|
893
|
-
const expandHome = (p) => p.replace(/^~/, process.env.HOME || "~");
|
|
894
|
-
const existingInstance = Object.entries(this.fleetConfig?.instances ?? {})
|
|
895
|
-
.find(([_, config]) => expandHome(config.working_directory) === workDir);
|
|
896
|
-
if (existingInstance) {
|
|
897
|
-
const [eName, eConfig] = existingInstance;
|
|
898
|
-
respond({
|
|
899
|
-
success: true,
|
|
900
|
-
status: "already_exists",
|
|
901
|
-
name: eName,
|
|
902
|
-
topic_id: eConfig.topic_id,
|
|
903
|
-
running: this.daemons.has(eName),
|
|
904
|
-
});
|
|
905
|
-
break;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
// Sequential steps with rollback
|
|
909
|
-
let createdTopicId;
|
|
910
|
-
let newInstanceName;
|
|
911
|
-
try {
|
|
912
|
-
// Step a: Create Telegram topic
|
|
913
|
-
createdTopicId = await this.createForumTopic(topicName);
|
|
914
|
-
// Step b: Register in config
|
|
915
|
-
// Use topicName for worktree instances to avoid long paths (Unix socket limit 104 bytes)
|
|
916
|
-
const nameBase = worktreePath ? topicName : basename(workDir);
|
|
917
|
-
newInstanceName = `${sanitizeInstanceName(nameBase)}-t${createdTopicId}`;
|
|
918
|
-
const instanceConfig = {
|
|
919
|
-
...DEFAULT_INSTANCE_CONFIG,
|
|
920
|
-
...this.fleetConfig.defaults,
|
|
921
|
-
working_directory: workDir,
|
|
922
|
-
topic_id: createdTopicId,
|
|
923
|
-
...(description ? { description } : {}),
|
|
924
|
-
...(args.model ? { model: args.model } : {}),
|
|
925
|
-
...(args.backend ? { backend: args.backend } : {}),
|
|
926
|
-
...(worktreePath ? { worktree_source: directory } : {}),
|
|
927
|
-
};
|
|
928
|
-
this.fleetConfig.instances[newInstanceName] = instanceConfig;
|
|
929
|
-
this.routingTable.set(String(createdTopicId), { kind: "instance", name: newInstanceName });
|
|
930
|
-
this.saveFleetConfig();
|
|
931
|
-
// Step c: Start instance
|
|
932
|
-
await this.startInstance(newInstanceName, instanceConfig, true);
|
|
933
|
-
await this.connectIpcToInstance(newInstanceName);
|
|
934
|
-
respond({
|
|
935
|
-
success: true,
|
|
936
|
-
name: newInstanceName,
|
|
937
|
-
topic_id: createdTopicId,
|
|
938
|
-
...(worktreePath ? { worktree_path: worktreePath, branch } : {}),
|
|
939
|
-
});
|
|
940
|
-
}
|
|
941
|
-
catch (err) {
|
|
942
|
-
// Rollback in reverse order
|
|
943
|
-
if (newInstanceName && this.daemons.has(newInstanceName)) {
|
|
944
|
-
await this.stopInstance(newInstanceName).catch(e => this.logger.error({ err: e, name: newInstanceName }, "Failed to stop instance during rollback"));
|
|
945
|
-
}
|
|
946
|
-
if (newInstanceName && this.fleetConfig?.instances[newInstanceName]) {
|
|
947
|
-
delete this.fleetConfig.instances[newInstanceName];
|
|
948
|
-
if (createdTopicId)
|
|
949
|
-
this.routingTable.delete(String(createdTopicId));
|
|
950
|
-
this.saveFleetConfig();
|
|
951
|
-
}
|
|
952
|
-
if (createdTopicId) {
|
|
953
|
-
await this.deleteForumTopic(createdTopicId);
|
|
954
|
-
}
|
|
955
|
-
// Rollback worktree
|
|
956
|
-
if (worktreePath) {
|
|
957
|
-
try {
|
|
958
|
-
const { execFile: execFileCb } = await import("node:child_process");
|
|
959
|
-
const { promisify } = await import("node:util");
|
|
960
|
-
const execFileAsync = promisify(execFileCb);
|
|
961
|
-
await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], { cwd: directory });
|
|
962
|
-
}
|
|
963
|
-
catch { /* best-effort worktree cleanup */ }
|
|
964
|
-
}
|
|
965
|
-
respond(null, `Failed to create instance: ${err.message}`);
|
|
966
|
-
}
|
|
967
|
-
break;
|
|
968
|
-
}
|
|
969
|
-
case "delete_instance": {
|
|
970
|
-
const instanceName = args.name;
|
|
971
|
-
const deleteTopic = args.delete_topic ?? false;
|
|
972
|
-
const instanceConfig = this.fleetConfig?.instances[instanceName];
|
|
973
|
-
if (!instanceConfig) {
|
|
974
|
-
respond(null, `Instance not found: ${instanceName}`);
|
|
975
|
-
break;
|
|
976
|
-
}
|
|
977
|
-
if (instanceConfig.general_topic) {
|
|
978
|
-
respond(null, "Cannot delete the General instance");
|
|
979
|
-
break;
|
|
980
|
-
}
|
|
981
|
-
// Delete Telegram topic if requested (before removeInstance clears config)
|
|
982
|
-
if (deleteTopic && instanceConfig.topic_id) {
|
|
983
|
-
await this.deleteForumTopic(instanceConfig.topic_id);
|
|
984
|
-
}
|
|
985
|
-
await this.removeInstance(instanceName);
|
|
986
|
-
respond({ success: true, name: instanceName, topic_deleted: deleteTopic });
|
|
987
|
-
break;
|
|
988
|
-
}
|
|
989
|
-
default:
|
|
990
|
-
respond(null, `Unknown tool: ${tool}`);
|
|
592
|
+
// Dispatch fleet-specific tools via handler map
|
|
593
|
+
const handler = outboundHandlers.get(tool);
|
|
594
|
+
if (handler) {
|
|
595
|
+
await handler(this, args, respond, { instanceName, requestId, fleetRequestId, senderSessionName });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
respond(null, `Unknown tool: ${tool}`);
|
|
991
599
|
}
|
|
992
600
|
}
|
|
993
601
|
/** Handle tool status update from a daemon instance */
|
|
@@ -1015,7 +623,7 @@ export class FleetManager {
|
|
|
1015
623
|
async handleScheduleTrigger(schedule) {
|
|
1016
624
|
const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
|
|
1017
625
|
const RATE_LIMIT_DEFER_THRESHOLD = 85;
|
|
1018
|
-
const rl = this.
|
|
626
|
+
const rl = this.statuslineWatcher.getRateLimits(target);
|
|
1019
627
|
if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
|
|
1020
628
|
this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`);
|
|
1021
629
|
this.eventLog?.insert(target, "schedule_deferred", {
|
|
@@ -1143,7 +751,7 @@ export class FleetManager {
|
|
|
1143
751
|
this.topicCleanupTimer = setInterval(async () => {
|
|
1144
752
|
if (!this.fleetConfig?.channel?.group_id || !this.adapter?.topicExists)
|
|
1145
753
|
return;
|
|
1146
|
-
for (const [threadId, target] of this.
|
|
754
|
+
for (const [threadId, target] of this.routing.entries()) {
|
|
1147
755
|
try {
|
|
1148
756
|
if (!isProbeableRouteTarget(target)) {
|
|
1149
757
|
continue;
|
|
@@ -1207,87 +815,18 @@ export class FleetManager {
|
|
|
1207
815
|
this.logger.info({ path: this.configPath }, "Saved fleet config");
|
|
1208
816
|
}
|
|
1209
817
|
async removeInstance(name) {
|
|
818
|
+
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
1210
819
|
const config = this.fleetConfig?.instances[name];
|
|
1211
|
-
if (
|
|
1212
|
-
return;
|
|
1213
|
-
// Never remove the General instance
|
|
1214
|
-
if (config.general_topic) {
|
|
1215
|
-
this.logger.warn({ name }, "Refusing to remove General instance");
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1218
|
-
// Clean up schedules
|
|
1219
|
-
if (this.scheduler && config.topic_id) {
|
|
820
|
+
if (this.scheduler && config?.topic_id) {
|
|
1220
821
|
const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
|
|
1221
822
|
if (count > 0) {
|
|
1222
823
|
this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
|
|
1223
824
|
}
|
|
1224
825
|
}
|
|
1225
|
-
|
|
1226
|
-
if (this.daemons.has(name)) {
|
|
1227
|
-
await this.stopInstance(name);
|
|
1228
|
-
}
|
|
1229
|
-
// Clean up git worktree if applicable
|
|
1230
|
-
if (config.worktree_source && config.working_directory) {
|
|
1231
|
-
const { existsSync } = await import("node:fs");
|
|
1232
|
-
if (!existsSync(config.working_directory)) {
|
|
1233
|
-
this.logger.info({ worktree: config.working_directory }, "Worktree directory already gone, skipping removal");
|
|
1234
|
-
}
|
|
1235
|
-
else {
|
|
1236
|
-
try {
|
|
1237
|
-
const { execFile: execFileCb } = await import("node:child_process");
|
|
1238
|
-
const { promisify } = await import("node:util");
|
|
1239
|
-
const execFileAsync = promisify(execFileCb);
|
|
1240
|
-
await execFileAsync("git", ["worktree", "remove", "--force", config.working_directory], {
|
|
1241
|
-
cwd: config.worktree_source,
|
|
1242
|
-
});
|
|
1243
|
-
this.logger.info({ worktree: config.working_directory }, "Removed git worktree");
|
|
1244
|
-
}
|
|
1245
|
-
catch (err) {
|
|
1246
|
-
this.logger.warn({ err, worktree: config.working_directory }, "Failed to remove git worktree");
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
// Clean up IPC
|
|
1251
|
-
const ipc = this.instanceIpcClients.get(name);
|
|
1252
|
-
if (ipc) {
|
|
1253
|
-
await ipc.close();
|
|
1254
|
-
this.instanceIpcClients.delete(name);
|
|
1255
|
-
}
|
|
1256
|
-
// Remove from routing table
|
|
1257
|
-
if (config.topic_id != null) {
|
|
1258
|
-
this.routingTable.delete(String(config.topic_id));
|
|
1259
|
-
}
|
|
1260
|
-
// Remove from fleet config and save
|
|
1261
|
-
delete this.fleetConfig.instances[name];
|
|
1262
|
-
this.saveFleetConfig();
|
|
1263
|
-
this.logger.info({ name }, "Instance removed");
|
|
826
|
+
return this.lifecycle.remove(name);
|
|
1264
827
|
}
|
|
1265
828
|
startStatuslineWatcher(name) {
|
|
1266
|
-
|
|
1267
|
-
const timer = setInterval(() => {
|
|
1268
|
-
try {
|
|
1269
|
-
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
1270
|
-
if (data.cost?.total_cost_usd != null) {
|
|
1271
|
-
this.costGuard?.updateCost(name, data.cost.total_cost_usd);
|
|
1272
|
-
}
|
|
1273
|
-
const rl = data.rate_limits;
|
|
1274
|
-
if (rl) {
|
|
1275
|
-
const prev = this.instanceRateLimits.get(name);
|
|
1276
|
-
const newSevenDay = rl.seven_day?.used_percentage ?? 0;
|
|
1277
|
-
if (prev?.seven_day_pct === 100 && newSevenDay < 100) {
|
|
1278
|
-
this.notifyInstanceTopic(name, `✅ ${name} weekly usage limit has reset — instance is available again.`);
|
|
1279
|
-
this.logger.info({ name }, "Weekly rate limit recovered");
|
|
1280
|
-
}
|
|
1281
|
-
this.instanceRateLimits.set(name, {
|
|
1282
|
-
five_hour_pct: rl.five_hour?.used_percentage ?? 0,
|
|
1283
|
-
seven_day_pct: newSevenDay,
|
|
1284
|
-
});
|
|
1285
|
-
this.checkModelFailover(name, rl.five_hour?.used_percentage ?? 0);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
catch { /* file may not exist yet or be mid-write */ }
|
|
1289
|
-
}, 10_000);
|
|
1290
|
-
this.statuslineWatchers.set(name, timer);
|
|
829
|
+
this.statuslineWatcher.watch(name);
|
|
1291
830
|
}
|
|
1292
831
|
// ── Model failover ──────────────────────────────────────────────────────
|
|
1293
832
|
static FAILOVER_TRIGGER_PCT = 90;
|
|
@@ -1368,7 +907,7 @@ export class FleetManager {
|
|
|
1368
907
|
const stickers = await this.adapter.getTopicIconStickers();
|
|
1369
908
|
if (stickers.length === 0)
|
|
1370
909
|
return;
|
|
1371
|
-
//
|
|
910
|
+
// getForumTopicIconStickers returns a fixed set of available icons.
|
|
1372
911
|
// Try to match by emoji character, fall back to positional.
|
|
1373
912
|
const find = (targets) => stickers.find((s) => targets.some((t) => s.emoji.includes(t)));
|
|
1374
913
|
const green = find(["🟢", "✅", "💚"]);
|
|
@@ -1401,54 +940,9 @@ export class FleetManager {
|
|
|
1401
940
|
this.lastActivity.set(instanceName, Date.now());
|
|
1402
941
|
}
|
|
1403
942
|
/** Start periodic idle archive checker */
|
|
1404
|
-
|
|
1405
|
-
this.archiveTimer = setInterval(() => {
|
|
1406
|
-
this.archiveIdleTopics().catch((err) => this.logger.debug({ err }, "Archive idle check failed"));
|
|
1407
|
-
}, 30 * 60_000); // check every 30 minutes
|
|
1408
|
-
}
|
|
1409
|
-
/** Close topics that have been idle beyond threshold */
|
|
1410
|
-
async archiveIdleTopics() {
|
|
1411
|
-
if (!this.adapter?.closeForumTopic || !this.fleetConfig)
|
|
1412
|
-
return;
|
|
1413
|
-
const now = Date.now();
|
|
1414
|
-
for (const [name, config] of Object.entries(this.fleetConfig.instances)) {
|
|
1415
|
-
const topicId = config.topic_id;
|
|
1416
|
-
if (topicId == null || config.general_topic)
|
|
1417
|
-
continue;
|
|
1418
|
-
const topicIdStr = String(topicId);
|
|
1419
|
-
if (this.archivedTopics.has(topicIdStr))
|
|
1420
|
-
continue;
|
|
1421
|
-
const status = this.getInstanceStatus(name);
|
|
1422
|
-
if (status !== "running")
|
|
1423
|
-
continue; // only archive running-but-idle
|
|
1424
|
-
const last = this.lastActivity.get(name) ?? 0;
|
|
1425
|
-
if (last === 0)
|
|
1426
|
-
continue; // never active → skip (just started)
|
|
1427
|
-
if (now - last < FleetManager.ARCHIVE_IDLE_MS)
|
|
1428
|
-
continue;
|
|
1429
|
-
this.logger.info({ name, topicId, idleHours: Math.round((now - last) / 3600000) }, "Archiving idle topic");
|
|
1430
|
-
this.archivedTopics.add(topicIdStr);
|
|
1431
|
-
this.setTopicIcon(name, "remove");
|
|
1432
|
-
await this.adapter.closeForumTopic(topicId);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
/** Reopen an archived topic and restore icon */
|
|
1436
|
-
async reopenArchivedTopic(topicId, instanceName) {
|
|
1437
|
-
if (!this.archivedTopics.has(topicId))
|
|
1438
|
-
return;
|
|
1439
|
-
this.archivedTopics.delete(topicId);
|
|
1440
|
-
if (this.adapter?.reopenForumTopic) {
|
|
1441
|
-
await this.adapter.reopenForumTopic(topicId);
|
|
1442
|
-
}
|
|
1443
|
-
this.setTopicIcon(instanceName, "green");
|
|
1444
|
-
this.touchActivity(instanceName);
|
|
1445
|
-
this.logger.info({ instanceName, topicId }, "Reopened archived topic");
|
|
1446
|
-
}
|
|
943
|
+
// archiveIdleTopics / reopenArchivedTopic → delegated to TopicArchiver
|
|
1447
944
|
clearStatuslineWatchers() {
|
|
1448
|
-
|
|
1449
|
-
clearInterval(timer);
|
|
1450
|
-
this.statuslineWatchers.clear();
|
|
1451
|
-
this.instanceRateLimits.clear();
|
|
945
|
+
this.statuslineWatcher.stopAll();
|
|
1452
946
|
this.failoverActive.clear();
|
|
1453
947
|
}
|
|
1454
948
|
async stopAll() {
|
|
@@ -1463,10 +957,7 @@ export class FleetManager {
|
|
|
1463
957
|
clearInterval(this.sessionPruneTimer);
|
|
1464
958
|
this.sessionPruneTimer = null;
|
|
1465
959
|
}
|
|
1466
|
-
|
|
1467
|
-
clearInterval(this.archiveTimer);
|
|
1468
|
-
this.archiveTimer = null;
|
|
1469
|
-
}
|
|
960
|
+
this.topicArchiver.stop();
|
|
1470
961
|
this.scheduler?.shutdown();
|
|
1471
962
|
await Promise.allSettled([...this.daemons.entries()].map(async ([name, daemon]) => {
|
|
1472
963
|
try {
|
|
@@ -1642,7 +1133,7 @@ export class FleetManager {
|
|
|
1642
1133
|
await this.startInstance(name, config, topicMode);
|
|
1643
1134
|
}
|
|
1644
1135
|
if (topicMode) {
|
|
1645
|
-
this.
|
|
1136
|
+
this.routing.rebuild(this.fleetConfig);
|
|
1646
1137
|
await new Promise(r => setTimeout(r, 3000));
|
|
1647
1138
|
await this.connectToInstances(fleet);
|
|
1648
1139
|
for (const name of Object.keys(fleet.instances)) {
|
|
@@ -1658,7 +1149,7 @@ export class FleetManager {
|
|
|
1658
1149
|
this.logger.info({ count: instances.length }, "Sending restart notification to instances");
|
|
1659
1150
|
for (const [name, config] of instances) {
|
|
1660
1151
|
const threadId = config.topic_id != null ? String(config.topic_id) : undefined;
|
|
1661
|
-
// Send to
|
|
1152
|
+
// Send to topic so the message appears in the instance's channel
|
|
1662
1153
|
if (threadId) {
|
|
1663
1154
|
this.adapter.sendText(String(groupId), "Fleet restart complete. Continue from where you left off.", { threadId })
|
|
1664
1155
|
.catch(e => this.logger.warn({ err: e, name, threadId }, "Failed to post per-instance restart notification"));
|