@songsid/agend 2.1.1-beta.13 → 2.1.1-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-path.js +15 -6
- package/dist/access-path.js.map +1 -1
- package/dist/classic-channel-manager.js +1 -5
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +53 -24
- package/dist/cli.js.map +1 -1
- package/dist/cost-guard.d.ts +3 -1
- package/dist/cost-guard.js +3 -1
- package/dist/cost-guard.js.map +1 -1
- package/dist/daemon.d.ts +21 -0
- package/dist/daemon.js +318 -228
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.js +4 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +45 -0
- package/dist/fleet-manager.js +147 -7
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.js +5 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +9 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/scheduler/db.js +3 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/tmux-control.d.ts +10 -4
- package/dist/tmux-control.js +19 -6
- package/dist/tmux-control.js.map +1 -1
- package/dist/ui/view.html +10 -1
- package/dist/usage/providers.d.ts +24 -0
- package/dist/usage/providers.js +43 -9
- package/dist/usage/providers.js.map +1 -1
- package/dist/view-api.js +1 -0
- package/dist/view-api.js.map +1 -1
- package/package.json +4 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -177,6 +177,9 @@ export class FleetManager {
|
|
|
177
177
|
healthServer = null;
|
|
178
178
|
healthPortRetried = false;
|
|
179
179
|
updateCheckTimer = null;
|
|
180
|
+
eventLogPruneTimer = null;
|
|
181
|
+
/** Days of event/activity history to keep. */
|
|
182
|
+
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
180
183
|
watchdogTimer = null;
|
|
181
184
|
startedAt = 0;
|
|
182
185
|
// Mirror topic: buffer cross-instance messages, flush every 3s
|
|
@@ -898,7 +901,7 @@ export class FleetManager {
|
|
|
898
901
|
}
|
|
899
902
|
const pidPath = join(this.dataDir, "fleet.pid");
|
|
900
903
|
writeFileSync(pidPath, String(process.pid), "utf-8");
|
|
901
|
-
this.eventLog =
|
|
904
|
+
this.eventLog = this.openEventLog();
|
|
902
905
|
// Initialize classic channel manager. The primary adapter (channels[0])
|
|
903
906
|
// migrates legacy single-bot entries and names without a suffix. Classic
|
|
904
907
|
// routing does NOT go through the routing engine (single-key, can't hold two
|
|
@@ -1134,6 +1137,12 @@ export class FleetManager {
|
|
|
1134
1137
|
// Signal systemd: generals ready
|
|
1135
1138
|
sdNotify("READY=1");
|
|
1136
1139
|
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
1140
|
+
// EventLog.prune() existed but was never called, so `events` and `activity`
|
|
1141
|
+
// grew without bound for the life of the install. Prune once at startup and
|
|
1142
|
+
// daily after that; the timer is unref'd so it never holds the loop open.
|
|
1143
|
+
this.pruneEventLog();
|
|
1144
|
+
this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
|
|
1145
|
+
this.eventLogPruneTimer.unref?.();
|
|
1137
1146
|
// Phase 2: Start remaining instances with staggered concurrency
|
|
1138
1147
|
if (others.length > 0) {
|
|
1139
1148
|
await this.startInstancesWithConcurrency(others, topicMode);
|
|
@@ -3454,6 +3463,107 @@ export class FleetManager {
|
|
|
3454
3463
|
this.collabInstances.add(instanceName);
|
|
3455
3464
|
return true;
|
|
3456
3465
|
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Open the event log, tolerating a corrupt file.
|
|
3468
|
+
*
|
|
3469
|
+
* `events.db` holds history only — event rows and the activity feed. Nothing the
|
|
3470
|
+
* fleet needs to run depends on it, and every consumer already uses
|
|
3471
|
+
* `this.eventLog?.`. An unguarded `new EventLog(...)` here meant a corrupt or
|
|
3472
|
+
* unreadable history file (a truncated WAL after a hard kill, a full disk)
|
|
3473
|
+
* threw during startAll and the WHOLE FLEET FAILED TO BOOT — trading every
|
|
3474
|
+
* running agent for a file whose only job is reporting.
|
|
3475
|
+
*
|
|
3476
|
+
* So: try, move a bad file aside and retry once with a fresh one, and if even
|
|
3477
|
+
* that fails carry on without an event log.
|
|
3478
|
+
*/
|
|
3479
|
+
/** Drop event/activity rows older than the retention window. Best-effort. */
|
|
3480
|
+
pruneEventLog() {
|
|
3481
|
+
try {
|
|
3482
|
+
this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
|
|
3483
|
+
}
|
|
3484
|
+
catch (err) {
|
|
3485
|
+
this.logger.warn({ err }, "Event log prune failed");
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
openEventLog() {
|
|
3489
|
+
const dbPath = join(this.dataDir, "events.db");
|
|
3490
|
+
try {
|
|
3491
|
+
return new EventLog(dbPath);
|
|
3492
|
+
}
|
|
3493
|
+
catch (err) {
|
|
3494
|
+
this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
|
|
3495
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3496
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
3497
|
+
try {
|
|
3498
|
+
renameSync(`${dbPath}${suffix}`, `${dbPath}${suffix}.corrupt-${stamp}`);
|
|
3499
|
+
}
|
|
3500
|
+
catch { /* may not exist */ }
|
|
3501
|
+
}
|
|
3502
|
+
try {
|
|
3503
|
+
return new EventLog(dbPath);
|
|
3504
|
+
}
|
|
3505
|
+
catch (retryErr) {
|
|
3506
|
+
// History is worth losing; a fleet that won't start is not.
|
|
3507
|
+
this.logger.error({ err: retryErr, dbPath }, "Could not open a fresh events.db — continuing without event logging");
|
|
3508
|
+
return null;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Report a fleet-level fault (not attributable to one instance) to the General
|
|
3514
|
+
* topic, so the operator learns about it without reading daemon.log.
|
|
3515
|
+
*
|
|
3516
|
+
* Throttled per distinct message: an unhandled rejection typically comes from a
|
|
3517
|
+
* loop (a poller, a repeating timer), and one channel message per occurrence
|
|
3518
|
+
* would bury the topic — which is worse than silence. First occurrence goes out
|
|
3519
|
+
* immediately, repeats are suppressed for THROTTLE_MS and then re-sent with a
|
|
3520
|
+
* count.
|
|
3521
|
+
*
|
|
3522
|
+
* The log line is written by the caller regardless: if every adapter is down,
|
|
3523
|
+
* the only notification path is the one that is broken.
|
|
3524
|
+
*/
|
|
3525
|
+
notifyFleetError(text) {
|
|
3526
|
+
const now = Date.now();
|
|
3527
|
+
const key = text.slice(0, 200);
|
|
3528
|
+
const seen = this.fleetErrorNotices.get(key);
|
|
3529
|
+
if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
|
|
3530
|
+
seen.suppressed++;
|
|
3531
|
+
return;
|
|
3532
|
+
}
|
|
3533
|
+
const suppressed = seen?.suppressed ?? 0;
|
|
3534
|
+
this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
|
|
3535
|
+
// Bound the map: it is keyed by message text, and a message with a varying
|
|
3536
|
+
// suffix (a path, an id) would otherwise grow it without limit.
|
|
3537
|
+
if (this.fleetErrorNotices.size > 100) {
|
|
3538
|
+
const oldest = this.fleetErrorNotices.keys().next().value;
|
|
3539
|
+
if (oldest !== undefined)
|
|
3540
|
+
this.fleetErrorNotices.delete(oldest);
|
|
3541
|
+
}
|
|
3542
|
+
const body = suppressed > 0
|
|
3543
|
+
? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
|
|
3544
|
+
: text;
|
|
3545
|
+
// Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
|
|
3546
|
+
// and a fleet-level fault is exactly when the General may be down. The topic
|
|
3547
|
+
// itself still exists, and notifyInstanceTopic only needs adapter + group +
|
|
3548
|
+
// topic_id to post into it.
|
|
3549
|
+
const general = Object.entries(this.fleetConfig?.instances ?? {})
|
|
3550
|
+
.find(([, config]) => config.general_topic === true)?.[0];
|
|
3551
|
+
if (general) {
|
|
3552
|
+
this.notifyInstanceTopic(general, body);
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
// No General instance — fall back to the primary channel's group.
|
|
3556
|
+
const channelCfg = this.getChannelConfig();
|
|
3557
|
+
const groupId = channelCfg?.group_id;
|
|
3558
|
+
if (this.adapter && groupId) {
|
|
3559
|
+
this.adapter.sendText(String(groupId), body)
|
|
3560
|
+
.catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
|
|
3564
|
+
}
|
|
3565
|
+
static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
|
|
3566
|
+
fleetErrorNotices = new Map();
|
|
3457
3567
|
notifyInstanceTopic(instanceName, text, extraOpts) {
|
|
3458
3568
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
3459
3569
|
if (!adapter)
|
|
@@ -4581,9 +4691,19 @@ When users create specialized instances, suggest these configurations:
|
|
|
4581
4691
|
// DC path: respond immediately with progress text
|
|
4582
4692
|
await pending.respond(progressText).catch(() => { });
|
|
4583
4693
|
}
|
|
4584
|
-
// Apply model in background — don't await here (keeps callback handler fast)
|
|
4694
|
+
// Apply model in background — don't await here (keeps callback handler fast).
|
|
4695
|
+
// Guarded: applyModel() restarts the instance, and an unguarded rejection here
|
|
4696
|
+
// meant a user picking from the /model menu could take the whole fleet down.
|
|
4697
|
+
// On failure the user gets told, rather than the click silently doing nothing.
|
|
4585
4698
|
void (async () => {
|
|
4586
|
-
|
|
4699
|
+
let result;
|
|
4700
|
+
try {
|
|
4701
|
+
result = await this.applyModel(pending.instanceName, model);
|
|
4702
|
+
}
|
|
4703
|
+
catch (err) {
|
|
4704
|
+
this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
|
|
4705
|
+
result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
4706
|
+
}
|
|
4587
4707
|
if (pending.adapter && pending.adapterChatId) {
|
|
4588
4708
|
if (progressMsgId) {
|
|
4589
4709
|
pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
|
|
@@ -4863,7 +4983,20 @@ When users create specialized instances, suggest these configurations:
|
|
|
4863
4983
|
this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
|
|
4864
4984
|
return t("classic.stopped");
|
|
4865
4985
|
}
|
|
4866
|
-
|
|
4986
|
+
/**
|
|
4987
|
+
* Idempotent while in flight: SIGINT and SIGTERM share one handler and the
|
|
4988
|
+
* uncaughtException path calls this too, so overlapping runs were possible —
|
|
4989
|
+
* each snapshotting the daemon map and calling stop() on the same daemons
|
|
4990
|
+
* concurrently. Deliberately NOT `async`, so callers receive the same promise
|
|
4991
|
+
* object rather than a fresh wrapper around it. The latch clears when the run
|
|
4992
|
+
* settles, so a later genuine stop (after a restart) still does the work.
|
|
4993
|
+
*/
|
|
4994
|
+
stopAll() {
|
|
4995
|
+
this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
|
|
4996
|
+
return this.stopAllInFlight;
|
|
4997
|
+
}
|
|
4998
|
+
stopAllInFlight = null;
|
|
4999
|
+
async doStopAll() {
|
|
4867
5000
|
this.startupComplete = false;
|
|
4868
5001
|
this.reloadPending = false;
|
|
4869
5002
|
this.ipcStoppingInstances.add("__fleet_stopping__");
|
|
@@ -4887,6 +5020,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
4887
5020
|
clearInterval(this.updateCheckTimer);
|
|
4888
5021
|
this.updateCheckTimer = null;
|
|
4889
5022
|
}
|
|
5023
|
+
if (this.eventLogPruneTimer) {
|
|
5024
|
+
clearInterval(this.eventLogPruneTimer);
|
|
5025
|
+
this.eventLogPruneTimer = null;
|
|
5026
|
+
}
|
|
4890
5027
|
if (this.topicCleanupTimer) {
|
|
4891
5028
|
clearInterval(this.topicCleanupTimer);
|
|
4892
5029
|
this.topicCleanupTimer = null;
|
|
@@ -5584,7 +5721,10 @@ When users create specialized instances, suggest these configurations:
|
|
|
5584
5721
|
res.writeHead(500);
|
|
5585
5722
|
res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
|
|
5586
5723
|
}
|
|
5587
|
-
|
|
5724
|
+
// The inner catch can itself throw (writeHead after a successful
|
|
5725
|
+
// writeHead is ERR_HTTP_HEADERS_SENT), and that rejection escapes the
|
|
5726
|
+
// IIFE. Same for the two handlers below.
|
|
5727
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP start handler failed"));
|
|
5588
5728
|
return;
|
|
5589
5729
|
}
|
|
5590
5730
|
// Instance restart (immediate, no idle wait)
|
|
@@ -5605,7 +5745,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5605
5745
|
res.writeHead(status);
|
|
5606
5746
|
res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
|
|
5607
5747
|
}
|
|
5608
|
-
})();
|
|
5748
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP restart handler failed"));
|
|
5609
5749
|
return;
|
|
5610
5750
|
}
|
|
5611
5751
|
if (req.method === "POST" && req.url?.startsWith("/stop/")) {
|
|
@@ -5628,7 +5768,7 @@ When users create specialized instances, suggest these configurations:
|
|
|
5628
5768
|
res.writeHead(500);
|
|
5629
5769
|
res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
|
|
5630
5770
|
}
|
|
5631
|
-
})();
|
|
5771
|
+
})().catch(err => this.logger.error({ err, name }, "HTTP stop handler failed"));
|
|
5632
5772
|
return;
|
|
5633
5773
|
}
|
|
5634
5774
|
// ── Agent CLI endpoint ─────
|