@songsid/agend 2.1.2-beta.45 → 2.1.2-beta.46

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.
@@ -63,7 +63,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
63
63
  private instanceWorldBinding;
64
64
  private recentMessageIds;
65
65
  private accessManager;
66
- /** Primary world (first adapter) used for fleet-level notifications */
66
+ /** Primary world (channels[0]), independent of concurrent adapter startup order. */
67
67
  get primaryWorld(): AdapterWorld | undefined;
68
68
  readonly routing: RoutingEngine;
69
69
  get routingTable(): Map<string, RouteTarget>;
@@ -223,12 +223,22 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
223
223
  getChannelConfig(adapterId?: string): import("./types.js").ChannelConfig | undefined;
224
224
  /** Get the group_id for an instance's bound adapter */
225
225
  getGroupIdForInstance(name: string): string;
226
+ /** Configured primary adapter id. Never infer this from Map insertion order. */
227
+ private getPrimaryAdapterId;
228
+ /** Warn when a coordinator's adapter identity is ambiguous to the operator. */
229
+ private warnUnboundGeneralChannelIds;
230
+ /**
231
+ * Resolve the authoritative adapter identity for an instance.
232
+ * Fleet instances without channel_id and legacy Classic entries both belong
233
+ * to channels[0]. Runtime bindings remain available for external sessions.
234
+ */
235
+ private getInstanceAdapterId;
226
236
  /**
227
237
  * Bind an instance to a specific world (the bot that answers for it).
228
238
  * fromInbound=true (binding inferred from which adapter received a message)
229
- * must not override a configured identity: skip when the instance is a general
230
- * or has an explicit channel_id otherwise a persona instance whose message
231
- * was also seen by the main bot would get rebound to the wrong bot.
239
+ * must not override a configured identity. Fleet instances use channel_id or
240
+ * channels[0]; Classic instances use their persisted adapter (or channels[0]
241
+ * for a legacy entry). Only external sessions may bind from inbound traffic.
232
242
  */
233
243
  bindInstanceAdapter(name: string, adapterId: string, fromInbound?: boolean): void;
234
244
  getInstanceStatus(name: string): "running" | "paused" | "stopped" | "crashed";
