@songsid/agend 2.1.5-beta.19 → 2.1.5-beta.20

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.
@@ -5,6 +5,7 @@ import type { Scheduler } from "./scheduler/index.js";
5
5
  import type { Logger } from "./logger.js";
6
6
  import type { CostGuard } from "./cost-guard.js";
7
7
  import type { ClassicChannelManager } from "./classic-channel-manager.js";
8
+ import type { ExplicitInstanceRemoval } from "./instance-removal.js";
8
9
  export type RouteTarget = {
9
10
  kind: "instance";
10
11
  name: string;
@@ -88,7 +89,13 @@ export interface FleetContext {
88
89
  saveFleetConfig(): void;
89
90
  getInstanceDir(name: string): string;
90
91
  createForumTopic(topicName: string, adapterId?: string): Promise<number | string>;
91
- removeInstance(name: string): Promise<void>;
92
+ removeInstance(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
93
+ /** Quarantine a provider-confirmed missing topic without deleting user data. */
94
+ quarantineMissingTopic(threadId: string, target: RouteTarget, evidence: {
95
+ source: "provider-event" | "provider-probe";
96
+ adapterId?: string;
97
+ generation?: number;
98
+ }): void;
92
99
  getAdapterStates?(): Map<string, {
93
100
  status: string;
94
101
  retryCount: number;
@@ -1 +1 @@
1
- {"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../src/fleet-context.ts"],"names":[],"mappings":"AAgCA,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,OAAO,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"fleet-context.js","sourceRoot":"","sources":["../src/fleet-context.ts"],"names":[],"mappings":"AAiCA,MAAM,UAAU,sBAAsB,CAAC,MAAmB;IACxD,OAAO,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC;AACpC,CAAC"}
@@ -19,6 +19,7 @@ import { type OutboundContext } from "./outbound-handlers.js";
19
19
  import { type RawConfigPatch } from "./settings-api.js";
20
20
  import { type AgentEndpointContext } from "./agent-endpoint.js";
21
21
  import { ClassicChannelManager } from "./classic-channel-manager.js";
22
+ import { type ExplicitInstanceRemoval } from "./instance-removal.js";
22
23
  import type { InstanceState } from "./backend/types.js";
23
24
  import { StormWindow } from "./storm-window.js";
24
25
  import { SpawnGate } from "./spawn-gate.js";
@@ -134,6 +135,10 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
134
135
  private reloadPending;
135
136
  /** A running reconciliation; only one may mutate lifecycle/config state at a time. */
136
137
  private reconcileInFlight;
138
+ /** Topology checks are serialized separately from config reconciliation. */
139
+ private topicCleanupInFlight;
140
+ private topicCleanupGeneration;
141
+ private topicProbeWarnings;
137
142
  logger: Logger;
138
143
  private topicCommands;
139
144
  sessionRegistry: Map<string, string>;
@@ -759,8 +764,31 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
759
764
  private sessionPruneTimer;
760
765
  private classicReloadTimer;
761
766
  private botUserId;
762
- /** Periodically check if bound topics still exist */
767
+ /** Periodically check if bound topics still exist. Never deletes user data. */
763
768
  private startTopicCleanupPoller;
769
+ /** Coalesce timer ticks; an outage must not create overlapping destructive-looking scans. */
770
+ private scheduleTopicCleanup;
771
+ private confirmedProbeFence;
772
+ private sameProbeFence;
773
+ private warnTopicProbeUnknown;
774
+ /** One fixed-snapshot topology pass. Automatic evidence can only quarantine. */
775
+ private runTopicCleanup;
776
+ /**
777
+ * A gateway event is only a hint. Confirm it through the passive REST probe;
778
+ * reconnecting gateways have emitted false channelDelete events in practice.
779
+ */
780
+ private handleProviderTopicClosed;
781
+ private bindTopicClosedHandler;
782
+ /**
783
+ * Remove only the volatile route. The instance config, daemon, schedules,
784
+ * teams, metadata directory, and working tree remain untouched until an
785
+ * authenticated explicit deletion is requested.
786
+ */
787
+ quarantineMissingTopic(threadId: string, target: RouteTarget, evidence: {
788
+ source: "provider-event" | "provider-probe";
789
+ adapterId?: string;
790
+ generation?: number;
791
+ }): void;
764
792
  /**
765
793
  * Patch only values changed in the effective config into the original YAML
766
794
  * document. Unknown keys and comments remain untouched; redundant
@@ -771,7 +799,7 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
771
799
  private slimFleetConfigAtStartup;
772
800
  private writeFleetConfigBackup;
773
801
  private patchFleetDocument;
774
- removeInstance(name: string): Promise<void>;
802
+ removeInstance(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
775
803
  startStatuslineWatcher(name: string): void;
776
804
  stopStatuslineWatcher(name: string): void;
777
805
  reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string): void;
@@ -55,6 +55,7 @@ import { handleSettingsRequest } from "./settings-api.js";
55
55
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
56
56
  import { handleAgentRequest } from "./agent-endpoint.js";
57
57
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
58
+ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
58
59
  import { validateFleetConfig } from "./config-validator.js";
59
60
  import { readLastInboundAt } from "./daemon.js";
60
61
  import { clearPausedMarker } from "./pause-marker.js";
@@ -316,6 +317,10 @@ export class FleetManager {
316
317
  reloadPending = false;
317
318
  /** A running reconciliation; only one may mutate lifecycle/config state at a time. */
318
319
  reconcileInFlight = null;
320
+ /** Topology checks are serialized separately from config reconciliation. */
321
+ topicCleanupInFlight = null;
322
+ topicCleanupGeneration = 0;
323
+ topicProbeWarnings = new Map();
319
324
  logger = createLogger("info");
320
325
  topicCommands;
321
326
  // sessionName → instanceName mapping for external sessions
@@ -3018,10 +3023,11 @@ export class FleetManager {
3018
3023
  accessManager,
3019
3024
  inboxDir,
3020
3025
  });
3021
- const world = new AdapterWorld(adapterId, this.adapter, accessManager, channelConfig);
3026
+ const adapter = this.adapter;
3027
+ const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
3022
3028
  this.worlds.set(adapterId, world);
3023
- this.adapters.set(adapterId, this.adapter);
3024
- this.bindAdapterHealth(this.adapter, adapterId);
3029
+ this.adapters.set(adapterId, adapter);
3030
+ this.bindAdapterHealth(adapter, adapterId);
3025
3031
  this.adapter.on("message", safeHandler(async (msg) => {
3026
3032
  await this.handleInboundMessage(msg);
3027
3033
  }, this.logger, "adapter.message"));
@@ -3066,12 +3072,7 @@ export class FleetManager {
3066
3072
  return;
3067
3073
  }
3068
3074
  }, this.logger, "adapter.callback_query"));
