@songsid/agend 2.1.12-beta.6 → 2.1.12-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-cli.js +13 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/backend/claude-code.d.ts +34 -1
- package/dist/backend/claude-code.js +123 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +21 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +13 -0
- package/dist/channel/adapters/discord.js +43 -0
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +7 -1
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +4 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cpu-profile.d.ts +32 -0
- package/dist/cpu-profile.js +221 -0
- package/dist/cpu-profile.js.map +1 -0
- package/dist/daemon.d.ts +27 -1
- package/dist/daemon.js +131 -30
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +22 -0
- package/dist/delivery-outbox.js +111 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-context.d.ts +10 -1
- package/dist/fleet-manager.d.ts +66 -2
- package/dist/fleet-manager.js +409 -62
- package/dist/fleet-manager.js.map +1 -1
- package/dist/locale.js +26 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +8 -0
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +15 -1
- package/dist/outbound-schemas.js +9 -3
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-input-residue.d.ts +2 -0
- package/dist/pane-input-residue.js +5 -1
- package/dist/pane-input-residue.js.map +1 -1
- package/dist/profile-control.d.ts +20 -0
- package/dist/profile-control.js +308 -0
- package/dist/profile-control.js.map +1 -0
- package/dist/runtime-cpu-profile.d.ts +37 -0
- package/dist/runtime-cpu-profile.js +123 -0
- package/dist/runtime-cpu-profile.js.map +1 -0
- package/dist/scheduler/db.d.ts +29 -4
- package/dist/scheduler/db.js +96 -6
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/types.d.ts +33 -0
- package/dist/service-installer.d.ts +6 -0
- package/dist/service-installer.js +18 -2
- package/dist/service-installer.js.map +1 -1
- package/dist/topic-commands.d.ts +12 -1
- package/dist/topic-commands.js +33 -1
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
2
|
+
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
3
|
+
import { ProfileControlServer } from "./profile-control.js";
|
|
2
4
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
3
5
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -57,6 +59,51 @@ import { DeliveryOutbox } from "./delivery-outbox.js";
|
|
|
57
59
|
// active manager/target generation pair owns the lane until the daemon reports
|
|
58
60
|
// a state transition or that generation is replaced.
|
|
59
61
|
export const DURABLE_DELIVERY_LANE_ALERT_MS = 35 * 60_000;
|
|
62
|
+
/**
|
|
63
|
+
* #1335: Cap an unfiltered task list at 100 rows (most recently updated first).
|
|
64
|
+
* Exported so the production branch can be tested directly without starting a fleet.
|
|
65
|
+
* Filtered calls pass-through unchanged. Empty strings count as "not set" (P3).
|
|
66
|
+
* #1336: generic over the row shape so it works on both full Task and compact rows.
|
|
67
|
+
*/
|
|
68
|
+
export const TASK_LIST_CAP = 100;
|
|
69
|
+
/**
|
|
70
|
+
* #1336: the non-terminal statuses a bare `list` returns by default. `done`
|
|
71
|
+
* and `cancelled` are excluded unless the caller passes `filter_status`.
|
|
72
|
+
*/
|
|
73
|
+
export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked"];
|
|
74
|
+
/**
|
|
75
|
+
* #1336 P2: shared normalization for filter_status across both task handlers
|
|
76
|
+
* and the cap helper. Trims whitespace before dropping empties, so
|
|
77
|
+
* " \t " and [" ", "\t"] both normalize to undefined (not treated as explicit
|
|
78
|
+
* filters). The same result drives the live-only default and the cap decision.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeStatusFilter(v) {
|
|
81
|
+
if (typeof v === "string") {
|
|
82
|
+
const s = v.trim();
|
|
83
|
+
return s || undefined;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(v) && v.every(x => typeof x === "string")) {
|
|
86
|
+
const arr = [...new Set(v.map(s => s.trim()).filter(s => s.length > 0))];
|
|
87
|
+
return arr.length > 0 ? arr : undefined;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
92
|
+
// Use the shared normalizer so " \t " is treated identically to undefined.
|
|
93
|
+
const normalized = normalizeStatusFilter(filterStatus);
|
|
94
|
+
const hasStatusFilter = Array.isArray(normalized) ? normalized.length > 0 : !!normalized;
|
|
95
|
+
const isFiltered = !!filterAssignee || hasStatusFilter;
|
|
96
|
+
if (!isFiltered && tasks.length > TASK_LIST_CAP) {
|
|
97
|
+
const omitted = tasks.length - TASK_LIST_CAP;
|
|
98
|
+
tasks.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
99
|
+
return {
|
|
100
|
+
tasks: tasks.slice(0, TASK_LIST_CAP),
|
|
101
|
+
omitted,
|
|
102
|
+
hint: `${omitted} older task(s) omitted — use filter_assignee or filter_status to narrow results`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return tasks;
|
|
106
|
+
}
|
|
60
107
|
import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
|
|
61
108
|
import { handleViewRequest, isViewPath } from "./view-api.js";
|
|
62
109
|
import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
|
|
@@ -571,6 +618,7 @@ export class FleetManager {
|
|
|
571
618
|
/** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
|
|
572
619
|
fullRestartLauncher = launchFullRestartHelper;
|
|
573
620
|
eventLogPruneTimer = null;
|
|
621
|
+
outboxPruneTimer = null;
|
|
574
622
|
logRotateTimer = null;
|
|
575
623
|
discordPresenceTimer = null;
|
|
576
624
|
discordPresenceEagerTimer = null;
|
|
@@ -1543,6 +1591,85 @@ export class FleetManager {
|
|
|
1543
1591
|
child.once("error", err => this.failUpdateProgress(err.message));
|
|
1544
1592
|
child.unref();
|
|
1545
1593
|
}
|
|
1594
|
+
async handleProfileSlash(data, adapterId) {
|
|
1595
|
+
const route = this.routing.resolve(data.channelId);
|
|
1596
|
+
await this.handleGeneralProfile(route?.kind === "general" ? route.name : undefined, data.userId, adapterId, data.options?.seconds, data.respond);
|
|
1597
|
+
}
|
|
1598
|
+
/** Typed /profile belongs only to Telegram's General dispatcher. */
|
|
1599
|
+
async runProfileCommand(msg, seconds) {
|
|
1600
|
+
if (msg.source !== "telegram")
|
|
1601
|
+
return;
|
|
1602
|
+
const adapterId = msg.adapterId ?? this.getPrimaryAdapterId();
|
|
1603
|
+
const route = msg.threadId ? this.routing.resolve(msg.threadId) : undefined;
|
|
1604
|
+
const general = msg.threadId ? (route?.kind === "general" ? route.name : undefined)
|
|
1605
|
+
: Object.keys(this.fleetConfig?.instances ?? {}).find(name => this.fleetConfig.instances[name].general_topic
|
|
1606
|
+
&& this.getInstanceAdapterId(name) === adapterId);
|
|
1607
|
+
const adapter = adapterId ? this.adapters.get(adapterId) : this.adapter;
|
|
1608
|
+
if (!adapter)
|
|
1609
|
+
return;
|
|
1610
|
+
await this.handleGeneralProfile(general, msg.userId, adapterId, seconds, async (text) => (await adapter.sendText(msg.chatId, text, { threadId: msg.threadId })).messageId);
|
|
1611
|
+
}
|
|
1612
|
+
async handleGeneralProfile(general, userId, ingressAdapterId, seconds, respond) {
|
|
1613
|
+
if (!general || !this.fleetConfig?.instances[general]?.general_topic) {
|
|
1614
|
+
await respond(t("profile.general_only"));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
const ownerId = this.getInstanceAdapterId(general);
|
|
1618
|
+
if (!ownerId || ownerId !== ingressAdapterId) {
|
|
1619
|
+
await respond(t("not_authorized"));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const gate = this.fleetAdminGate(userId, ownerId);
|
|
1623
|
+
if (gate !== "ok") {
|
|
1624
|
+
await respond(t(gate === "disabled" ? "profile.disabled" : "not_authorized"));
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
const adapter = this.getAdapterForInstance(general);
|
|
1628
|
+
const group = String(this.getGroupIdForInstance(general) ?? "");
|
|
1629
|
+
const topic = this.fleetConfig.instances[general].topic_id?.toString();
|
|
1630
|
+
if (!adapter || this.worlds.get(ownerId)?.adapter !== adapter || !group) {
|
|
1631
|
+
await respond(t("profile.unavailable"));
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
let duration;
|
|
1635
|
+
try {
|
|
1636
|
+
duration = profileDuration(seconds);
|
|
1637
|
+
}
|
|
1638
|
+
catch {
|
|
1639
|
+
await respond(t("profile.invalid"));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
let ticket;
|
|
1643
|
+
try {
|
|
1644
|
+
ticket = await this.startCpuProfile(duration);
|
|
1645
|
+
}
|
|
1646
|
+
catch (err) {
|
|
1647
|
+
await respond(err instanceof ProfileBusyError ? t("profile.busy", String(err.remainingSeconds)) : t("profile.unavailable"));
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
// A long recording must not hold a Discord interaction (or the inbound handler) open.
|
|
1651
|
+
const initial = Promise.resolve().then(() => respond(t("profile.started", String(ticket.seconds))));
|
|
1652
|
+
void (async () => {
|
|
1653
|
+
await initial.catch(err => this.logger.warn({ err }, "CPU profile acknowledgement failed"));
|
|
1654
|
+
let message;
|
|
1655
|
+
try {
|
|
1656
|
+
const result = await ticket.done;
|
|
1657
|
+
const size = result.bytes === null ? t("profile.size_unknown") : `${(result.bytes / 1048576).toFixed(2)} MiB`;
|
|
1658
|
+
message = t("profile.saved", result.path, size);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
message = t("profile.failed");
|
|
1662
|
+
}
|
|
1663
|
+
// Never send a delayed artifact path to a replacement adapter/topic/world.
|
|
1664
|
+
if (this.shuttingDown || this.getInstanceAdapterId(general) !== ownerId
|
|
1665
|
+
|| this.getAdapterForInstance(general) !== adapter || String(this.getGroupIdForInstance(general) ?? "") !== group
|
|
1666
|
+
|| this.fleetConfig?.instances[general]?.topic_id?.toString() !== topic
|
|
1667
|
+
|| !this.fleetConfig?.instances[general]?.general_topic)
|
|
1668
|
+
return;
|
|
1669
|
+
await adapter.sendText(group, message, { threadId: topic });
|
|
1670
|
+
})().catch(err => this.logger.warn({ err }, "CPU profile General notice failed"));
|
|
1671
|
+
await initial.catch(() => { });
|
|
1672
|
+
}
|
|
1546
1673
|
async handleRestartSlash(data, adapterId) {
|
|
1547
1674
|
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
1548
1675
|
await data.respond(t("not_authorized"));
|
|
@@ -2314,6 +2441,36 @@ export class FleetManager {
|
|
|
2314
2441
|
return "disabled";
|
|
2315
2442
|
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2316
2443
|
}
|
|
2444
|
+
runtimeCpuProfiler = null;
|
|
2445
|
+
cpuProfileControl = null;
|
|
2446
|
+
getCpuProfiler() {
|
|
2447
|
+
if (!this.runtimeCpuProfiler || (this.runtimeCpuProfiler.closed && !this.shuttingDown)) {
|
|
2448
|
+
this.runtimeCpuProfiler = new RuntimeCpuProfiler({ dataDir: this.dataDir,
|
|
2449
|
+
logger: { info: message => this.logger.info(message), warn: message => this.logger.warn(message) } });
|
|
2450
|
+
}
|
|
2451
|
+
return this.runtimeCpuProfiler;
|
|
2452
|
+
}
|
|
2453
|
+
/** Called only by cold CLI startup, after claiming the fleet singleton. */
|
|
2454
|
+
async startCpuProfileControl() {
|
|
2455
|
+
if (this.shuttingDown || this.cpuProfileControl)
|
|
2456
|
+
return;
|
|
2457
|
+
const control = new ProfileControlServer(this.dataDir, this.getCpuProfiler());
|
|
2458
|
+
this.cpuProfileControl = control; // shutdown owns even an in-flight listen
|
|
2459
|
+
try {
|
|
2460
|
+
await control.listen();
|
|
2461
|
+
}
|
|
2462
|
+
catch (err) {
|
|
2463
|
+
if (this.cpuProfileControl === control)
|
|
2464
|
+
this.cpuProfileControl = null;
|
|
2465
|
+
throw err;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
startEnvironmentCpuProfile() { return this.getCpuProfiler().startFromEnvironment(); }
|
|
2469
|
+
startCpuProfile(seconds) {
|
|
2470
|
+
if (this.shuttingDown)
|
|
2471
|
+
return Promise.reject(new Error("Fleet is stopping."));
|
|
2472
|
+
return this.getCpuProfiler().start(seconds);
|
|
2473
|
+
}
|
|
2317
2474
|
/**
|
|
2318
2475
|
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
2319
2476
|
*
|
|
@@ -2446,6 +2603,9 @@ export class FleetManager {
|
|
|
2446
2603
|
else if (data.command === "update") {
|
|
2447
2604
|
await this.handleUpdateSlash(data, adapterId);
|
|
2448
2605
|
}
|
|
2606
|
+
else if (data.command === "profile") {
|
|
2607
|
+
await this.handleProfileSlash(data, adapterId);
|
|
2608
|
+
}
|
|
2449
2609
|
else if (data.command === "doctor") {
|
|
2450
2610
|
await data.respond(await this.runBackendDoctor());
|
|
2451
2611
|
}
|
|
@@ -4116,6 +4276,11 @@ export class FleetManager {
|
|
|
4116
4276
|
this.pruneEventLog();
|
|
4117
4277
|
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
4118
4278
|
this.eventLogPruneTimer.unref?.();
|
|
4279
|
+
// #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
|
|
4280
|
+
// + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
|
|
4281
|
+
void this.pruneOutboxAndTasks();
|
|
4282
|
+
this.outboxPruneTimer = setInterval(() => { void this.pruneOutboxAndTasks(); }, 24 * 60 * 60_000);
|
|
4283
|
+
this.outboxPruneTimer.unref?.();
|
|
4119
4284
|
// Same shape for logs, and for the same reason: the only sweep that
|
|
4120
4285
|
// covered them lived inside the daily-summary callback, so it did not run at
|
|
4121
4286
|
// all when summaries were off.
|
|
@@ -5274,6 +5439,35 @@ export class FleetManager {
|
|
|
5274
5439
|
* the message came from — its own world's group_id. Two Telegram fleet
|
|
5275
5440
|
* worlds can number topics alike; a coincidence is dropped, never delivered.
|
|
5276
5441
|
*/
|
|
5442
|
+
/**
|
|
5443
|
+
* #1346: whether this copy may claim the shared dedup key for
|
|
5444
|
+
* command-like text. Fleet topics resolve an owning adapter and only its
|
|
5445
|
+
* copy proceeds; anything else (classic targets, unknown routing, no
|
|
5446
|
+
* identity) passes through to the existing handling.
|
|
5447
|
+
*/
|
|
5448
|
+
isOwnerCommandCopy(msg, threadId) {
|
|
5449
|
+
if (!msg.adapterId || threadId === undefined)
|
|
5450
|
+
return true;
|
|
5451
|
+
const target = this.resolveInboundTarget(msg, threadId);
|
|
5452
|
+
if (!target || target.kind === "classic")
|
|
5453
|
+
return true;
|
|
5454
|
+
const owner = this.getInstanceAdapterId(target.name);
|
|
5455
|
+
if (owner && msg.adapterId !== owner)
|
|
5456
|
+
return false;
|
|
5457
|
+
// #1346 6b: /cmd@otherbot is addressed elsewhere — ignore it even on the
|
|
5458
|
+
// owner's copy. An unknown username can't be judged: let it through (the
|
|
5459
|
+
// receiver gate above still applies). Same case-insensitive rule as the
|
|
5460
|
+
// Telegram classic branch.
|
|
5461
|
+
// Hyphenated aliases count too (e.g. /install-cli): Telegram command
|
|
5462
|
+
// names cannot contain "-", but typed aliases can.
|
|
5463
|
+
const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
|
|
5464
|
+
if (suffix && owner) {
|
|
5465
|
+
const ownerUser = this.worlds.get(owner)?.botUsername;
|
|
5466
|
+
if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
|
|
5467
|
+
return false;
|
|
5468
|
+
}
|
|
5469
|
+
return true;
|
|
5470
|
+
}
|
|
5277
5471
|
resolveInboundTarget(msg, threadId) {
|
|
5278
5472
|
if (msg.source !== "telegram")
|
|
5279
5473
|
return this.routing.resolve(threadId);
|
|
@@ -5337,6 +5531,16 @@ export class FleetManager {
|
|
|
5337
5531
|
// EXCEPTION — classic channels with same-channel multi-bot: two bots may own
|
|
5338
5532
|
// separate agents in one channel, so each bot must process its OWN copy of
|
|
5339
5533
|
// the message (the @mention filter downstream decides who actually forwards).
|
|
5534
|
+
// #1346: a non-owner adapter's copy of command-like text must not burn
|
|
5535
|
+
// the shared dedup key — the owner's copy still has to run the command.
|
|
5536
|
+
// (Non-command input keeps first-wins delivery.) Classic channels keep
|
|
5537
|
+
// per-adapter keys — first-/start-contact onboarding needs every copy
|
|
5538
|
+
// to flow — so only fleet targets are judged here; the in-handler gates
|
|
5539
|
+
// (owner entry check, Discord ignore) cover classic instead.
|
|
5540
|
+
if (msg.messageId && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
|
|
5541
|
+
this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5340
5544
|
// Key the dedup per-adapter there so a sibling bot's copy isn't dropped.
|
|
5341
5545
|
if (msg.messageId) {
|
|
5342
5546
|
const classicCid = threadId || msg.chatId;
|
|
@@ -5665,11 +5869,14 @@ export class FleetManager {
|
|
|
5665
5869
|
// Unregistered group: ignore
|
|
5666
5870
|
return;
|
|
5667
5871
|
}
|
|
5668
|
-
// General topic:
|
|
5872
|
+
// General topic: Discord /xxx gets the system note first (never a
|
|
5873
|
+
// command); other sources fall through to the handlers below.
|
|
5669
5874
|
const generalInstance = this.findGeneralInstance(msg.adapterId);
|
|
5875
|
+
if (generalInstance && await this.replyDiscordNotACommand(msg, generalInstance))
|
|
5876
|
+
return;
|
|
5670
5877
|
if (generalInstance && await this.topicCommands.handleInstanceCommand(msg, generalInstance))
|
|
5671
5878
|
return;
|
|
5672
|
-
if (await this.topicCommands.handleGeneralCommand(msg))
|
|
5879
|
+
if (generalInstance && await this.topicCommands.handleGeneralCommand(msg, generalInstance))
|
|
5673
5880
|
return;
|
|
5674
5881
|
// Forward to General Topic instance if configured
|
|
5675
5882
|
if (generalInstance) {
|
|
@@ -5754,13 +5961,18 @@ export class FleetManager {
|
|
|
5754
5961
|
return;
|
|
5755
5962
|
}
|
|
5756
5963
|
const instanceName = target.name;
|
|
5964
|
+
// #1346: on Discord, plain-text /xxx runs no command — the owner posts
|
|
5965
|
+
// the system note before the handlers below ever see the text.
|
|
5966
|
+
if (await this.replyDiscordNotACommand(msg, instanceName)) {
|
|
5967
|
+
return;
|
|
5968
|
+
}
|
|
5757
5969
|
// Intercept /ctx /compact /collab in ANY topic (including general)
|
|
5758
5970
|
if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) {
|
|
5759
5971
|
return;
|
|
5760
5972
|
}
|
|
5761
5973
|
// Intercept admin commands (/status, /restart, /sysinfo) in general topics
|
|
5762
5974
|
const instanceConfig = this.fleetConfig?.instances[instanceName];
|
|
5763
|
-
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) {
|
|
5975
|
+
if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg, instanceName)) {
|
|
5764
5976
|
return;
|
|
5765
5977
|
}
|
|
5766
5978
|
// Bind instance to the adapter that delivered this message
|
|
@@ -6636,45 +6848,74 @@ export class FleetManager {
|
|
|
6636
6848
|
const asStatus = (v) => {
|
|
6637
6849
|
return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
6638
6850
|
};
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6851
|
+
// #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
|
|
6852
|
+
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
6853
|
+
const asStatusFilter = normalizeStatusFilter;
|
|
6854
|
+
// #1336: small write ack — identifying fields only.
|
|
6855
|
+
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
6856
|
+
try {
|
|
6857
|
+
switch (action) {
|
|
6858
|
+
case "create": {
|
|
6859
|
+
const title = asStr(args.title);
|
|
6860
|
+
if (!title)
|
|
6861
|
+
return { error: "title is required" };
|
|
6862
|
+
return ack(db.createTask({
|
|
6863
|
+
title,
|
|
6864
|
+
description: asStr(args.description),
|
|
6865
|
+
priority: asPriority(args.priority),
|
|
6866
|
+
assignee: asStr(args.assignee),
|
|
6867
|
+
depends_on: asStrArr(args.depends_on),
|
|
6868
|
+
created_by: instance,
|
|
6869
|
+
}));
|
|
6870
|
+
}
|
|
6871
|
+
case "list": {
|
|
6872
|
+
const filterAssignee = asStr(args.filter_assignee) || undefined;
|
|
6873
|
+
const explicitStatus = asStatusFilter(args.filter_status);
|
|
6874
|
+
// #1336 item 1: default to live-only (no done/cancelled) unless the
|
|
6875
|
+
// caller explicitly asked for a status.
|
|
6876
|
+
const effectiveStatus = explicitStatus ?? LIVE_TASK_STATUSES;
|
|
6877
|
+
const verbose = args.verbose === true;
|
|
6878
|
+
const tasks = verbose
|
|
6879
|
+
? db.listTasks({ assignee: filterAssignee, status: effectiveStatus, verbose: true })
|
|
6880
|
+
: db.listTasks({ assignee: filterAssignee, status: effectiveStatus });
|
|
6881
|
+
// #1335: cap unfiltered list. The default live-only filter does not
|
|
6882
|
+
// count as an explicit filter for the cap decision.
|
|
6883
|
+
return applyTaskListCap(tasks, filterAssignee, explicitStatus);
|
|
6884
|
+
}
|
|
6885
|
+
case "get": {
|
|
6886
|
+
const id = asStr(args.id);
|
|
6887
|
+
if (!id)
|
|
6888
|
+
return { error: "id is required" };
|
|
6889
|
+
return db.getTaskByPrefix(id);
|
|
6890
|
+
}
|
|
6891
|
+
case "claim": {
|
|
6892
|
+
const id = asStr(args.id);
|
|
6893
|
+
if (!id)
|
|
6894
|
+
return { error: "id is required" };
|
|
6895
|
+
return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
|
|
6896
|
+
}
|
|
6897
|
+
case "done": {
|
|
6898
|
+
const id = asStr(args.id);
|
|
6899
|
+
if (!id)
|
|
6900
|
+
return { error: "id is required" };
|
|
6901
|
+
return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
|
|
6902
|
+
}
|
|
6903
|
+
case "update": {
|
|
6904
|
+
const id = asStr(args.id);
|
|
6905
|
+
if (!id)
|
|
6906
|
+
return { error: "id is required" };
|
|
6907
|
+
return ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
6908
|
+
status: asStatus(args.status),
|
|
6909
|
+
assignee: asStr(args.assignee),
|
|
6910
|
+
result: asStr(args.result),
|
|
6911
|
+
priority: asPriority(args.priority),
|
|
6912
|
+
}));
|
|
6913
|
+
}
|
|
6914
|
+
default: return { error: `Unknown task action: ${action}` };
|
|
6676
6915
|
}
|
|
6677
|
-
|
|
6916
|
+
}
|
|
6917
|
+
catch (err) {
|
|
6918
|
+
return { error: err.message };
|
|
6678
6919
|
}
|
|
6679
6920
|
}
|
|
6680
6921
|
async handleSetDisplayNameHttp(instance, name) {
|
|
@@ -6745,11 +6986,17 @@ export class FleetManager {
|
|
|
6745
6986
|
}
|
|
6746
6987
|
const db = this.scheduler.db;
|
|
6747
6988
|
const action = payload.action;
|
|
6989
|
+
// #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
|
|
6990
|
+
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
6991
|
+
const asStatusFilter = normalizeStatusFilter;
|
|
6992
|
+
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
6748
6993
|
try {
|
|
6749
6994
|
let result;
|
|
6995
|
+
// Full task kept for activity logging; the IPC reply uses the compact ack.
|
|
6996
|
+
let logTask;
|
|
6750
6997
|
switch (action) {
|
|
6751
6998
|
case "create":
|
|
6752
|
-
|
|
6999
|
+
logTask = db.createTask({
|
|
6753
7000
|
title: payload.title,
|
|
6754
7001
|
description: payload.description,
|
|
6755
7002
|
priority: payload.priority,
|
|
@@ -6757,43 +7004,57 @@ export class FleetManager {
|
|
|
6757
7004
|
depends_on: payload.depends_on,
|
|
6758
7005
|
created_by: meta.instance_name || instanceName,
|
|
6759
7006
|
});
|
|
7007
|
+
result = ack(logTask);
|
|
6760
7008
|
break;
|
|
6761
|
-
case "list":
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
7009
|
+
case "list": {
|
|
7010
|
+
// P3: normalize empty strings to undefined — SchedulerDb.listTasks
|
|
7011
|
+
// ignores them but the cap logic must treat them as "not filtered".
|
|
7012
|
+
const filterAssignee = payload.filter_assignee || undefined;
|
|
7013
|
+
const explicitStatus = asStatusFilter(payload.filter_status);
|
|
7014
|
+
// #1336 item 1: default to live-only (no done/cancelled).
|
|
7015
|
+
const effectiveStatus = explicitStatus ?? LIVE_TASK_STATUSES;
|
|
7016
|
+
const verbose = payload.verbose === true;
|
|
7017
|
+
const tasks = verbose
|
|
7018
|
+
? db.listTasks({ assignee: filterAssignee, status: effectiveStatus, verbose: true })
|
|
7019
|
+
: db.listTasks({ assignee: filterAssignee, status: effectiveStatus });
|
|
7020
|
+
// #1335: cap unfiltered list — the default live-only filter does not
|
|
7021
|
+
// count as an explicit filter.
|
|
7022
|
+
result = applyTaskListCap(tasks, filterAssignee, explicitStatus);
|
|
7023
|
+
break;
|
|
7024
|
+
}
|
|
7025
|
+
case "get":
|
|
7026
|
+
result = db.getTaskByPrefix(payload.id);
|
|
6766
7027
|
break;
|
|
6767
7028
|
case "claim":
|
|
6768
|
-
|
|
7029
|
+
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
|
|
7030
|
+
result = ack(logTask);
|
|
6769
7031
|
break;
|
|
6770
7032
|
case "done":
|
|
6771
|
-
|
|
7033
|
+
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
|
|
7034
|
+
result = ack(logTask);
|
|
6772
7035
|
break;
|
|
6773
7036
|
case "update":
|
|
6774
|
-
|
|
7037
|
+
logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
|
|
6775
7038
|
status: payload.status,
|
|
6776
7039
|
assignee: payload.assignee,
|
|
6777
7040
|
result: payload.result,
|
|
6778
7041
|
priority: payload.priority,
|
|
6779
7042
|
});
|
|
7043
|
+
result = ack(logTask);
|
|
6780
7044
|
break;
|
|
6781
7045
|
default:
|
|
6782
7046
|
throw new Error(`Unknown task action: ${action}`);
|
|
6783
7047
|
}
|
|
6784
7048
|
ipc.send({ type: "fleet_task_response", fleetRequestId, result });
|
|
6785
7049
|
// Activity log for task lifecycle events
|
|
6786
|
-
if (action === "create") {
|
|
6787
|
-
|
|
6788
|
-
this.eventLog?.logActivity("task_update", instanceName, `created task: ${t.title}`, t.assignee ?? undefined);
|
|
7050
|
+
if (action === "create" && logTask) {
|
|
7051
|
+
this.eventLog?.logActivity("task_update", instanceName, `created task: ${logTask.title}`, logTask.assignee ?? undefined);
|
|
6789
7052
|
}
|
|
6790
|
-
else if (action === "claim") {
|
|
6791
|
-
|
|
6792
|
-
this.eventLog?.logActivity("task_update", instanceName, `claimed: ${t.title}`);
|
|
7053
|
+
else if (action === "claim" && logTask) {
|
|
7054
|
+
this.eventLog?.logActivity("task_update", instanceName, `claimed: ${logTask.title}`);
|
|
6793
7055
|
}
|
|
6794
|
-
else if (action === "done") {
|
|
6795
|
-
|
|
6796
|
-
this.eventLog?.logActivity("task_update", instanceName, `completed: ${t.title}`, undefined, t.result ?? undefined);
|
|
7056
|
+
else if (action === "done" && logTask) {
|
|
7057
|
+
this.eventLog?.logActivity("task_update", instanceName, `completed: ${logTask.title}`, undefined, logTask.result ?? undefined);
|
|
6797
7058
|
}
|
|
6798
7059
|
}
|
|
6799
7060
|
catch (err) {
|
|
@@ -8165,6 +8426,42 @@ export class FleetManager {
|
|
|
8165
8426
|
this.logger.warn({ err }, "Event log prune failed");
|
|
8166
8427
|
}
|
|
8167
8428
|
}
|
|
8429
|
+
/** #1335: Prune delivery-outbox.db and Task Board in chunked async passes. */
|
|
8430
|
+
async pruneOutboxAndTasks() {
|
|
8431
|
+
const days = this.fleetConfig?.defaults?.retention_days ?? 30;
|
|
8432
|
+
// Outbox prune
|
|
8433
|
+
const outbox = this.deliveryOutbox;
|
|
8434
|
+
if (outbox?.isOpen) {
|
|
8435
|
+
try {
|
|
8436
|
+
const { pruned, durationMs } = await outbox.prune(days);
|
|
8437
|
+
if (pruned > 0) {
|
|
8438
|
+
this.logger.info({ pruned, durationMs: Math.round(durationMs) }, "Delivery outbox pruned");
|
|
8439
|
+
}
|
|
8440
|
+
}
|
|
8441
|
+
catch (err) {
|
|
8442
|
+
this.logger.warn({ err }, "Delivery outbox prune failed");
|
|
8443
|
+
}
|
|
8444
|
+
}
|
|
8445
|
+
// Task Board prune
|
|
8446
|
+
if (this.scheduler?.db) {
|
|
8447
|
+
try {
|
|
8448
|
+
const pruned = await this.scheduler.db.pruneOldTasks(days);
|
|
8449
|
+
if (pruned > 0) {
|
|
8450
|
+
this.logger.info({ pruned }, "Task board pruned");
|
|
8451
|
+
}
|
|
8452
|
+
}
|
|
8453
|
+
catch (err) {
|
|
8454
|
+
this.logger.warn({ err }, "Task board prune failed");
|
|
8455
|
+
}
|
|
8456
|
+
}
|
|
8457
|
+
}
|
|
8458
|
+
/**
|
|
8459
|
+
* #1335: True when the delivery_id was in the outbox, was pruned by
|
|
8460
|
+
* retention, and the caller is the original source or target (#1340 P2 🔒).
|
|
8461
|
+
*/
|
|
8462
|
+
wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) {
|
|
8463
|
+
return this.deliveryOutbox?.wasDeliveryIdPrunedForCaller(deliveryId, callerInstance) ?? false;
|
|
8464
|
+
}
|
|
8168
8465
|
openEventLog() {
|
|
8169
8466
|
const dbPath = join(this.dataDir, "events.db");
|
|
8170
8467
|
try {
|
|
@@ -10941,13 +11238,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
10941
11238
|
this.failoverActive.clear();
|
|
10942
11239
|
}
|
|
10943
11240
|
// ── Classic Channel Methods ──────────────────────────────────────────
|
|
11241
|
+
/**
|
|
11242
|
+
* #1346: on Discord, plain-text /xxx is never a command — the slash menu
|
|
11243
|
+
* is. Both fleet call sites run this BEFORE the command handlers, so even
|
|
11244
|
+
* the owning adapter's copy never executes text. The owning adapter posts
|
|
11245
|
+
* one system note and consumes the message so it never reaches the agent;
|
|
11246
|
+
* other adapters' copies stay silent (and the shared dedup key means
|
|
11247
|
+
* exactly one copy gets here). Telegram keeps text commands working —
|
|
11248
|
+
* text is its command path — and other sources pass through untouched.
|
|
11249
|
+
*/
|
|
11250
|
+
async replyDiscordNotACommand(msg, instanceName) {
|
|
11251
|
+
if (msg.source !== "discord")
|
|
11252
|
+
return false;
|
|
11253
|
+
if (!/^\/\w/.test(msg.text?.trim() ?? ""))
|
|
11254
|
+
return false;
|
|
11255
|
+
const owner = this.getInstanceAdapterId(instanceName);
|
|
11256
|
+
if (msg.adapterId && owner && msg.adapterId !== owner)
|
|
11257
|
+
return false;
|
|
11258
|
+
const adapter = this.worlds.get(owner ?? msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
11259
|
+
if (!adapter)
|
|
11260
|
+
return false;
|
|
11261
|
+
await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
|
|
11262
|
+
return true;
|
|
11263
|
+
}
|
|
10944
11264
|
/** Handle a message in a classic channel: log it, forward only /chat messages */
|
|
10945
11265
|
async handleClassicChannelMessage(instanceName, msg) {
|
|
10946
11266
|
const text = msg.text ?? "";
|
|
10947
11267
|
const channelId = msg.threadId ?? msg.chatId;
|
|
10948
11268
|
const isCollabMode = this.classicChannels?.isCollab(channelId, msg.adapterId) ?? false;
|
|
10949
|
-
//
|
|
11269
|
+
// #1346: Discord ClassicBot channels ignore plain-text /xxx silently —
|
|
11270
|
+
// no warning (multi-bot groups must not all warn), no command, and no
|
|
11271
|
+
// forward to the agent either. The received-reaction and chat-log paths
|
|
11272
|
+
// below still run (a /chat ack is not a reply). Telegram keeps working
|
|
11273
|
+
// (text is its command path there).
|
|
11274
|
+
const discordSlashText = msg.source === "discord" && /^\/\w/.test(text.trim());
|
|
11275
|
+
// Handle /ctx in classic mode — always, regardless of collab mode.
|
|
11276
|
+
// Only the adapter that owns an entry in this channel answers; the
|
|
11277
|
+
// per-adapter dedup key already scopes copies, this guards the rest.
|
|
11278
|
+
// Discord /chat still flows through (its received-reaction below is an
|
|
11279
|
+
// ack, not a reply); only its forward is skipped.
|
|
11280
|
+
if (discordSlashText && !/^\/chat(\s|$)/.test(text.trim()))
|
|
11281
|
+
return;
|
|
10950
11282
|
if (text === "/ctx" || text.startsWith("/ctx@")) {
|
|
11283
|
+
if (!this.classicChannels?.getInstanceByChannel(channelId, msg.adapterId))
|
|
11284
|
+
return;
|
|
10951
11285
|
const reply = await this.topicCommands.getCtxText(instanceName);
|
|
10952
11286
|
const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
|
|
10953
11287
|
if (classicAdapter)
|
|
@@ -11049,7 +11383,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11049
11383
|
finalText = `${tags}\n${finalText}`;
|
|
11050
11384
|
}
|
|
11051
11385
|
}
|
|
11052
|
-
|
|
11386
|
+
// #1346: Discord typed /xxx is never forwarded (reacts above already ran).
|
|
11387
|
+
if (!discordSlashText)
|
|
11388
|
+
await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
|
|
11053
11389
|
return;
|
|
11054
11390
|
}
|
|
11055
11391
|
// Normal mode: /chat trigger
|
|
@@ -11116,7 +11452,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
11116
11452
|
.catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
|
|
11117
11453
|
}
|
|
11118
11454
|
}
|
|
11119
|
-
|
|
11455
|
+
// #1346: Discord typed /chat gets its received-reaction above but is
|
|
11456
|
+
// never forwarded to the agent.
|
|
11457
|
+
if (!discordSlashText)
|
|
11458
|
+
await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
|
|
11120
11459
|
}
|
|
11121
11460
|
/** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
|
|
11122
11461
|
async saveClassicAttachment(instanceName, msg) {
|
|
@@ -12554,6 +12893,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12554
12893
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
12555
12894
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
12556
12895
|
this.shuttingDown = true;
|
|
12896
|
+
const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
|
|
12557
12897
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
12558
12898
|
// Release held delivery promises before awaiting daemon shutdown, then
|
|
12559
12899
|
// reject spawn work which has not started. Otherwise a storm backoff could
|
|
@@ -12582,6 +12922,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12582
12922
|
// A login/install window is a dedicated tmux server with its own TTL
|
|
12583
12923
|
// timer and HTTP listener living in THIS process: without an explicit
|
|
12584
12924
|
// shutdown it would outlive us as an owner-less login CLI (sol B3).
|
|
12925
|
+
await profileStopped;
|
|
12926
|
+
await this.cpuProfileControl?.close();
|
|
12927
|
+
this.cpuProfileControl = null;
|
|
12585
12928
|
await this.shutdownLoginWindows();
|
|
12586
12929
|
// Cancel adapter retry timers
|
|
12587
12930
|
for (const state of this.adapterState.values()) {
|
|
@@ -12604,6 +12947,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
12604
12947
|
clearInterval(this.eventLogPruneTimer);
|
|
12605
12948
|
this.eventLogPruneTimer = null;
|
|
12606
12949
|
}
|
|
12950
|
+
if (this.outboxPruneTimer) {
|
|
12951
|
+
clearInterval(this.outboxPruneTimer);
|
|
12952
|
+
this.outboxPruneTimer = null;
|
|
12953
|
+
}
|
|
12607
12954
|
if (this.replyObligationTimer) {
|
|
12608
12955
|
clearInterval(this.replyObligationTimer);
|
|
12609
12956
|
this.replyObligationTimer = null;
|