@@ -185,8 +185,11 @@ export class FleetManager {
185
185
  // same guild both receive every message). Bounded FIFO of recent message keys.
186
186
  recentMessageIds = new Set();
187
187
  accessManager = null;
188
- /** Primary world (first adapter) used for fleet-level notifications */
189
- get primaryWorld() { return this.worlds.values().next().value; }
188
+ /** Primary world (channels[0]), independent of concurrent adapter startup order. */
189
+ get primaryWorld() {
190
+ const adapterId = this.getPrimaryAdapterId();
191
+ return adapterId ? this.worlds.get(adapterId) : undefined;
192
+ }
190
193
  routing = new RoutingEngine();
191
194
  get routingTable() { return this.routing.map; }
192
195
  instanceIpcClients = new Map();
@@ -458,10 +461,20 @@ export class FleetManager {
458
461
  if (!this.classicChannels)
459
462
  return;
460
463
  const channels = this.classicChannels.getAll();
464
+ // Classic's persisted adapter is authoritative. Legacy adapter-less rows
465
+ // deterministically belong to channels[0], never to whichever bot happens
466
+ // to deliver the first message after startup/reconnect.
467
+ for (const ch of channels) {
468
+ const adapterId = ch.adapterId ?? this.getPrimaryAdapterId();
469
+ if (adapterId)
470
+ this.instanceWorldBinding.set(ch.instanceName, adapterId);
471
+ }
461
472
  // Always update adapter openChannels (including empty — clears stale entries on /stop)
462
473
  for (const [adapterId, w] of this.worlds) {
463
474
  if (typeof w.adapter?.setOpenChannels === "function") {
464
- const owned = channels.filter(ch => ch.adapterId === adapterId).map(ch => ch.channelId);
475
+ const owned = channels
476
+ .filter(ch => (ch.adapterId ?? this.getPrimaryAdapterId()) === adapterId)
477
+ .map(ch => ch.channelId);
465
478
  w.adapter.setOpenChannels(owned);
466
479
  }
467
480
  }
@@ -539,51 +552,88 @@ export class FleetManager {
539
552
  }
540
553
  /** Get the adapter bound to an instance, falling back to primary adapter */
541
554
  getAdapterForInstance(name) {
542
- const worldId = this.instanceWorldBinding.get(name);
555
+ const worldId = this.getInstanceAdapterId(name);
543
556
  if (worldId)
544
557
  return this.worlds.get(worldId)?.adapter ?? this.adapter;
545
558
  return this.adapter;
546
559
  }
547
560
  /** Get the world for an instance */
548
561
  getWorldForInstance(name) {
549
- const worldId = this.instanceWorldBinding.get(name);
550
- return worldId ? this.worlds.get(worldId) : this.worlds.values().next().value;
562
+ const worldId = this.getInstanceAdapterId(name);
563
+ return worldId ? this.worlds.get(worldId) : undefined;
551
564
  }
552
565
  /** Get channel config for a specific adapter (by id), falling back to primary */
553
566
  getChannelConfig(adapterId) {
567
+ const channels = this.fleetConfig?.channels
568
+ ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
554
569
  if (adapterId) {
555
- const world = this.worlds.get(adapterId);
556
- if (world)
557
- return world.channelConfig;
570
+ return channels.find(ch => (ch.id ?? ch.type) === adapterId)
571
+ ?? this.worlds.get(adapterId)?.channelConfig
572
+ ?? channels[0];
558
573
  }
559
- return this.fleetConfig?.channel;
574
+ return channels[0];
560
575
  }
561
576
  /** Get the group_id for an instance's bound adapter */
562
577
  getGroupIdForInstance(name) {
563
- const world = this.getWorldForInstance(name);
564
- return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
578
+ const adapterId = this.getInstanceAdapterId(name);
579
+ const world = adapterId ? this.worlds.get(adapterId) : undefined;
580
+ return world?.groupId ?? String(this.getChannelConfig(adapterId)?.group_id ?? "");
581
+ }
582
+ /** Configured primary adapter id. Never infer this from Map insertion order. */
583
+ getPrimaryAdapterId() {
584
+ const primary = this.fleetConfig?.channels?.[0] ?? this.fleetConfig?.channel;
585
+ if (primary)
586
+ return primary.id ?? primary.type;
587
+ // Defensive compatibility for callers/tests that provide a live world but
588
+ // no channel config. Real multi-adapter fleets always have channels[].
589
+ return this.worlds.keys().next().value;
590
+ }
591
+ /** Warn when a coordinator's adapter identity is ambiguous to the operator. */
592
+ warnUnboundGeneralChannelIds(fleet) {
593
+ const channels = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
594
+ if (channels.length <= 1)
595
+ return;
596
+ const adapterIds = channels.map(ch => ch.id ?? ch.type);
597
+ for (const [name, config] of Object.entries(fleet.instances)) {
598
+ if (!config.general_topic || config.channel_id)
599
+ continue;
600
+ this.logger.warn({
601
+ instance: name,
602
+ defaultAdapter: adapterIds[0],
603
+ availableAdapters: adapterIds,
604
+ }, "General instance has no channel_id in a multi-channel fleet; defaulting to the first adapter. Set channel_id explicitly.");
605
+ }
606
+ }
607
+ /**
608
+ * Resolve the authoritative adapter identity for an instance.
609
+ * Fleet instances without channel_id and legacy Classic entries both belong
610
+ * to channels[0]. Runtime bindings remain available for external sessions.
611
+ */
612
+ getInstanceAdapterId(name) {
613
+ const cfg = this.fleetConfig?.instances[name];
614
+ if (cfg)
615
+ return cfg.channel_id ?? this.getPrimaryAdapterId();
616
+ if (this.classicChannels?.getChannelIdByInstance(name) !== undefined) {
617
+ return this.classicChannels.getAdapterIdByInstance(name) ?? this.getPrimaryAdapterId();
618
+ }
619
+ return this.instanceWorldBinding.get(name) ?? this.getPrimaryAdapterId();
565
620
  }
566
621
  /**
567
622
  * Bind an instance to a specific world (the bot that answers for it).
568
623
  * fromInbound=true (binding inferred from which adapter received a message)
569
- * must not override a configured identity: skip when the instance is a general
570
- * or has an explicit channel_id otherwise a persona instance whose message
571
- * was also seen by the main bot would get rebound to the wrong bot.
624
+ * must not override a configured identity. Fleet instances use channel_id or
625
+ * channels[0]; Classic instances use their persisted adapter (or channels[0]
626
+ * for a legacy entry). Only external sessions may bind from inbound traffic.
572
627
  */
573
628
  bindInstanceAdapter(name, adapterId, fromInbound = false) {
574
- const cfg = this.fleetConfig?.instances[name];
575
629
  if (fromInbound) {
576
- // Skip inbound-derived binding for any instance that doesn't have an
577
- // explicit channel_id — those default to primary adapter deterministically.
578
- // This prevents a non-deterministic race where whichever adapter delivers
579
- // first after restart wins the binding.
580
- if (cfg?.general_topic || cfg?.channel_id)
581
- return;
582
- if (cfg && !cfg.channel_id)
583
- return; // fleet instance without explicit binding → use primary
584
- // Classic instance: don't override an existing binding (authoritative from /start)
585
- if (this.classicChannels?.getChannelIdByInstance(name) !== undefined && this.instanceWorldBinding.has(name))
630
+ const configuredId = this.getInstanceAdapterId(name);
631
+ if (this.fleetConfig?.instances[name]
632
+ || this.classicChannels?.getChannelIdByInstance(name) !== undefined) {
633
+ if (configuredId)
634
+ this.instanceWorldBinding.set(name, configuredId);
586
635
  return;
636
+ }
587
637
  }
588
638
  this.instanceWorldBinding.set(name, adapterId);
589
639
  }
@@ -1335,6 +1385,7 @@ export class FleetManager {
1335
1385
  this.rotateInboxes();
1336
1386
  // Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
1337
1387
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
1388
+ this.warnUnboundGeneralChannelIds(fleet);
1338
1389
  const primaryAdapterId = channelConfigs[0] ? (channelConfigs[0].id ?? channelConfigs[0].type) : undefined;
1339
1390
  const generalInstances = Object.entries(fleet.instances).filter(([, inst]) => inst.general_topic === true);
1340
1391
  let generalsCreated = false;
@@ -1494,25 +1545,14 @@ export class FleetManager {
1494
1545
  catch (err) {
1495
1546
  this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
1496
1547
  }
1497
- // Bind instances to their adapter (which bot answers on their behalf).
1498
- // An explicit channel_id is authoritative this is how a persona instance
1499
- // picks its bot when several share one guild. Generals without a channel_id
1500
- // fall back to a name-contains-adapterId heuristic.
1501
- const channelConfigsForBind = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
1548
+ // Bind every fleet instance deterministically. Explicit channel_id wins;
1549
+ // otherwise channels[0] is authoritative. Do not infer identity from
1550
+ // concurrent adapter startup or whichever bot receives a message first.
1551
+ const primaryAdapterId = this.getPrimaryAdapterId();
1502
1552
  for (const [name, config] of Object.entries(fleet.instances)) {
1503
- if (config.channel_id) {
1504
- this.bindInstanceAdapter(name, config.channel_id);
1505
- continue;
1506
- }
1507
- if (!config.general_topic)
1508
- continue;
1509
- for (const ch of channelConfigsForBind) {
1510
- const id = ch.id ?? ch.type;
1511
- if (name.includes(id)) {
1512
- this.bindInstanceAdapter(name, id);
1513
- break;
1514
- }
1515
- }
1553
+ const adapterId = config.channel_id ?? primaryAdapterId;
1554
+ if (adapterId)
1555
+ this.bindInstanceAdapter(name, adapterId);
1516
1556
  }
1517
1557
  // Guard against a stale/invalid general topic_id. An old auto-general
1518
1558
  // could have written the TG-convention "1" for a Discord general; the DC
@@ -1522,7 +1562,7 @@ export class FleetManager {
1522
1562
  for (const [name, cfg] of Object.entries(this.fleetConfig.instances)) {
1523
1563
  if (!cfg.general_topic || cfg.topic_id == null)
1524
1564
  continue;
1525
- const adapterId = this.instanceWorldBinding.get(name) ?? cfg.channel_id;
1565
+ const adapterId = this.getInstanceAdapterId(name);
1526
1566
  if (this.getChannelConfig(adapterId)?.type === "discord" && !/^\d{17,}$/.test(String(cfg.topic_id))) {
1527
1567
  this.logger.warn({ name, topic_id: cfg.topic_id }, "Discord general topic_id is not a valid channel — unbinding to avoid a crash loop");
1528
1568
  delete cfg.topic_id;
@@ -2075,7 +2115,9 @@ export class FleetManager {
2075
2115
  this.probeCliEnvs();
2076
2116
  this.adapter.on("started", safeHandler((username, userId) => {
2077
2117
  this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
2078
- const w = this.worlds.values().next().value;
2118
+ // Concurrent startup can insert a secondary world first. Update the
2119
+ // configured primary world, not Map insertion order.
2120
+ const w = this.worlds.get(adapterId);
2079
2121
  if (w) {
2080
2122
  w.botUsername = username;
2081
2123
  if (userId)
@@ -4203,7 +4245,7 @@ export class FleetManager {
4203
4245
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4204
4246
  if (!adapter)
4205
4247
  return;
4206
- const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName));
4248
+ const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
4207
4249
  const groupId = channelCfg?.group_id;
4208
4250
  // Fleet topic instance
4209
4251
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
@@ -4328,7 +4370,7 @@ export class FleetManager {
4328
4370
  // `channels:` worlds the primary `channel:` block is empty, so an instance
4329
4371
  // with no world binding yet (fresh restart, cross-instance delegation)
4330
4372
  // resolved group_id to undefined and the button silently never appeared.
4331
- const adapterId = this.instanceWorldBinding.get(instanceName);
4373
+ const adapterId = this.getInstanceAdapterId(instanceName);
4332
4374
  const groupId = this.getGroupIdForInstance(instanceName) || undefined;
4333
4375
  const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
4334
4376
  let chatId;
@@ -4753,7 +4795,7 @@ export class FleetManager {
4753
4795
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4754
4796
  if (!adapter)
4755
4797
  return;
4756
- const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName));
4798
+ const channelCfg = this.getChannelConfig(this.getInstanceAdapterId(instanceName));
4757
4799
  const groupId = channelCfg?.group_id;
4758
4800
  if (!groupId)
4759
4801
  return;