@songsid/agend 2.1.0-beta.29 → 2.1.0-beta.30
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 +1 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/channel/mcp-tools.js +17 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +26 -1
- package/dist/daemon.js +200 -61
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +27 -0
- package/dist/fleet-manager.js +127 -9
- package/dist/fleet-manager.js.map +1 -1
- package/dist/outbound-handlers.js +164 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +38 -0
- package/dist/outbound-schemas.js +35 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/tmux-control.d.ts +7 -0
- package/dist/tmux-control.js +22 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -52,6 +52,26 @@ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
|
52
52
|
}
|
|
53
53
|
return instanceConfig?.topic_id != null ? String(instanceConfig.topic_id) : undefined;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Pure warm-cap victim selection (extracted for testability). Given the current
|
|
57
|
+
* warm (running) instance names and a cap, return the LRU idle instances to evict
|
|
58
|
+
* so the running count returns to the cap. Skips: the `exclude` instance, any
|
|
59
|
+
* already-evicting, general instances (never evicted), and non-idle instances
|
|
60
|
+
* (working/stuck can't be evicted). Oldest last-inbound is evicted first; a
|
|
61
|
+
* missing timestamp (0) sorts oldest. cap <= 0 (or non-integer) = unlimited → [].
|
|
62
|
+
*/
|
|
63
|
+
export function selectLruEvictions(warm, cap, opts) {
|
|
64
|
+
if (!Number.isInteger(cap) || cap <= 0)
|
|
65
|
+
return [];
|
|
66
|
+
if (warm.length <= cap)
|
|
67
|
+
return [];
|
|
68
|
+
const candidates = warm.filter(name => name !== opts.exclude
|
|
69
|
+
&& !opts.isEvicting(name)
|
|
70
|
+
&& !opts.isGeneral(name)
|
|
71
|
+
&& opts.isIdle(name));
|
|
72
|
+
candidates.sort((a, b) => opts.lastInboundAt(a) - opts.lastInboundAt(b));
|
|
73
|
+
return candidates.slice(0, warm.length - cap);
|
|
74
|
+
}
|
|
55
75
|
/** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
|
|
56
76
|
* forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
|
|
57
77
|
const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
|
|
@@ -102,6 +122,8 @@ export class FleetManager {
|
|
|
102
122
|
lastActivity = new Map();
|
|
103
123
|
/** Latest pane-derived execution snapshot reported by each daemon. */
|
|
104
124
|
instanceStateCache = new Map();
|
|
125
|
+
/** Instances currently being auto-paused by warm_cap, so concurrent checks don't double-evict. */
|
|
126
|
+
warmCapEvicting = new Set();
|
|
105
127
|
/** Per-instance tail keeps cross-instance and scheduled deliveries FIFO. */
|
|
106
128
|
idleGatedDeliveryTails = new Map();
|
|
107
129
|
/** Non-user work must observe a fresh idle snapshot after the latest delivery. */
|
|
@@ -382,6 +404,46 @@ export class FleetManager {
|
|
|
382
404
|
});
|
|
383
405
|
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
384
406
|
check();
|
|
407
|
+
// warm_cap: a fresh transition into idle may free this instance for eviction,
|
|
408
|
+
// or (more usefully) reveal that the fleet is now over cap. Only fire on the
|
|
409
|
+
// edge into idle, not on every idle heartbeat.
|
|
410
|
+
if (state === "idle" && previous?.state !== "idle")
|
|
411
|
+
this.enforceWarmCap();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Fleet-wide warm cap: if more than `defaults.warm_cap` instances are running,
|
|
415
|
+
* auto-pause the least-recently-active idle instances until back at the cap.
|
|
416
|
+
* Never evicts general instances (must stay warm) or working/stuck instances
|
|
417
|
+
* (only idle). 0/unset = unlimited. wake-before-deliver re-warms any evicted
|
|
418
|
+
* instance when a message next arrives.
|
|
419
|
+
*
|
|
420
|
+
* @param exclude instance to spare (e.g. one just woken to receive a delivery).
|
|
421
|
+
*/
|
|
422
|
+
enforceWarmCap(exclude) {
|
|
423
|
+
const cap = this.fleetConfig?.defaults?.warm_cap ?? 0;
|
|
424
|
+
if (!Number.isInteger(cap) || cap <= 0)
|
|
425
|
+
return; // 0/invalid = unlimited
|
|
426
|
+
const warm = [];
|
|
427
|
+
for (const name of this.daemons.keys()) {
|
|
428
|
+
if (this.getInstanceStatus(name) === "running")
|
|
429
|
+
warm.push(name);
|
|
430
|
+
}
|
|
431
|
+
if (warm.length <= cap)
|
|
432
|
+
return;
|
|
433
|
+
const victims = selectLruEvictions(warm, cap, {
|
|
434
|
+
exclude,
|
|
435
|
+
isEvicting: name => this.warmCapEvicting.has(name),
|
|
436
|
+
isGeneral: name => this.fleetConfig?.instances[name]?.general_topic === true,
|
|
437
|
+
isIdle: name => this.getInstanceExecutionState(name) === "idle",
|
|
438
|
+
lastInboundAt: name => readLastInboundAt(this.getInstanceDir(name)) ?? 0,
|
|
439
|
+
});
|
|
440
|
+
for (const victim of victims) {
|
|
441
|
+
this.warmCapEvicting.add(victim);
|
|
442
|
+
this.logger.info({ instance: victim, warm: warm.length, cap }, "warm_cap exceeded — auto-pausing LRU idle instance");
|
|
443
|
+
this.lifecycle.pause(victim)
|
|
444
|
+
.catch(err => this.logger.warn({ err, instance: victim }, "warm_cap auto-pause failed"))
|
|
445
|
+
.finally(() => this.warmCapEvicting.delete(victim));
|
|
446
|
+
}
|
|
385
447
|
}
|
|
386
448
|
waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter = 0) {
|
|
387
449
|
const isReady = () => {
|
|
@@ -427,6 +489,9 @@ export class FleetManager {
|
|
|
427
489
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
428
490
|
const wakeStartedAt = Date.now();
|
|
429
491
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
492
|
+
// Waking added one to the warm count — make room by evicting a different
|
|
493
|
+
// LRU idle instance (never this one; it's about to work).
|
|
494
|
+
this.enforceWarmCap(instanceName);
|
|
430
495
|
// Never satisfy a post-wake gate from a stale pre-pause cache entry.
|
|
431
496
|
idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
|
|
432
497
|
}
|
|
@@ -453,6 +518,7 @@ export class FleetManager {
|
|
|
453
518
|
if (!waitForIdle) {
|
|
454
519
|
if (this.lifecycle.isPaused(instanceName)) {
|
|
455
520
|
await this.lifecycle.wake(instanceName, 30_000);
|
|
521
|
+
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
456
522
|
}
|
|
457
523
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
458
524
|
if (!ipc?.connected)
|
|
@@ -483,6 +549,7 @@ export class FleetManager {
|
|
|
483
549
|
async changeInstancePauseState(name, action) {
|
|
484
550
|
if (action === "wake") {
|
|
485
551
|
await this.lifecycle.wake(name, 30_000);
|
|
552
|
+
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
486
553
|
return "awake";
|
|
487
554
|
}
|
|
488
555
|
await this.lifecycle.pause(name);
|
|
@@ -510,6 +577,13 @@ export class FleetManager {
|
|
|
510
577
|
return;
|
|
511
578
|
}
|
|
512
579
|
if (config.general_topic) {
|
|
580
|
+
// antigravity (agy) does not read MCP instructions — fleet context and
|
|
581
|
+
// routing instructions are not injected, so it cannot act as a dispatcher.
|
|
582
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
583
|
+
if (backend === "antigravity") {
|
|
584
|
+
this.logger.warn({ name }, "antigravity backend does not support MCP instructions — general dispatcher will not work correctly");
|
|
585
|
+
this.notifyInstanceTopic(name, "⚠️ antigravity backend is not supported for General instances (no MCP instructions injection). Switch to claude-code or kiro-cli.");
|
|
586
|
+
}
|
|
513
587
|
this.ensureGeneralInstructions(config.working_directory, config.backend);
|
|
514
588
|
}
|
|
515
589
|
await this.lifecycle.start(name, config, topicMode);
|
|
@@ -538,8 +612,21 @@ export class FleetManager {
|
|
|
538
612
|
*/
|
|
539
613
|
async startInstancesWithConcurrency(entries, topicMode) {
|
|
540
614
|
const raw = this.fleetConfig?.defaults?.startup;
|
|
541
|
-
const
|
|
615
|
+
const explicitConcurrency = raw?.concurrency;
|
|
542
616
|
const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
|
|
617
|
+
// Adaptive concurrency: if not explicitly set, estimate from available RAM.
|
|
618
|
+
// Each instance uses ~300MB (tmux + CLI process + model overhead).
|
|
619
|
+
const ESTIMATED_MB_PER_INSTANCE = 300;
|
|
620
|
+
const { freemem } = await import("node:os");
|
|
621
|
+
let concurrency;
|
|
622
|
+
if (explicitConcurrency != null) {
|
|
623
|
+
concurrency = Math.max(1, Math.min(20, explicitConcurrency));
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
const freeMemMB = Math.round(freemem() / (1024 * 1024));
|
|
627
|
+
concurrency = Math.max(2, Math.min(10, Math.floor(freeMemMB / ESTIMATED_MB_PER_INSTANCE)));
|
|
628
|
+
this.logger.info({ concurrency, freeMemMB: freeMemMB, totalInstances: entries.length }, "Adaptive startup concurrency");
|
|
629
|
+
}
|
|
543
630
|
const byWorkDir = new Map();
|
|
544
631
|
for (const [name, config] of entries) {
|
|
545
632
|
const dir = config.working_directory;
|
|
@@ -561,6 +648,17 @@ export class FleetManager {
|
|
|
561
648
|
if (pendingTimer)
|
|
562
649
|
return;
|
|
563
650
|
while (running < concurrency && idx < groups.length) {
|
|
651
|
+
// Re-check memory if adaptive (no explicit concurrency set)
|
|
652
|
+
if (explicitConcurrency == null && running > 0) {
|
|
653
|
+
const nowFreeMB = Math.round(freemem() / (1024 * 1024));
|
|
654
|
+
if (nowFreeMB < ESTIMATED_MB_PER_INSTANCE) {
|
|
655
|
+
this.logger.warn({ freeMemMB: nowFreeMB, remaining: groups.length - idx }, "Low memory — pausing instance startup");
|
|
656
|
+
// Wait and retry in 5s
|
|
657
|
+
pendingTimer = true;
|
|
658
|
+
setTimeout(() => { pendingTimer = false; startNext(); }, 5000);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
564
662
|
const now = Date.now();
|
|
565
663
|
const elapsed = now - lastStartAt;
|
|
566
664
|
if (lastStartAt > 0 && elapsed < staggerMs) {
|
|
@@ -986,16 +1084,26 @@ export class FleetManager {
|
|
|
986
1084
|
const classicCount = this.classicChannels?.getAll().length ?? 0;
|
|
987
1085
|
const total = Object.keys(fleet.instances).length + classicCount;
|
|
988
1086
|
const started = this.daemons.size;
|
|
989
|
-
const
|
|
1087
|
+
const allNotRunning = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
|
|
1088
|
+
const pausedNames = allNotRunning.filter(n => this.lifecycle.isPaused(n));
|
|
1089
|
+
const failedNames = allNotRunning.filter(n => !this.lifecycle.isPaused(n));
|
|
990
1090
|
const generalName = this.findGeneralInstance();
|
|
991
1091
|
const generalThreadId = generalName ? fleet.instances[generalName]?.topic_id : undefined;
|
|
992
1092
|
const { createRequire } = await import("node:module");
|
|
993
1093
|
const _require = createRequire(import.meta.url);
|
|
994
1094
|
const agendVersion = _require("../package.json").version ?? "unknown";
|
|
995
1095
|
if (this.adapter && fleet.channel?.group_id) {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1096
|
+
let text;
|
|
1097
|
+
if (failedNames.length === 0 && pausedNames.length === 0) {
|
|
1098
|
+
text = t("fleet.ready", started, total, agendVersion);
|
|
1099
|
+
}
|
|
1100
|
+
else if (failedNames.length === 0) {
|
|
1101
|
+
text = t("fleet.ready", started, total, agendVersion) + `\n⏸ Paused: ${pausedNames.join(", ")}`;
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
|
|
1105
|
+
+ (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
|
|
1106
|
+
}
|
|
999
1107
|
this.adapter.sendText(String(fleet.channel.group_id), text, {
|
|
1000
1108
|
threadId: generalThreadId != null ? String(generalThreadId) : undefined,
|
|
1001
1109
|
}).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
|
|
@@ -4544,13 +4652,23 @@ When users create specialized instances, suggest these configurations:
|
|
|
4544
4652
|
if (groupId && this.adapter) {
|
|
4545
4653
|
const total = Object.keys(fleet.instances).length;
|
|
4546
4654
|
const started = this.daemons.size;
|
|
4547
|
-
const
|
|
4655
|
+
const allNotRunning2 = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
|
|
4656
|
+
const pausedNames2 = allNotRunning2.filter(n => this.lifecycle.isPaused(n));
|
|
4657
|
+
const failedNames = allNotRunning2.filter(n => !this.lifecycle.isPaused(n));
|
|
4548
4658
|
const { createRequire } = await import("node:module");
|
|
4549
4659
|
const _require2 = createRequire(import.meta.url);
|
|
4550
4660
|
const agendVersion2 = _require2("../package.json").version ?? "unknown";
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4661
|
+
let restartText;
|
|
4662
|
+
if (failedNames.length === 0 && pausedNames2.length === 0) {
|
|
4663
|
+
restartText = t("fleet.ready", started, total, agendVersion2);
|
|
4664
|
+
}
|
|
4665
|
+
else if (failedNames.length === 0) {
|
|
4666
|
+
restartText = t("fleet.ready", started, total, agendVersion2) + `\n⏸ Paused: ${pausedNames2.join(", ")}`;
|
|
4667
|
+
}
|
|
4668
|
+
else {
|
|
4669
|
+
restartText = t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "))
|
|
4670
|
+
+ (pausedNames2.length > 0 ? `\n⏸ Paused: ${pausedNames2.join(", ")}` : "");
|
|
4671
|
+
}
|
|
4554
4672
|
await this.adapter.sendText(String(groupId), restartText, notifyOpts)
|
|
4555
4673
|
.catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
|
|
4556
4674
|
// Notify each instance's channel — staggered to avoid rate limit storm
|