@songsid/agend 2.0.12 → 2.1.0-beta.1

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.
@@ -57,6 +57,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
57
57
  private failoverActive;
58
58
  readonly ipcStoppingInstances: Set<string>;
59
59
  private adapterRestarting;
60
+ private adapterState;
60
61
  private collabInstances;
61
62
  private healthServer;
62
63
  private healthPortRetried;
@@ -137,6 +138,18 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
137
138
  private rotateInboxes;
138
139
  /** Start the shared channel adapter(s) for topic mode */
139
140
  private startSharedAdapter;
141
+ /** Exponential backoff retry for a single failed adapter (background, non-blocking). */
142
+ private scheduleAdapterRetry;
143
+ /** Notify admin about adapter failure (uses any available adapter). */
144
+ private notifyAdapterFailure;
145
+ /** Notify admin that a retried adapter reconnected. */
146
+ private notifyAdapterRecovery;
147
+ /** Get adapter states for /status visibility. */
148
+ getAdapterStates(): Map<string, {
149
+ status: string;
150
+ retryCount: number;
151
+ lastError?: string;
152
+ }>;
140
153
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
141
154
  private startSingleAdapter;
142
155
  /** Start an additional (non-primary) adapter */
@@ -110,6 +110,8 @@ export class FleetManager {
110
110
  ipcStoppingInstances = new Set();
111
111
  // Adapter restart: prevents re-entrant restart attempts
112
112
  adapterRestarting = new Set();
113
+ // Adapter isolation: track state per adapter for retry + visibility
114
+ adapterState = new Map();
113
115
  collabInstances = new Set();
114
116
  // Health endpoint
115
117
  healthServer = null;
@@ -836,17 +838,81 @@ export class FleetManager {
836
838
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
837
839
  if (channelConfigs.length === 0)
838
840
  return;
839
- // Start primary adapter (first channel)this.adapter for backward compat.
840
- await this.startSingleAdapter(fleet, channelConfigs[0]);
841
- // Start additional adapters. Every bot registers its own slash commands —
842
- // Discord slash commands are per-application, so a same-guild secondary bot's
843
- // /start etc. are distinct entries (labelled with the bot name) and the
844
- // interaction only reaches the invoked bot. This is how ClassicBot supports
845
- // multiple bots in one guild: the user picks which bot from autocomplete.
846
- for (let i = 1; i < channelConfigs.length; i++) {
847
- await this.startAdditionalAdapter(channelConfigs[i]);
841
+ // Start ALL adapters in parallelany single failure doesn't block others.
842
+ const results = await Promise.allSettled(channelConfigs.map((cfg, i) => i === 0
843
+ ? this.startSingleAdapter(fleet, cfg)
844
+ : this.startAdditionalAdapter(cfg)));
845
+ // Track state + schedule background retry for failures.
846
+ for (let i = 0; i < channelConfigs.length; i++) {
847
+ const adapterId = channelConfigs[i].id ?? channelConfigs[i].type;
848
+ if (results[i].status === "fulfilled") {
849
+ this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
850
+ }
851
+ else {
852
+ const err = results[i].reason;
853
+ this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
854
+ this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
855
+ this.scheduleAdapterRetry(adapterId, channelConfigs[i], i === 0 ? fleet : undefined);
856
+ // Notify admin via whichever adapter is already up
857
+ this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
858
+ }
848
859
  }
849
860
  }
861
+ /** Exponential backoff retry for a single failed adapter (background, non-blocking). */
862
+ scheduleAdapterRetry(adapterId, channelConfig, fleet) {
863
+ const MAX_RETRIES = 10;
864
+ const INITIAL_DELAY_MS = 5_000;
865
+ const MAX_DELAY_MS = 5 * 60_000;
866
+ const state = this.adapterState.get(adapterId);
867
+ if (!state || state.retryCount >= MAX_RETRIES) {
868
+ if (state) {
869
+ state.status = "failed";
870
+ this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
871
+ this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
872
+ }
873
+ return;
874
+ }
875
+ const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
876
+ this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
877
+ state.retryTimer = setTimeout(async () => {
878
+ state.retryCount++;
879
+ try {
880
+ if (fleet) {
881
+ await this.startSingleAdapter(fleet, channelConfig);
882
+ }
883
+ else {
884
+ await this.startAdditionalAdapter(channelConfig);
885
+ }
886
+ state.status = "connected";
887
+ state.lastError = undefined;
888
+ this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
889
+ this.notifyAdapterRecovery(adapterId, state.retryCount);
890
+ }
891
+ catch (err) {
892
+ state.lastError = err?.message ?? String(err);
893
+ this.logger.warn({ adapterId, attempt: state.retryCount, err: state.lastError }, "Adapter retry failed");
894
+ this.scheduleAdapterRetry(adapterId, channelConfig, fleet);
895
+ }
896
+ }, delay);
897
+ }
898
+ /** Notify admin about adapter failure (uses any available adapter). */
899
+ notifyAdapterFailure(adapterId, error) {
900
+ const generalId = this.findGeneralInstance();
901
+ if (generalId) {
902
+ this.notifyInstanceTopic(generalId, `⚠️ Adapter "${adapterId}" failed to start: ${error}\nRetrying in background. Other adapters unaffected.`);
903
+ }
904
+ }
905
+ /** Notify admin that a retried adapter reconnected. */
906
+ notifyAdapterRecovery(adapterId, attempts) {
907
+ const generalId = this.findGeneralInstance();
908
+ if (generalId) {
909
+ this.notifyInstanceTopic(generalId, `✅ Adapter "${adapterId}" reconnected (after ${attempts} ${attempts === 1 ? "retry" : "retries"}).`);
910
+ }
911
+ }
912
+ /** Get adapter states for /status visibility. */
913
+ getAdapterStates() {
914
+ return this.adapterState;
915
+ }
850
916
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
851
917
  async startSingleAdapter(fleet, channelConfig) {
852
918
  const botToken = process.env[channelConfig.bot_token_env];
@@ -3683,6 +3749,13 @@ When users create specialized instances, suggest these configurations:
3683
3749
  clearInterval(this.watchdogTimer);
3684
3750
  this.watchdogTimer = null;
3685
3751
  }
3752
+ // Cancel adapter retry timers
3753
+ for (const state of this.adapterState.values()) {
3754
+ if (state.retryTimer) {
3755
+ clearTimeout(state.retryTimer);
3756
+ state.retryTimer = undefined;
3757
+ }
3758
+ }
3686
3759
  this.clearStatuslineWatchers();
3687
3760
  this.costGuard?.stop();
3688
3761
  this.dailySummary?.stop();