3069
- this.adapter.on("topic_closed", safeHandler(async (data) => {
3070
- // Skip unbind if we archived this topic ourselves
3071
- if (this.topicArchiver.isArchived(data.threadId))
3072
- return;
3073
- await this.topicCommands.handleTopicDeleted(data.threadId);
3074
- }, this.logger, "adapter.topic_closed"));
3075
+ this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
3075
3076
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
3076
3077
  this.adapter.on("slash_command", safeHandler(async (data) => {
3077
3078
  if (data.command === "start") {
@@ -3462,11 +3463,7 @@ export class FleetManager {
3462
3463
  return;
3463
3464
  }
3464
3465
  }, this.logger, `adapter[${adapterId}].callback_query`));
3465
- adapter.on("topic_closed", safeHandler(async (data) => {
3466
- if (this.topicArchiver.isArchived(data.threadId))
3467
- return;
3468
- await this.topicCommands.handleTopicDeleted(data.threadId);
3469
- }, this.logger, `adapter[${adapterId}].topic_closed`));
3466
+ this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
3470
3467
  // Slash commands: classic bot + admin commands
3471
3468
  adapter.on("slash_command", safeHandler(async (data) => {
3472
3469
  if (data.command === "start") {
@@ -5456,26 +5453,176 @@ export class FleetManager {
5456
5453
  sessionPruneTimer = null;
5457
5454
  classicReloadTimer = null;
5458
5455
  botUserId;
5459
- /** Periodically check if bound topics still exist */
5456
+ /** Periodically check if bound topics still exist. Never deletes user data. */
5460
5457
  startTopicCleanupPoller() {
5461
- this.topicCleanupTimer = setInterval(async () => {
5462
- if (!this.fleetConfig?.channel?.group_id || !this.adapter?.topicExists)
5458
+ if (this.topicCleanupTimer)
5459
+ clearInterval(this.topicCleanupTimer);
5460
+ const generation = ++this.topicCleanupGeneration;
5461
+ this.topicCleanupTimer = setInterval(() => { void this.scheduleTopicCleanup(generation); }, 5 * 60_000);
5462
+ }
5463
+ /** Coalesce timer ticks; an outage must not create overlapping destructive-looking scans. */
5464
+ scheduleTopicCleanup(generation = this.topicCleanupGeneration) {
5465
+ if (this.topicCleanupInFlight)
5466
+ return this.topicCleanupInFlight;
5467
+ const run = this.runTopicCleanup(generation).finally(() => {
5468
+ if (this.topicCleanupInFlight === run)
5469
+ this.topicCleanupInFlight = null;
5470
+ });
5471
+ this.topicCleanupInFlight = run;
5472
+ return run;
5473
+ }
5474
+ confirmedProbeFence(adapterId, adapter) {
5475
+ const health = adapter.getHealthSnapshot?.();
5476
+ if (health) {
5477
+ return health.status === "connected" && health.isReady
5478
+ ? { generation: health.generation }
5479
+ : null;
5480
+ }
5481
+ return this.adapterState.get(adapterId)?.status === "connected" ? {} : null;
5482
+ }
5483
+ sameProbeFence(adapterId, adapter, before, result) {
5484
+ const after = this.confirmedProbeFence(adapterId, adapter);
5485
+ if (!after)
5486
+ return false;
5487
+ if (before.generation !== after.generation)
5488
+ return false;
5489
+ return result?.generation === undefined || result.generation === after.generation;
5490
+ }
5491
+ warnTopicProbeUnknown(instanceName, threadId, adapterId, reason) {
5492
+ const key = `${adapterId ?? "unbound"}:${reason}`;
5493
+ const now = Date.now();
5494
+ const last = this.topicProbeWarnings.get(key) ?? 0;
5495
+ if (now - last < FleetManager.FLEET_ERROR_THROTTLE_MS)
5496
+ return;
5497
+ this.topicProbeWarnings.set(key, now);
5498
+ this.logger.error({ instanceName, threadId, adapterId, reason }, "Topic presence could not be confirmed — retaining instance and all data");
5499
+ this.notifyFleetError(t("fleet.topic_probe_unknown", instanceName, adapterId ?? "unbound"));
5500
+ }
5501
+ /** One fixed-snapshot topology pass. Automatic evidence can only quarantine. */
5502
+ async runTopicCleanup(generation) {
5503
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5504
+ return;
5505
+ const snapshot = [...this.routing.entries()].filter(([, target]) => isProbeableRouteTarget(target));
5506
+ const missing = [];
5507
+ for (const [threadId, target] of snapshot) {
5508
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5463
5509
  return;
5464
- for (const [threadId, target] of this.routing.entries()) {
5465
- try {
5466
- if (!isProbeableRouteTarget(target)) {
5467
- continue;
5468
- }
5469
- const exists = await this.adapter.topicExists(threadId);
5470
- if (!exists) {
5471
- await this.topicCommands.handleTopicDeleted(threadId);
5472
- }
5473
- }
5474
- catch (err) {
5475
- this.logger.debug({ err, threadId }, "Topic existence check failed");
5476
- }
5510
+ // The route may have been replaced while an earlier probe was in flight.
5511
+ if (this.routing.resolve(threadId) !== target)
5512
+ continue;
5513
+ const adapterId = this.getInstanceAdapterId(target.name);
5514
+ const adapter = adapterId ? this.adapters.get(adapterId) : undefined;
5515
+ if (!adapterId || !adapter?.probeTopicPresence) {
5516
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "owner-adapter-unavailable");
5517
+ continue;
5518
+ }
5519
+ const before = this.confirmedProbeFence(adapterId, adapter);
5520
+ if (!before) {
5521
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "owner-adapter-not-ready");
5522
+ continue;
5477
5523
  }
5478
- }, 5 * 60_000);
5524
+ let result;
5525
+ try {
5526
+ result = await adapter.probeTopicPresence(threadId);
5527
+ }
5528
+ catch {
5529
+ result = { status: "unknown", reason: "provider-probe-threw" };
5530
+ }
5531
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown)
5532
+ return;
5533
+ if (!this.sameProbeFence(adapterId, adapter, before, result)) {
5534
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "owner-adapter-generation-changed");
5535
+ continue;
5536
+ }
5537
+ if (result.status === "unknown") {
5538
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, result.reason);
5539
+ }
5540
+ else if (result.status === "missing") {
5541
+ missing.push({ threadId, target, adapterId, adapter, generation: result.generation });
5542
+ }
5543
+ }
5544
+ if (generation !== this.topicCleanupGeneration || this.shuttingDown || missing.length === 0)
5545
+ return;
5546
+ if (missing.length > 1) {
5547
+ this.logger.error({ missing: missing.map(item => ({ instanceName: item.target.name, threadId: item.threadId, adapterId: item.adapterId })) }, "Multiple topics appeared missing in one pass — treating topology evidence as untrusted and retaining all data");
5548
+ this.notifyFleetError(t("fleet.topic_probe_bulk", missing.length));
5549
+ return;
5550
+ }
5551
+ const item = missing[0];
5552
+ if (this.routing.resolve(item.threadId) !== item.target
5553
+ || this.getInstanceAdapterId(item.target.name) !== item.adapterId
5554
+ || this.adapters.get(item.adapterId) !== item.adapter)
5555
+ return;
5556
+ const current = this.confirmedProbeFence(item.adapterId, item.adapter);
5557
+ if (!current || (item.generation !== undefined && current.generation !== item.generation)) {
5558
+ this.warnTopicProbeUnknown(item.target.name, item.threadId, item.adapterId, "owner-adapter-changed-before-action");
5559
+ return;
5560
+ }
5561
+ this.topicCommands.handleTopicDeleted(item.threadId, {
5562
+ source: "provider-probe",
5563
+ adapterId: item.adapterId,
5564
+ generation: item.generation,
5565
+ });
5566
+ }
5567
+ /**
5568
+ * A gateway event is only a hint. Confirm it through the passive REST probe;
5569
+ * reconnecting gateways have emitted false channelDelete events in practice.
5570
+ */
5571
+ async handleProviderTopicClosed(threadId, adapterId, adapter) {
5572
+ const target = this.routing.resolve(threadId);
5573
+ if (!target || !isProbeableRouteTarget(target))
5574
+ return;
5575
+ if (this.getInstanceAdapterId(target.name) !== adapterId)
5576
+ return;
5577
+ if (this.adapters.get(adapterId) !== adapter)
5578
+ return;
5579
+ const before = this.confirmedProbeFence(adapterId, adapter);
5580
+ if (!before || !adapter.probeTopicPresence) {
5581
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-from-unready-adapter");
5582
+ return;
5583
+ }
5584
+ let result;
5585
+ try {
5586
+ result = await adapter.probeTopicPresence(threadId);
5587
+ }
5588
+ catch {
5589
+ result = { status: "unknown", reason: "provider-probe-threw" };
5590
+ }
5591
+ if (this.routing.resolve(threadId) !== target
5592
+ || this.getInstanceAdapterId(target.name) !== adapterId
5593
+ || this.adapters.get(adapterId) !== adapter
5594
+ || !this.sameProbeFence(adapterId, adapter, before, result)) {
5595
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, "topic-close-generation-changed");
5596
+ return;
5597
+ }
5598
+ if (result.status !== "missing") {
5599
+ this.warnTopicProbeUnknown(target.name, threadId, adapterId, result.status === "unknown" ? result.reason : "topic-close-not-confirmed-missing");
5600
+ return;
5601
+ }
5602
+ this.topicCommands.handleTopicDeleted(threadId, {
5603
+ source: "provider-event",
5604
+ adapterId,
5605
+ generation: result.generation,
5606
+ });
5607
+ }
5608
+ bindTopicClosedHandler(adapter, adapterId, label) {
5609
+ adapter.on("topic_closed", safeHandler(async (data) => {
5610
+ if (this.topicArchiver.isArchived(data.threadId))
5611
+ return;
5612
+ await this.handleProviderTopicClosed(data.threadId, adapterId, adapter);
5613
+ }, this.logger, label));
5614
+ }
5615
+ /**
5616
+ * Remove only the volatile route. The instance config, daemon, schedules,
5617
+ * teams, metadata directory, and working tree remain untouched until an
5618
+ * authenticated explicit deletion is requested.
5619
+ */
5620
+ quarantineMissingTopic(threadId, target, evidence) {
5621
+ if (!isProbeableRouteTarget(target) || this.routing.resolve(threadId) !== target)
5622
+ return;
5623
+ this.routing.unregister(threadId);
5624
+ this.logger.error({ instanceName: target.name, threadId, ...evidence }, "Topic is confirmed missing — route quarantined; instance configuration and user data were retained");
5625
+ this.notifyFleetError(t("fleet.topic_quarantined", target.name, threadId));
5479
5626
  }
5480
5627
  /**
5481
5628
  * Patch only values changed in the effective config into the original YAML
@@ -5628,7 +5775,8 @@ export class FleetManager {
5628
5775
  }
5629
5776
  }
5630
5777
  }
5631
- async removeInstance(name) {
5778
+ async removeInstance(name, authorization) {
5779
+ assertExplicitInstanceRemoval(authorization);
5632
5780
  // Drop cached pane context — the map is keyed by instance name and nothing
5633
5781
  // else evicted deleted entries, so it grew for the life of the process.
5634
5782
  forgetInstanceContext(name);
@@ -5654,7 +5802,7 @@ export class FleetManager {
5654
5802
  }
5655
5803
  }
5656
5804
  }
5657
- await this.lifecycle.remove(name);
5805
+ await this.lifecycle.remove(name, authorization);
5658
5806
  // Clean up per-instance tracking maps so they don't grow unbounded
5659
5807
  // as instances are created and deleted over the lifetime of the fleet.
5660
5808
  this.lastActivity.delete(name);
@@ -9995,6 +10143,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
9995
10143
  clearInterval(this.topicCleanupTimer);
9996
10144
  this.topicCleanupTimer = null;
9997
10145
  }
10146
+ this.topicCleanupGeneration++;
9998
10147
  if (this.sessionPruneTimer) {
9999
10148
  clearInterval(this.sessionPruneTimer);
10000
10149
  this.sessionPruneTimer = null;