@songsid/agend 2.1.2-beta.17 → 2.1.2-beta.19
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.
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +17 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +18 -0
- package/dist/backend/antigravity.js +31 -5
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/channel/ipc-timeouts.d.ts +7 -1
- package/dist/channel/ipc-timeouts.js +8 -1
- package/dist/channel/ipc-timeouts.js.map +1 -1
- package/dist/fleet-manager.d.ts +43 -0
- package/dist/fleet-manager.js +234 -34
- package/dist/fleet-manager.js.map +1 -1
- package/dist/reply-dedup.d.ts +41 -0
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -0
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
7
7
|
import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
|
|
8
8
|
import { readFleetMemory } from "./process-memory.js";
|
|
9
|
+
import { ReplyDeduper } from "./reply-dedup.js";
|
|
9
10
|
import { isScalar, parseDocument } from "yaml";
|
|
10
11
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
12
|
const __dirname = dirname(__filename);
|
|
@@ -85,6 +86,32 @@ const CANCEL_BTN_MAX_RETRIES = 3;
|
|
|
85
86
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
86
87
|
* reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
|
|
87
88
|
const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
89
|
+
/**
|
|
90
|
+
* How long after a reply an instance gets to resume working before its cancel
|
|
91
|
+
* button is retired. A short turn ends with a reply and never works again → the
|
|
92
|
+
* button disappears ~2 minutes after the answer. A multi-step run replies
|
|
93
|
+
* mid-flight and keeps going → the grace check sees "working" and leaves the
|
|
94
|
+
* button alone (the idle edge retires it when the run really ends).
|
|
95
|
+
*/
|
|
96
|
+
const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
|
|
97
|
+
/**
|
|
98
|
+
* The daemon only broadcasts execution state on TRANSITIONS, so a long
|
|
99
|
+
* single-state run sends nothing for hours. The idle backstop therefore pokes a
|
|
100
|
+
* query each tick; a live daemon answers within milliseconds and refreshes the
|
|
101
|
+
* cache. When nothing has refreshed it for this long despite those pokes, the
|
|
102
|
+
* reporting chain (daemon, IPC, or state monitor) is dead and a "working" state
|
|
103
|
+
* from 30 minutes ago proves nothing — the button may be retired.
|
|
104
|
+
*/
|
|
105
|
+
const STATE_REPORT_STALE_MS = 30 * 60_000;
|
|
106
|
+
/**
|
|
107
|
+
* Unconditional ceiling on a cancel button's life. Deliberately far beyond any
|
|
108
|
+
* legitimate run (multi-hour tasks are normal on this fleet): everything below
|
|
109
|
+
* this is decided by real state; a button that somehow survives a full day is
|
|
110
|
+
* wreckage, stuck or not.
|
|
111
|
+
*/
|
|
112
|
+
const CANCEL_BTN_MAX_LIFETIME_MS = 24 * 60 * 60_000;
|
|
113
|
+
/** Orphaned-button ledger, swept at startup. Lives in the fleet data dir. */
|
|
114
|
+
const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
88
115
|
/**
|
|
89
116
|
* How often the cancel button's text is refreshed with elapsed working time.
|
|
90
117
|
*
|
|
@@ -184,6 +211,8 @@ export class FleetManager {
|
|
|
184
211
|
// reply, on cancel, or when a newer button supersedes it for the same
|
|
185
212
|
// instance. Per-button tracking means a failed delete never strands a button.
|
|
186
213
|
cancelButtons = new Map();
|
|
214
|
+
/** Duplicate-reply suppression across both the MCP and HTTP reply paths. */
|
|
215
|
+
replyDeduper = new ReplyDeduper();
|
|
187
216
|
/** instanceName → what it is doing right now, when the backend can tell us. */
|
|
188
217
|
instanceActivity = new Map();
|
|
189
218
|
/** instanceName → tail of deliveries waiting for its IPC to come back. */
|
|
@@ -273,12 +302,35 @@ export class FleetManager {
|
|
|
273
302
|
this.startupComplete = true;
|
|
274
303
|
if (this.reloadPending)
|
|
275
304
|
this.scheduleReconcile();
|
|
305
|
+
void this.sweepOrphanedCancelButtons();
|
|
276
306
|
}
|
|
277
307
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
278
308
|
lastActivityMs(name) {
|
|
279
309
|
return this.lastActivity.get(name) ?? 0;
|
|
280
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Is the instance between turns?
|
|
313
|
+
*
|
|
314
|
+
* Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
|
|
315
|
+
* the control client's raw 2-second output-silence heuristic. The raw heuristic
|
|
316
|
+
* reads every >2s output lull as idle — and long silent tools (a build, a test
|
|
317
|
+
* run) or an LLM pause produce those constantly mid-turn. That misreading is
|
|
318
|
+
* what retired cancel buttons in the middle of long work (the 5-minute backstop
|
|
319
|
+
* fired during a lull) and froze their progress text (ticker skipped "idle"
|
|
320
|
+
* ticks). The silence heuristic remains only as the fallback for instances
|
|
321
|
+
* whose daemon has not reported a state yet.
|
|
322
|
+
*/
|
|
281
323
|
getInstanceIdle(name) {
|
|
324
|
+
// A daemon that is not running cannot be mid-turn. This is what a stale
|
|
325
|
+
// "working" cache after a hard daemon kill (SIGKILL/OOM — no IPC crash
|
|
326
|
+
// report ever arrives) must not override.
|
|
327
|
+
if (this.getInstanceStatus(name) !== "running")
|
|
328
|
+
return true;
|
|
329
|
+
const state = this.getInstanceExecutionState(name);
|
|
330
|
+
if (state === "working" || state === "stuck")
|
|
331
|
+
return false;
|
|
332
|
+
if (state === "idle")
|
|
333
|
+
return true;
|
|
282
334
|
try {
|
|
283
335
|
const widFile = join(this.getInstanceDir(name), "window-id");
|
|
284
336
|
if (!existsSync(widFile))
|
|
@@ -290,6 +342,18 @@ export class FleetManager {
|
|
|
290
342
|
return true;
|
|
291
343
|
}
|
|
292
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* True when the instance claims working/stuck but nothing has refreshed that
|
|
347
|
+
* claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
|
|
348
|
+
* Measures the CACHE's age, not the button's — a healthy multi-hour run
|
|
349
|
+
* answers every query and never trips this.
|
|
350
|
+
*/
|
|
351
|
+
stateReportDead(name) {
|
|
352
|
+
const cached = this.instanceStateCache.get(name);
|
|
353
|
+
if (!cached)
|
|
354
|
+
return false; // no claim to distrust — getInstanceIdle owns this case
|
|
355
|
+
return Date.now() - cached.receivedAt > STATE_REPORT_STALE_MS;
|
|
356
|
+
}
|
|
293
357
|
// ── LifecycleContext bridge methods ──────────────────────────────────────
|
|
294
358
|
webhookEmit(event, name, data) {
|
|
295
359
|
this.webhookEmitter?.emit(event, name, data);
|
|
@@ -518,6 +582,9 @@ export class FleetManager {
|
|
|
518
582
|
unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
|
|
519
583
|
observedAt: numberOr(msg.observedAt, now),
|
|
520
584
|
stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
|
|
585
|
+
// Fleet-manager receipt time, NOT the daemon's observation time: staleness
|
|
586
|
+
// asks "is anyone still reporting", which only the receiver can date.
|
|
587
|
+
receivedAt: now,
|
|
521
588
|
});
|
|
522
589
|
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
523
590
|
check();
|
|
@@ -3013,35 +3080,33 @@ export class FleetManager {
|
|
|
3013
3080
|
delete args.thread_id;
|
|
3014
3081
|
threadId = undefined;
|
|
3015
3082
|
}
|
|
3083
|
+
// Reply dedup: retries land here when the agent was told a send failed
|
|
3084
|
+
// (daemon budget elapsed, shell tool killed) while the adapter send was
|
|
3085
|
+
// still in flight and about to succeed. One real send, everyone gets its
|
|
3086
|
+
// outcome; a genuinely failed send clears the entry so a retry passes.
|
|
3087
|
+
if (tool === "reply") {
|
|
3088
|
+
const ticket = this.replyDeduper.begin(instanceName, String(args.text ?? ""), Array.isArray(args.files) ? args.files : []);
|
|
3089
|
+
if (ticket.duplicate) {
|
|
3090
|
+
this.logger.info({ instanceName }, "Duplicate reply suppressed — replaying the original send's outcome");
|
|
3091
|
+
ticket.subscribe(respond);
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
const original = respond;
|
|
3095
|
+
const respondAndRecord = (result, error) => {
|
|
3096
|
+
ticket.complete(result, error);
|
|
3097
|
+
original(result, error);
|
|
3098
|
+
};
|
|
3099
|
+
if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
|
|
3100
|
+
this.afterReplyRouted(instanceName, args, senderSessionName);
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
// routeToolCall knows "reply"; not handling it means the world changed.
|
|
3104
|
+
ticket.complete(null, "reply not handled");
|
|
3105
|
+
original(null, "reply not handled");
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3016
3108
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
3017
3109
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
3018
|
-
if (tool === "reply") {
|
|
3019
|
-
// A reply is NOT proof the turn is over: on multi-step work an agent
|
|
3020
|
-
// replies ("starting…") and keeps going for many minutes. Retiring the
|
|
3021
|
-
// button here left the channel looking idle with no way to cancel and no
|
|
3022
|
-
// sign anything was happening (#410). Idle state owns retirement now; if the
|
|
3023
|
-
// instance is still working, move the button below the new reply so it stays
|
|
3024
|
-
// the last thing in the channel.
|
|
3025
|
-
if (this.getInstanceIdle(instanceName)) {
|
|
3026
|
-
this.clearCancelButton(instanceName);
|
|
3027
|
-
}
|
|
3028
|
-
else {
|
|
3029
|
-
void this.sendCancelButton(instanceName);
|
|
3030
|
-
}
|
|
3031
|
-
this.reactDone(instanceName);
|
|
3032
|
-
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
3033
|
-
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
3034
|
-
this.emitSseEvent("message", {
|
|
3035
|
-
instance: instanceName, sender: senderSessionName ?? instanceName,
|
|
3036
|
-
text: (args.text ?? "").slice(0, 2000),
|
|
3037
|
-
ts: new Date().toISOString(),
|
|
3038
|
-
});
|
|
3039
|
-
// Log bot reply to classic instance chat-log
|
|
3040
|
-
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
3041
|
-
if (isClassic) {
|
|
3042
|
-
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
3043
|
-
}
|
|
3044
|
-
}
|
|
3045
3110
|
return;
|
|
3046
3111
|
}
|
|
3047
3112
|
// Log tool calls for activity visualization
|
|
@@ -3056,6 +3121,35 @@ export class FleetManager {
|
|
|
3056
3121
|
respond(null, `Unknown tool: ${tool}`);
|
|
3057
3122
|
}
|
|
3058
3123
|
}
|
|
3124
|
+
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
3125
|
+
afterReplyRouted(instanceName, args, senderSessionName) {
|
|
3126
|
+
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
3127
|
+
// more work either. Split the difference: an instance that is clearly
|
|
3128
|
+
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
3129
|
+
// below the reply so it stays last in the channel), with a 2-minute
|
|
3130
|
+
// grace check — if it has NOT resumed working by then, the reply was the
|
|
3131
|
+
// end of the turn and the button goes. A multi-step run that keeps
|
|
3132
|
+
// working sails through the check and keeps its button.
|
|
3133
|
+
if (this.getInstanceIdle(instanceName)) {
|
|
3134
|
+
this.clearCancelButton(instanceName);
|
|
3135
|
+
}
|
|
3136
|
+
else {
|
|
3137
|
+
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
3138
|
+
}
|
|
3139
|
+
this.reactDone(instanceName);
|
|
3140
|
+
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
3141
|
+
this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
|
|
3142
|
+
this.emitSseEvent("message", {
|
|
3143
|
+
instance: instanceName, sender: senderSessionName ?? instanceName,
|
|
3144
|
+
text: (args.text ?? "").slice(0, 2000),
|
|
3145
|
+
ts: new Date().toISOString(),
|
|
3146
|
+
});
|
|
3147
|
+
// Log bot reply to classic instance chat-log
|
|
3148
|
+
const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
|
|
3149
|
+
if (isClassic) {
|
|
3150
|
+
ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3059
3153
|
/** Handle tool status update from a daemon instance */
|
|
3060
3154
|
handleToolStatusFromInstance(instanceName, msg) {
|
|
3061
3155
|
const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
@@ -4019,8 +4113,13 @@ export class FleetManager {
|
|
|
4019
4113
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
4020
4114
|
if (!adapter)
|
|
4021
4115
|
return;
|
|
4116
|
+
// Resolve the group through the world fallback (first world when unbound),
|
|
4117
|
+
// NOT through getChannelConfig(binding)?.group_id: on a fleet configured with
|
|
4118
|
+
// `channels:` worlds the primary `channel:` block is empty, so an instance
|
|
4119
|
+
// with no world binding yet (fresh restart, cross-instance delegation)
|
|
4120
|
+
// resolved group_id to undefined and the button silently never appeared.
|
|
4022
4121
|
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
4023
|
-
const groupId = this.
|
|
4122
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
4024
4123
|
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
4025
4124
|
let chatId;
|
|
4026
4125
|
let threadId;
|
|
@@ -4036,8 +4135,12 @@ export class FleetManager {
|
|
|
4036
4135
|
if (!chatId && groupId)
|
|
4037
4136
|
chatId = String(groupId);
|
|
4038
4137
|
}
|
|
4039
|
-
if (!chatId)
|
|
4138
|
+
if (!chatId) {
|
|
4139
|
+
// A button that cannot be addressed must say so — this exact silence is how
|
|
4140
|
+
// "the cancel button sometimes never appears" stayed unreported-in-logs.
|
|
4141
|
+
this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
|
|
4040
4142
|
return;
|
|
4143
|
+
}
|
|
4041
4144
|
try {
|
|
4042
4145
|
const sent = await adapter.notifyAlert(chatId, {
|
|
4043
4146
|
type: "cancel",
|
|
@@ -4064,6 +4167,10 @@ export class FleetManager {
|
|
|
4064
4167
|
// when the work was handed over — not from the pane's working transition,
|
|
4065
4168
|
// which resets if the CLI blips idle mid-turn.
|
|
4066
4169
|
startedAt: Date.now(),
|
|
4170
|
+
// Matches the text notifyAlert just posted, so the first 60s tick does
|
|
4171
|
+
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4172
|
+
// with nothing visibly changed.
|
|
4173
|
+
lastProgressText: "👀 處理中…",
|
|
4067
4174
|
};
|
|
4068
4175
|
this.startProgressTicker(entry);
|
|
4069
4176
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -4074,12 +4181,24 @@ export class FleetManager {
|
|
|
4074
4181
|
clearInterval(entry.idleCheckTimer);
|
|
4075
4182
|
return;
|
|
4076
4183
|
}
|
|
4077
|
-
|
|
4078
|
-
this.
|
|
4184
|
+
const reason = this.getInstanceIdle(instanceName) ? "idle"
|
|
4185
|
+
: this.stateReportDead(instanceName) ? "state reports stopped"
|
|
4186
|
+
: Date.now() - (entry.startedAt ?? 0) > CANCEL_BTN_MAX_LIFETIME_MS ? "24h ceiling"
|
|
4187
|
+
: null;
|
|
4188
|
+
if (reason) {
|
|
4189
|
+
this.logger.info({ instanceName, messageId: entry.messageId, reason }, "Cancel button backstop retiring");
|
|
4079
4190
|
this.retireButton(entry);
|
|
4191
|
+
return;
|
|
4080
4192
|
}
|
|
4193
|
+
// Still looks busy. The daemon only broadcasts on transitions, so ask for
|
|
4194
|
+
// a fresh snapshot — a live daemon's answer refreshes receivedAt and keeps
|
|
4195
|
+
// the staleness check honest; a dead one's silence is the evidence.
|
|
4196
|
+
this.instanceIpcClients.get(instanceName)?.send({
|
|
4197
|
+
type: "query_instance_state", requestId: `cancel-btn-${Date.now()}`,
|
|
4198
|
+
});
|
|
4081
4199
|
}, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
|
|
4082
4200
|
this.cancelButtons.set(sent.messageId, entry);
|
|
4201
|
+
this.persistCancelButtons();
|
|
4083
4202
|
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
4084
4203
|
}
|
|
4085
4204
|
catch (e) {
|
|
@@ -4152,9 +4271,6 @@ export class FleetManager {
|
|
|
4152
4271
|
clearInterval(entry.progressTimer);
|
|
4153
4272
|
return;
|
|
4154
4273
|
}
|
|
4155
|
-
// Idle means the turn ended; the idle-edge handler retires the button.
|
|
4156
|
-
if (this.getInstanceIdle(entry.instanceName))
|
|
4157
|
-
return;
|
|
4158
4274
|
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
|
|
4159
4275
|
if (text === entry.lastProgressText)
|
|
4160
4276
|
return; // nothing changed — skip the API call
|
|
@@ -4177,6 +4293,29 @@ export class FleetManager {
|
|
|
4177
4293
|
}, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4178
4294
|
entry.progressTimer.unref?.();
|
|
4179
4295
|
}
|
|
4296
|
+
/**
|
|
4297
|
+
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
4298
|
+
* it has not, retire its button. Re-arming replaces the previous timer, so a
|
|
4299
|
+
* burst of replies ends with exactly one pending check.
|
|
4300
|
+
*/
|
|
4301
|
+
armReplyGrace(instanceName) {
|
|
4302
|
+
for (const entry of this.cancelButtons.values()) {
|
|
4303
|
+
if (entry.instanceName !== instanceName)
|
|
4304
|
+
continue;
|
|
4305
|
+
if (entry.replyGraceTimer)
|
|
4306
|
+
clearTimeout(entry.replyGraceTimer);
|
|
4307
|
+
entry.replyGraceTimer = setTimeout(() => {
|
|
4308
|
+
entry.replyGraceTimer = undefined;
|
|
4309
|
+
if (!this.cancelButtons.has(entry.messageId))
|
|
4310
|
+
return;
|
|
4311
|
+
if (!this.getInstanceIdle(instanceName))
|
|
4312
|
+
return; // resumed — a long run keeps its button
|
|
4313
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
4314
|
+
this.retireButton(entry);
|
|
4315
|
+
}, REPLY_RETIRE_GRACE_MS);
|
|
4316
|
+
entry.replyGraceTimer.unref?.();
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4180
4319
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
4181
4320
|
retireInstanceButtons(instanceName) {
|
|
4182
4321
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -4210,7 +4349,68 @@ export class FleetManager {
|
|
|
4210
4349
|
clearInterval(entry.idleCheckTimer);
|
|
4211
4350
|
if (entry.progressTimer)
|
|
4212
4351
|
clearInterval(entry.progressTimer);
|
|
4352
|
+
if (entry.replyGraceTimer)
|
|
4353
|
+
clearTimeout(entry.replyGraceTimer);
|
|
4213
4354
|
this.cancelButtons.delete(entry.messageId);
|
|
4355
|
+
this.persistCancelButtons();
|
|
4356
|
+
}
|
|
4357
|
+
/**
|
|
4358
|
+
* Mirror the live buttons to disk. The map is memory-only, so before this a
|
|
4359
|
+
* fleet restart orphaned every button on screen: frozen "處理中…" text and a
|
|
4360
|
+
* click that did nothing, forever. The ledger is tiny (a handful of rows) and
|
|
4361
|
+
* written on every add/remove — no debounce needed at that rate.
|
|
4362
|
+
*/
|
|
4363
|
+
persistCancelButtons() {
|
|
4364
|
+
try {
|
|
4365
|
+
const rows = [...this.cancelButtons.values()].map(e => ({
|
|
4366
|
+
instanceName: e.instanceName,
|
|
4367
|
+
adapterId: e.adapterId,
|
|
4368
|
+
chatId: e.chatId,
|
|
4369
|
+
messageId: e.messageId,
|
|
4370
|
+
threadId: e.threadId,
|
|
4371
|
+
}));
|
|
4372
|
+
writeFileSync(join(this.dataDir, CANCEL_BTN_LEDGER_FILE), JSON.stringify(rows));
|
|
4373
|
+
}
|
|
4374
|
+
catch (err) {
|
|
4375
|
+
this.logger.debug({ err }, "Cancel button ledger write failed");
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
/**
|
|
4379
|
+
* Delete the previous process's buttons. Runs once adapters are up: nothing
|
|
4380
|
+
* from a previous fleet process can still be mid-turn from this process's
|
|
4381
|
+
* point of view, so every ledger row is an orphan by definition.
|
|
4382
|
+
*/
|
|
4383
|
+
async sweepOrphanedCancelButtons() {
|
|
4384
|
+
const ledgerPath = join(this.dataDir, CANCEL_BTN_LEDGER_FILE);
|
|
4385
|
+
let rows;
|
|
4386
|
+
try {
|
|
4387
|
+
if (!existsSync(ledgerPath))
|
|
4388
|
+
return;
|
|
4389
|
+
rows = JSON.parse(readFileSync(ledgerPath, "utf-8"));
|
|
4390
|
+
}
|
|
4391
|
+
catch {
|
|
4392
|
+
try {
|
|
4393
|
+
unlinkSync(ledgerPath);
|
|
4394
|
+
}
|
|
4395
|
+
catch { /* corrupt ledger — drop it */ }
|
|
4396
|
+
return;
|
|
4397
|
+
}
|
|
4398
|
+
for (const row of rows) {
|
|
4399
|
+
const adapter = (row.adapterId ? this.worlds.get(row.adapterId)?.adapter : undefined)
|
|
4400
|
+
?? this.getAdapterForInstance?.(row.instanceName) ?? this.adapter;
|
|
4401
|
+
if (!adapter?.deleteMessage)
|
|
4402
|
+
continue;
|
|
4403
|
+
try {
|
|
4404
|
+
await adapter.deleteMessage(row.chatId, row.messageId, row.threadId);
|
|
4405
|
+
this.logger.info({ instanceName: row.instanceName, messageId: row.messageId }, "Swept orphaned cancel button from previous run");
|
|
4406
|
+
}
|
|
4407
|
+
catch (err) {
|
|
4408
|
+
// Best effort: the message may already be gone, or too old to delete.
|
|
4409
|
+
this.logger.debug({ err, messageId: row.messageId }, "Orphaned cancel button sweep failed");
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
// The current process owns the ledger from here on.
|
|
4413
|
+
this.persistCancelButtons();
|
|
4214
4414
|
}
|
|
4215
4415
|
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
4216
4416
|
scheduleButtonRetry(entry, err) {
|