@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.
@@ -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 concurrency = Math.max(1, Math.min(20, raw?.concurrency ?? 10));
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 failedNames = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
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
- const text = failedNames.length === 0
997
- ? t("fleet.ready", started, total, agendVersion)
998
- : t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "));
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 failedNames = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
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
- const restartText = failedNames.length === 0
4552
- ? t("fleet.ready", started, total, agendVersion2)
4553
- : t("fleet.ready_with_failed", started, total, agendVersion2, failedNames.join(", "));
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