@songsid/agend 2.1.2-beta.29 → 2.1.2-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.
@@ -111,6 +111,8 @@ const STATE_REPORT_STALE_MS = 30 * 60_000;
111
111
  * wreckage, stuck or not.
112
112
  */
113
113
  const CANCEL_BTN_MAX_LIFETIME_MS = 24 * 60 * 60_000;
114
+ /** A click on a button the fleet no longer tracks may fire at most this often. */
115
+ const STALE_CANCEL_CLICK_COOLDOWN_MS = 10_000;
114
116
  /** Orphaned-button ledger, swept at startup. Lives in the fleet data dir. */
115
117
  const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
116
118
  /**
@@ -237,6 +239,8 @@ export class FleetManager {
237
239
  // Model failover state
238
240
  failoverActive = new Map(); // instance → current failover model
239
241
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
242
+ /** instance → when a click with no live button entry last fired a cancel. */
243
+ staleCancelClickAt = new Map();
240
244
  ipcStoppingInstances = new Set();
241
245
  /** Set the moment a graceful stop begins — see isPlannedRestart(). */
242
246
  shuttingDown = false;
@@ -1734,12 +1738,7 @@ export class FleetManager {
1734
1738
  return;
1735
1739
  }
1736
1740
  if (data.callbackData.startsWith("cancel:")) {
1737
- const instanceName = data.callbackData.slice("cancel:".length);
1738
- // Idempotent: a button click only acts while the button is live. A
1739
- // second click (entry already cleared) is a no-op — don't re-send the
1740
- // interrupt key. (The /cancel command path calls cancelInstance directly.)
1741
- if (this.hasCancelButton(instanceName))
1742
- this.cancelInstance(instanceName);
1741
+ this.handleCancelClick(data.callbackData.slice("cancel:".length), this.adapter, data);
1743
1742
  return;
1744
1743
  }
1745
1744
  }, this.logger, "adapter.callback_query"));
@@ -2046,10 +2045,7 @@ export class FleetManager {
2046
2045
  return;
2047
2046
  }
2048
2047
  if (data.callbackData.startsWith("cancel:")) {
2049
- const instanceName = data.callbackData.slice("cancel:".length);
2050
- // Idempotent: only the first click (while the button is live) acts.
2051
- if (this.hasCancelButton(instanceName))
2052
- this.cancelInstance(instanceName);
2048
+ this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter, data);
2053
2049
  return;
2054
2050
  }
2055
2051
  }, this.logger, `adapter[${adapterId}].callback_query`));
@@ -4141,6 +4137,40 @@ export class FleetManager {
4141
4137
  await data.respond(t("save.sent", cmd, target.name));
4142
4138
  }
4143
4139
  /** Whether the instance currently has at least one live cancel button. */
4140
+ /**
4141
+ * A click on a cancel button, whether or not the fleet still tracks it.
4142
+ *
4143
+ * The old rule was "act only while an entry is live", which made a click on a
4144
+ * button the fleet had forgotten a silent no-op — no cancel, no message, not
4145
+ * even a log line. That is indistinguishable from a broken button, and it is
4146
+ * what the "按鈕點了沒反應" reports were: the entry is briefly absent while a
4147
+ * button is being replaced, and a delete that fails leaves the message on
4148
+ * screen with no entry at all.
4149
+ *
4150
+ * So: honour the click if the instance is actually running, and say so plainly
4151
+ * if it is not. The stale-click path is rate-limited because the original
4152
+ * concern was real — a second click must not fire a second interrupt key at an
4153
+ * instance that has already started a new turn.
4154
+ */
4155
+ handleCancelClick(instanceName, adapter, data) {
4156
+ if (this.hasCancelButton(instanceName)) {
4157
+ this.cancelInstance(instanceName);
4158
+ return;
4159
+ }
4160
+ const lastAt = this.staleCancelClickAt.get(instanceName) ?? 0;
4161
+ if (Date.now() - lastAt < STALE_CANCEL_CLICK_COOLDOWN_MS)
4162
+ return;
4163
+ this.staleCancelClickAt.set(instanceName, Date.now());
4164
+ // cancelInstance returns false when there is no daemon — i.e. nothing to
4165
+ // cancel, which is the one case where the button really is dead.
4166
+ if (this.cancelInstance(instanceName)) {
4167
+ this.logger.info({ instanceName }, "Cancel click honoured with no live button entry");
4168
+ return;
4169
+ }
4170
+ this.logger.info({ instanceName }, "Cancel click on an expired button — instance not running");
4171
+ adapter?.editMessage(data.chatId, data.messageId, t("cancel.button_stale", instanceName), data.threadId)
4172
+ .catch(() => { });
4173
+ }
4144
4174
  hasCancelButton(instanceName) {
4145
4175
  for (const e of this.cancelButtons.values()) {
4146
4176
  if (e.instanceName === instanceName)
@@ -4149,10 +4179,12 @@ export class FleetManager {
4149
4179
  return false;
4150
4180
  }
4151
4181
  async sendCancelButton(instanceName, correlationId) {
4152
- // At most one button shown per instance: retire any existing ones first
4153
- // (delete + bounded retry). Each is tracked separately, so a failed delete
4154
- // here doesn't strand it it keeps retrying on its own timer.
4155
- this.retireInstanceButtons(instanceName);
4182
+ // Post first, retire after (see the tail of this method). Retiring up front
4183
+ // meant that from the delete until the new message came back a chat API
4184
+ // round trip, and every reply goes through here the instance had NO live
4185
+ // entry, while the old button was still on screen. A click in that window
4186
+ // hit `hasCancelButton() === false` and was silently dropped: the reported
4187
+ // "按鈕失效". If notifyAlert then failed, the button was simply gone.
4156
4188
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
4157
4189
  if (!adapter)
4158
4190
  return;
@@ -4191,13 +4223,6 @@ export class FleetManager {
4191
4223
  message: "👀 處理中…",
4192
4224
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
4193
4225
  }, threadId ? { threadId } : undefined);
4194
- // A concurrent sendCancelButton for the same instance may have posted its
4195
- // own button while we awaited notifyAlert. Retire any other buttons for
4196
- // this instance (not the one we just posted) so only the newest shows.
4197
- for (const other of this.cancelButtons.values()) {
4198
- if (other.instanceName === instanceName)
4199
- this.retireButton(other);
4200
- }
4201
4226
  const entry = {
4202
4227
  instanceName,
4203
4228
  adapterId,
@@ -4241,6 +4266,14 @@ export class FleetManager {
4241
4266
  });
4242
4267
  }, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
4243
4268
  this.cancelButtons.set(sent.messageId, entry);
4269
+ // Only now: at most one button per instance, but never zero. Covers both
4270
+ // the previous turn's button and any button a concurrent
4271
+ // sendCancelButton posted while we were awaiting notifyAlert.
4272
+ for (const other of [...this.cancelButtons.values()]) {
4273
+ if (other.instanceName === instanceName && other.messageId !== sent.messageId) {
4274
+ this.retireButton(other);
4275
+ }
4276
+ }
4244
4277
  this.persistCancelButtons();
4245
4278
  this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
4246
4279
  }