@songsid/agend 2.1.0-beta.1 → 2.1.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/antigravity.js +3 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/codex.js +3 -1
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.js +3 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/cli.js +43 -6
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +7 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/context-guardian.d.ts +2 -0
- package/dist/context-guardian.js +10 -2
- package/dist/context-guardian.js.map +1 -1
- package/dist/daemon-entry.js +2 -1
- package/dist/daemon-entry.js.map +1 -1
- package/dist/daemon.d.ts +50 -3
- package/dist/daemon.js +356 -31
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-context.d.ts +2 -1
- package/dist/fleet-manager.d.ts +21 -4
- package/dist/fleet-manager.js +213 -122
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +6 -1
- package/dist/instance-lifecycle.js +45 -3
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/logger.js +6 -3
- package/dist/logger.js.map +1 -1
- package/dist/outbound-handlers.d.ts +5 -0
- package/dist/outbound-handlers.js +68 -16
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/settings-api.d.ts +2 -1
- package/dist/settings-api.js +10 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/statusline-watcher.d.ts +1 -1
- package/dist/statusline-watcher.js +3 -2
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/tmux-manager.d.ts +2 -0
- package/dist/tmux-manager.js +9 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-archiver.d.ts +1 -1
- package/dist/topic-commands.d.ts +5 -1
- package/dist/topic-commands.js +60 -12
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +2 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/ui/settings.html +25 -3
- package/dist/view-api.d.ts +1 -1
- package/dist/web-api.d.ts +2 -1
- package/dist/web-api.js +21 -14
- package/dist/web-api.js.map +1 -1
- package/package.json +2 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -5,13 +5,13 @@ import { join, dirname, basename } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
7
|
import { sdNotify } from "./sd-notify.js";
|
|
8
|
-
import
|
|
8
|
+
import { isScalar, parseDocument } from "yaml";
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = dirname(__filename);
|
|
11
11
|
/** Fallback access policy for a channel with no `access:` block — open (no gate). */
|
|
12
12
|
const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
|
|
13
13
|
import { isProbeableRouteTarget } from "./fleet-context.js";
|
|
14
|
-
import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
14
|
+
import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
|
|
15
15
|
import { EventLog } from "./event-log.js";
|
|
16
16
|
import { AdapterWorld } from "./adapter-world.js";
|
|
17
17
|
import { CostGuard, formatCents } from "./cost-guard.js";
|
|
@@ -65,6 +65,9 @@ export class FleetManager {
|
|
|
65
65
|
/** @deprecated Use lifecycle.daemons — kept for backward compat */
|
|
66
66
|
get daemons() { return this.lifecycle.daemons; }
|
|
67
67
|
fleetConfig = null;
|
|
68
|
+
rawFleetConfig = {};
|
|
69
|
+
rawFleetDocument = null;
|
|
70
|
+
savedFleetConfigSnapshot = null;
|
|
68
71
|
adapter = null;
|
|
69
72
|
worlds = new Map();
|
|
70
73
|
adapters = new Map(); // derived view for backward compat
|
|
@@ -93,6 +96,8 @@ export class FleetManager {
|
|
|
93
96
|
// Topic icon + auto-archive state
|
|
94
97
|
topicIcons = {};
|
|
95
98
|
lastActivity = new Map();
|
|
99
|
+
/** Latest pane-derived execution snapshot reported by each daemon. */
|
|
100
|
+
instanceStateCache = new Map();
|
|
96
101
|
lastInboundUser = new Map(); // instanceName → last username
|
|
97
102
|
// Active "🛑 Cancel" buttons, tracked per button (keyed by messageId) rather
|
|
98
103
|
// than one-per-instance. A button is retired (deleted, with bounded retry) on
|
|
@@ -160,6 +165,7 @@ export class FleetManager {
|
|
|
160
165
|
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
|
|
161
166
|
name,
|
|
162
167
|
status: this.getInstanceStatus(name),
|
|
168
|
+
state: this.getInstanceExecutionState(name),
|
|
163
169
|
ipc: this.instanceIpcClients.has(name),
|
|
164
170
|
costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
|
|
165
171
|
rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
|
|
@@ -174,9 +180,21 @@ export class FleetManager {
|
|
|
174
180
|
}
|
|
175
181
|
/** Load fleet.yaml and build routing table */
|
|
176
182
|
loadConfig(configPath) {
|
|
183
|
+
this.configPath = configPath;
|
|
184
|
+
const source = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "{}\n";
|
|
185
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
186
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
187
|
+
throw new Error(`Invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
188
|
+
}
|
|
189
|
+
this.rawFleetConfig = loadRawFleetConfig(configPath);
|
|
177
190
|
this.fleetConfig = loadFleetConfig(configPath);
|
|
191
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
178
192
|
return this.fleetConfig;
|
|
179
193
|
}
|
|
194
|
+
/** User-authored fleet.yaml, before defaults are merged into instances. */
|
|
195
|
+
getRawFleetConfig() {
|
|
196
|
+
return structuredClone(this.rawFleetConfig);
|
|
197
|
+
}
|
|
180
198
|
/** Build topic routing table: { topicId -> RouteTarget } */
|
|
181
199
|
buildRoutingTable() {
|
|
182
200
|
if (this.fleetConfig) {
|
|
@@ -265,17 +283,24 @@ export class FleetManager {
|
|
|
265
283
|
*/
|
|
266
284
|
bindInstanceAdapter(name, adapterId, fromInbound = false) {
|
|
267
285
|
const cfg = this.fleetConfig?.instances[name];
|
|
268
|
-
if (fromInbound
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
if (fromInbound) {
|
|
287
|
+
// Skip inbound-derived binding for any instance that doesn't have an
|
|
288
|
+
// explicit channel_id — those default to primary adapter deterministically.
|
|
289
|
+
// This prevents a non-deterministic race where whichever adapter delivers
|
|
290
|
+
// first after restart wins the binding.
|
|
291
|
+
if (cfg?.general_topic || cfg?.channel_id)
|
|
292
|
+
return;
|
|
293
|
+
if (cfg && !cfg.channel_id)
|
|
294
|
+
return; // fleet instance without explicit binding → use primary
|
|
295
|
+
// Classic instance: don't override an existing binding (authoritative from /start)
|
|
296
|
+
if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
276
299
|
this.instanceWorldBinding.set(name, adapterId);
|
|
277
300
|
}
|
|
278
301
|
getInstanceStatus(name) {
|
|
302
|
+
if (this.lifecycle.isPaused(name))
|
|
303
|
+
return "paused";
|
|
279
304
|
const pidPath = join(this.getInstanceDir(name), "daemon.pid");
|
|
280
305
|
if (!existsSync(pidPath))
|
|
281
306
|
return "stopped";
|
|
@@ -288,6 +313,35 @@ export class FleetManager {
|
|
|
288
313
|
return "crashed";
|
|
289
314
|
}
|
|
290
315
|
}
|
|
316
|
+
getInstanceExecutionState(name) {
|
|
317
|
+
if (this.lifecycle.isPaused(name))
|
|
318
|
+
return null;
|
|
319
|
+
return this.instanceStateCache.get(name)?.state ?? null;
|
|
320
|
+
}
|
|
321
|
+
cacheInstanceExecutionState(name, msg) {
|
|
322
|
+
const state = msg.state;
|
|
323
|
+
if (state !== "idle" && state !== "working" && state !== "stuck")
|
|
324
|
+
return;
|
|
325
|
+
const previous = this.instanceStateCache.get(name);
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
328
|
+
this.instanceStateCache.set(name, {
|
|
329
|
+
state,
|
|
330
|
+
unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
|
|
331
|
+
observedAt: numberOr(msg.observedAt, now),
|
|
332
|
+
stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/** Single delivery facade: wake an auto-paused CLI before sending to daemon IPC. */
|
|
336
|
+
async deliverToInstance(instanceName, payload) {
|
|
337
|
+
if (this.lifecycle.isPaused(instanceName)) {
|
|
338
|
+
await this.lifecycle.wake(instanceName, 30_000);
|
|
339
|
+
}
|
|
340
|
+
const ipc = this.instanceIpcClients.get(instanceName);
|
|
341
|
+
if (!ipc?.connected)
|
|
342
|
+
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
343
|
+
ipc.send(payload);
|
|
344
|
+
}
|
|
291
345
|
async startInstance(name, config, topicMode) {
|
|
292
346
|
if (config.general_topic) {
|
|
293
347
|
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
@@ -355,6 +409,7 @@ export class FleetManager {
|
|
|
355
409
|
}
|
|
356
410
|
async stopInstance(name) {
|
|
357
411
|
this.failoverActive.delete(name);
|
|
412
|
+
this.instanceStateCache.delete(name);
|
|
358
413
|
return this.lifecycle.stop(name);
|
|
359
414
|
}
|
|
360
415
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
@@ -1520,10 +1575,16 @@ export class FleetManager {
|
|
|
1520
1575
|
else if (msg.type === "fleet_set_description") {
|
|
1521
1576
|
this.handleSetDescription(name, msg);
|
|
1522
1577
|
}
|
|
1578
|
+
else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
|
|
1579
|
+
this.cacheInstanceExecutionState(name, msg);
|
|
1580
|
+
}
|
|
1523
1581
|
}, this.logger, `ipc.message[${name}]`));
|
|
1524
1582
|
// Ask daemon for any sessions that registered before we connected
|
|
1525
1583
|
// (fixes race condition where mcp_ready was broadcast before fleet manager connected)
|
|
1526
1584
|
ipc.send({ type: "query_sessions" });
|
|
1585
|
+
// The initial state transition may have happened before FleetManager
|
|
1586
|
+
// connected, so seed the cache instead of waiting for another transition.
|
|
1587
|
+
ipc.send({ type: "query_instance_state", requestId: `fleet-state-${Date.now()}` });
|
|
1527
1588
|
this.logger.debug({ name }, "Connected to instance IPC");
|
|
1528
1589
|
if (!this.statuslineWatcher.has(name)) {
|
|
1529
1590
|
this.statuslineWatcher.watch(name);
|
|
@@ -1926,9 +1987,8 @@ export class FleetManager {
|
|
|
1926
1987
|
}
|
|
1927
1988
|
this.warnIfRateLimited(generalInstance, msg);
|
|
1928
1989
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
ipc.send({
|
|
1990
|
+
try {
|
|
1991
|
+
await this.deliverToInstance(generalInstance, {
|
|
1932
1992
|
type: "fleet_inbound",
|
|
1933
1993
|
content: text,
|
|
1934
1994
|
targetSession: generalInstance,
|
|
@@ -1954,6 +2014,9 @@ export class FleetManager {
|
|
|
1954
2014
|
this.trackInboundMsg(generalInstance, msg);
|
|
1955
2015
|
void this.sendCancelButton(generalInstance);
|
|
1956
2016
|
}
|
|
2017
|
+
catch (err) {
|
|
2018
|
+
this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
|
|
2019
|
+
}
|
|
1957
2020
|
}
|
|
1958
2021
|
return;
|
|
1959
2022
|
}
|
|
@@ -2016,27 +2079,32 @@ export class FleetManager {
|
|
|
2016
2079
|
this.setTopicIcon(instanceName, "blue");
|
|
2017
2080
|
this.warnIfRateLimited(instanceName, msg);
|
|
2018
2081
|
const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2082
|
+
try {
|
|
2083
|
+
await this.deliverToInstance(instanceName, {
|
|
2084
|
+
type: "fleet_inbound",
|
|
2085
|
+
content: text,
|
|
2086
|
+
targetSession: instanceName, // Channel messages → instance's own session
|
|
2087
|
+
meta: {
|
|
2088
|
+
chat_id: msg.chatId,
|
|
2089
|
+
message_id: msg.messageId,
|
|
2090
|
+
user: msg.username,
|
|
2091
|
+
user_id: msg.userId,
|
|
2092
|
+
ts: msg.timestamp.toISOString(),
|
|
2093
|
+
thread_id: msg.threadId ?? "",
|
|
2094
|
+
source: msg.source,
|
|
2095
|
+
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2096
|
+
...extraMeta,
|
|
2097
|
+
},
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
catch (err) {
|
|
2101
|
+
this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
|
|
2102
|
+
if (msg.chatId && msg.messageId) {
|
|
2103
|
+
const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
|
|
2104
|
+
reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
|
|
2105
|
+
}
|
|
2022
2106
|
return;
|
|
2023
2107
|
}
|
|
2024
|
-
ipc.send({
|
|
2025
|
-
type: "fleet_inbound",
|
|
2026
|
-
content: text,
|
|
2027
|
-
targetSession: instanceName, // Channel messages → instance's own session
|
|
2028
|
-
meta: {
|
|
2029
|
-
chat_id: msg.chatId,
|
|
2030
|
-
message_id: msg.messageId,
|
|
2031
|
-
user: msg.username,
|
|
2032
|
-
user_id: msg.userId,
|
|
2033
|
-
ts: msg.timestamp.toISOString(),
|
|
2034
|
-
thread_id: msg.threadId ?? "",
|
|
2035
|
-
source: msg.source,
|
|
2036
|
-
...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
|
|
2037
|
-
...extraMeta,
|
|
2038
|
-
},
|
|
2039
|
-
});
|
|
2040
2108
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
2041
2109
|
this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
|
|
2042
2110
|
this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
|
|
@@ -2197,20 +2265,23 @@ export class FleetManager {
|
|
|
2197
2265
|
const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
|
|
2198
2266
|
const retryCount = schedulerDefaults?.retry_count ?? 3;
|
|
2199
2267
|
const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
|
|
2200
|
-
const deliver = () => {
|
|
2201
|
-
|
|
2202
|
-
|
|
2268
|
+
const deliver = async () => {
|
|
2269
|
+
try {
|
|
2270
|
+
await this.deliverToInstance(target, {
|
|
2271
|
+
type: "fleet_schedule_trigger",
|
|
2272
|
+
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
2273
|
+
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
2274
|
+
});
|
|
2275
|
+
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
2276
|
+
void this.sendCancelButton(target);
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
catch (err) {
|
|
2280
|
+
this.logger.warn({ err: err.message, target }, "Scheduled wake/delivery attempt failed");
|
|
2203
2281
|
return false;
|
|
2204
|
-
|
|
2205
|
-
type: "fleet_schedule_trigger",
|
|
2206
|
-
payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
|
|
2207
|
-
meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
|
|
2208
|
-
});
|
|
2209
|
-
// A scheduled trigger also puts the instance to work — show a cancel button.
|
|
2210
|
-
void this.sendCancelButton(target);
|
|
2211
|
-
return true;
|
|
2282
|
+
}
|
|
2212
2283
|
};
|
|
2213
|
-
if (deliver()) {
|
|
2284
|
+
if (await deliver()) {
|
|
2214
2285
|
this.scheduler.recordRun(id, "delivered");
|
|
2215
2286
|
if (source !== target)
|
|
2216
2287
|
this.notifySourceTopic(schedule);
|
|
@@ -2218,7 +2289,7 @@ export class FleetManager {
|
|
|
2218
2289
|
}
|
|
2219
2290
|
for (let i = 0; i < retryCount; i++) {
|
|
2220
2291
|
await new Promise((r) => setTimeout(r, retryInterval));
|
|
2221
|
-
if (deliver()) {
|
|
2292
|
+
if (await deliver()) {
|
|
2222
2293
|
this.scheduler.recordRun(id, "delivered");
|
|
2223
2294
|
if (source !== target)
|
|
2224
2295
|
this.notifySourceTopic(schedule);
|
|
@@ -2642,73 +2713,80 @@ export class FleetManager {
|
|
|
2642
2713
|
}
|
|
2643
2714
|
}, 5 * 60_000);
|
|
2644
2715
|
}
|
|
2645
|
-
/**
|
|
2716
|
+
/**
|
|
2717
|
+
* Patch only values changed in the effective config into the original YAML
|
|
2718
|
+
* document. Unknown keys, explicit overrides and comments remain untouched.
|
|
2719
|
+
*/
|
|
2646
2720
|
saveFleetConfig() {
|
|
2647
2721
|
if (!this.fleetConfig || !this.configPath)
|
|
2648
2722
|
return;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2723
|
+
if (!this.savedFleetConfigSnapshot)
|
|
2724
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2725
|
+
// Re-read immediately before patching so an unrelated concurrent/manual
|
|
2726
|
+
// edit is retained. Invalid concurrent YAML is never overwritten.
|
|
2727
|
+
const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
|
|
2728
|
+
this.rawFleetDocument = parseDocument(source, { keepSourceTokens: true });
|
|
2729
|
+
if (this.rawFleetDocument.errors.length > 0) {
|
|
2730
|
+
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
2731
|
+
}
|
|
2732
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
2733
|
+
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
2734
|
+
const output = String(this.rawFleetDocument);
|
|
2735
|
+
const tempPath = `${this.configPath}.tmp-${process.pid}`;
|
|
2736
|
+
writeFileSync(tempPath, output, "utf-8");
|
|
2737
|
+
if (existsSync(this.configPath))
|
|
2738
|
+
chmodSync(tempPath, statSync(this.configPath).mode);
|
|
2739
|
+
renameSync(tempPath, this.configPath);
|
|
2740
|
+
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
2741
|
+
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2742
|
+
this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
|
|
2743
|
+
}
|
|
2744
|
+
patchFleetDocument(document, path, before, after) {
|
|
2745
|
+
if (Object.is(before, after))
|
|
2746
|
+
return;
|
|
2747
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
2748
|
+
const shared = Math.min(before.length, after.length);
|
|
2749
|
+
for (let i = 0; i < shared; i++) {
|
|
2750
|
+
this.patchFleetDocument(document, [...path, i], before[i], after[i]);
|
|
2751
|
+
}
|
|
2752
|
+
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
2753
|
+
for (let i = before.length - 1; i >= after.length; i--)
|
|
2754
|
+
document.deleteIn([...path, i]);
|
|
2755
|
+
for (let i = shared; i < after.length; i++)
|
|
2756
|
+
document.setIn([...path, i], after[i]);
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2760
|
+
if (isRecord(before) && isRecord(after)) {
|
|
2761
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
2762
|
+
for (const key of keys) {
|
|
2763
|
+
// `channel` is a derived alias when the raw file uses `channels`.
|
|
2764
|
+
if (path.length === 0 && key === "channel" && this.rawFleetConfig.channels)
|
|
2765
|
+
continue;
|
|
2766
|
+
// Conversely, `channels` is a normalized alias for a legacy `channel`.
|
|
2767
|
+
// Keep the user's original shape unless the caller explicitly removed
|
|
2768
|
+
// `channel` (the Settings channels endpoint intentionally migrates it).
|
|
2769
|
+
if (path.length === 0 && key === "channels" && this.rawFleetConfig.channel && !this.rawFleetConfig.channels && after.channel !== undefined)
|
|
2770
|
+
continue;
|
|
2771
|
+
this.patchFleetDocument(document, [...path, key], before[key], after[key]);
|
|
2772
|
+
}
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
if (after === undefined) {
|
|
2776
|
+
document.deleteIn(path);
|
|
2777
|
+
}
|
|
2778
|
+
else if (path.length === 0) {
|
|
2779
|
+
document.contents = document.createNode(after);
|
|
2780
|
+
}
|
|
2781
|
+
else {
|
|
2782
|
+
const currentNode = document.getIn(path, true);
|
|
2783
|
+
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
2784
|
+
currentNode.value = after;
|
|
2785
|
+
}
|
|
2786
|
+
else {
|
|
2787
|
+
document.setIn(path, after);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2712
2790
|
}
|
|
2713
2791
|
async removeInstance(name) {
|
|
2714
2792
|
// Clean up schedules (scheduler is fleet-level, not lifecycle-level)
|
|
@@ -2751,6 +2829,10 @@ export class FleetManager {
|
|
|
2751
2829
|
startStatuslineWatcher(name) {
|
|
2752
2830
|
this.statuslineWatcher.watch(name);
|
|
2753
2831
|
}
|
|
2832
|
+
stopStatuslineWatcher(name) {
|
|
2833
|
+
// Pausing stops I/O but retains the last observed limits for status views.
|
|
2834
|
+
this.statuslineWatcher.unwatch(name, true);
|
|
2835
|
+
}
|
|
2754
2836
|
reactMessageStatus(instanceName, chatId, messageId, emoji) {
|
|
2755
2837
|
// React via the adapter BOUND to this instance — NOT the first discord world.
|
|
2756
2838
|
// Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
|
|
@@ -3101,7 +3183,7 @@ export class FleetManager {
|
|
|
3101
3183
|
return [];
|
|
3102
3184
|
}
|
|
3103
3185
|
}
|
|
3104
|
-
async sendHangNotification(instanceName) {
|
|
3186
|
+
async sendHangNotification(instanceName, unchangedForMs) {
|
|
3105
3187
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
3106
3188
|
if (!adapter)
|
|
3107
3189
|
return;
|
|
@@ -3110,11 +3192,18 @@ export class FleetManager {
|
|
|
3110
3192
|
if (!groupId)
|
|
3111
3193
|
return;
|
|
3112
3194
|
const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
3195
|
+
const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
|
|
3196
|
+
const configuredMinutes = instanceHangConfig?.timeout_minutes
|
|
3197
|
+
?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
|
|
3198
|
+
?? 15;
|
|
3199
|
+
const unchangedMinutes = unchangedForMs == null
|
|
3200
|
+
? configuredMinutes
|
|
3201
|
+
: Math.max(1, Math.floor(unchangedForMs / 60_000));
|
|
3113
3202
|
this.setTopicIcon(instanceName, "red");
|
|
3114
3203
|
await adapter.notifyAlert(String(groupId), {
|
|
3115
3204
|
type: "hang",
|
|
3116
3205
|
instanceName,
|
|
3117
|
-
message: `⚠️ ${instanceName}
|
|
3206
|
+
message: `⚠️ ${instanceName} may be stuck — pane unchanged for ${unchangedMinutes}min, ready prompt not recognized`,
|
|
3118
3207
|
choices: [
|
|
3119
3208
|
{ id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
|
|
3120
3209
|
{ id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
|
|
@@ -3615,11 +3704,6 @@ When users create specialized instances, suggest these configurations:
|
|
|
3615
3704
|
const fullText = logContext
|
|
3616
3705
|
? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
|
|
3617
3706
|
: text;
|
|
3618
|
-
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3619
|
-
if (!ipc) {
|
|
3620
|
-
this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
|
|
3621
|
-
return;
|
|
3622
|
-
}
|
|
3623
3707
|
const meta = {
|
|
3624
3708
|
chat_id: msg.chatId,
|
|
3625
3709
|
message_id: msg.messageId,
|
|
@@ -3641,12 +3725,18 @@ When users create specialized instances, suggest these configurations:
|
|
|
3641
3725
|
meta.image_path = saves[saves.length - 1][1].split(",")[0].trim();
|
|
3642
3726
|
}
|
|
3643
3727
|
}
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3728
|
+
try {
|
|
3729
|
+
await this.deliverToInstance(instanceName, {
|
|
3730
|
+
type: "fleet_inbound",
|
|
3731
|
+
content: fullText,
|
|
3732
|
+
targetSession: instanceName,
|
|
3733
|
+
meta,
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
catch (err) {
|
|
3737
|
+
this.logger.warn({ err: err.message, instanceName }, "Classic wake/delivery failed");
|
|
3738
|
+
return;
|
|
3739
|
+
}
|
|
3650
3740
|
this.lastInboundUser.set(instanceName, msg.username);
|
|
3651
3741
|
this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
|
|
3652
3742
|
this.trackInboundMsg(instanceName, msg);
|
|
@@ -4326,6 +4416,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
4326
4416
|
lastActivity: this.lastActivityMs(inst.name) || null,
|
|
4327
4417
|
currentTask,
|
|
4328
4418
|
idle: this.getInstanceIdle(inst.name),
|
|
4419
|
+
state: this.getInstanceExecutionState(inst.name),
|
|
4329
4420
|
};
|
|
4330
4421
|
});
|
|
4331
4422
|
res.setHeader("Access-Control-Allow-Origin", "*");
|