@songsid/agend 2.1.2-beta.17 → 2.1.2-beta.18
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/fleet-manager.d.ts +38 -0
- package/dist/fleet-manager.js +185 -14
- package/dist/fleet-manager.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.d.ts
CHANGED
|
@@ -138,7 +138,26 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
138
138
|
private scheduleReconcile;
|
|
139
139
|
private finishStartup;
|
|
140
140
|
lastActivityMs(name: string): number;
|
|
141
|
+
/**
|
|
142
|
+
* Is the instance between turns?
|
|
143
|
+
*
|
|
144
|
+
* Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
|
|
145
|
+
* the control client's raw 2-second output-silence heuristic. The raw heuristic
|
|
146
|
+
* reads every >2s output lull as idle — and long silent tools (a build, a test
|
|
147
|
+
* run) or an LLM pause produce those constantly mid-turn. That misreading is
|
|
148
|
+
* what retired cancel buttons in the middle of long work (the 5-minute backstop
|
|
149
|
+
* fired during a lull) and froze their progress text (ticker skipped "idle"
|
|
150
|
+
* ticks). The silence heuristic remains only as the fallback for instances
|
|
151
|
+
* whose daemon has not reported a state yet.
|
|
152
|
+
*/
|
|
141
153
|
private getInstanceIdle;
|
|
154
|
+
/**
|
|
155
|
+
* True when the instance claims working/stuck but nothing has refreshed that
|
|
156
|
+
* claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
|
|
157
|
+
* Measures the CACHE's age, not the button's — a healthy multi-hour run
|
|
158
|
+
* answers every query and never trips this.
|
|
159
|
+
*/
|
|
160
|
+
private stateReportDead;
|
|
142
161
|
webhookEmit(event: string, name: string, data?: Record<string, unknown>): void;
|
|
143
162
|
getSysInfo(): import("./fleet-context.js").SysInfo;
|
|
144
163
|
/** Load fleet.yaml and build routing table */
|
|
@@ -501,6 +520,12 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
501
520
|
* delete the very cancel button this is trying to keep alive.
|
|
502
521
|
*/
|
|
503
522
|
private startProgressTicker;
|
|
523
|
+
/**
|
|
524
|
+
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
525
|
+
* it has not, retire its button. Re-arming replaces the previous timer, so a
|
|
526
|
+
* burst of replies ends with exactly one pending check.
|
|
527
|
+
*/
|
|
528
|
+
private armReplyGrace;
|
|
504
529
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
505
530
|
private retireInstanceButtons;
|
|
506
531
|
/** Begin retiring one button (delete + bounded retry on failure). Idempotent:
|
|
@@ -510,6 +535,19 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
|
|
|
510
535
|
private attemptButtonDelete;
|
|
511
536
|
/** Clear an entry's timers (retry + idle-check) and drop it from the map. */
|
|
512
537
|
private discardButton;
|
|
538
|
+
/**
|
|
539
|
+
* Mirror the live buttons to disk. The map is memory-only, so before this a
|
|
540
|
+
* fleet restart orphaned every button on screen: frozen "處理中…" text and a
|
|
541
|
+
* click that did nothing, forever. The ledger is tiny (a handful of rows) and
|
|
542
|
+
* written on every add/remove — no debounce needed at that rate.
|
|
543
|
+
*/
|
|
544
|
+
private persistCancelButtons;
|
|
545
|
+
/**
|
|
546
|
+
* Delete the previous process's buttons. Runs once adapters are up: nothing
|
|
547
|
+
* from a previous fleet process can still be mid-turn from this process's
|
|
548
|
+
* point of view, so every ledger row is an orphan by definition.
|
|
549
|
+
*/
|
|
550
|
+
private sweepOrphanedCancelButtons;
|
|
513
551
|
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
514
552
|
private scheduleButtonRetry;
|
|
515
553
|
/** Delete one button's message via its own adapter. Resolves on success,
|
package/dist/fleet-manager.js
CHANGED
|
@@ -85,6 +85,32 @@ const CANCEL_BTN_MAX_RETRIES = 3;
|
|
|
85
85
|
* buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
|
|
86
86
|
* reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
|
|
87
87
|
const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
|
|
88
|
+
/**
|
|
89
|
+
* How long after a reply an instance gets to resume working before its cancel
|
|
90
|
+
* button is retired. A short turn ends with a reply and never works again → the
|
|
91
|
+
* button disappears ~2 minutes after the answer. A multi-step run replies
|
|
92
|
+
* mid-flight and keeps going → the grace check sees "working" and leaves the
|
|
93
|
+
* button alone (the idle edge retires it when the run really ends).
|
|
94
|
+
*/
|
|
95
|
+
const REPLY_RETIRE_GRACE_MS = 2 * 60_000;
|
|
96
|
+
/**
|
|
97
|
+
* The daemon only broadcasts execution state on TRANSITIONS, so a long
|
|
98
|
+
* single-state run sends nothing for hours. The idle backstop therefore pokes a
|
|
99
|
+
* query each tick; a live daemon answers within milliseconds and refreshes the
|
|
100
|
+
* cache. When nothing has refreshed it for this long despite those pokes, the
|
|
101
|
+
* reporting chain (daemon, IPC, or state monitor) is dead and a "working" state
|
|
102
|
+
* from 30 minutes ago proves nothing — the button may be retired.
|
|
103
|
+
*/
|
|
104
|
+
const STATE_REPORT_STALE_MS = 30 * 60_000;
|
|
105
|
+
/**
|
|
106
|
+
* Unconditional ceiling on a cancel button's life. Deliberately far beyond any
|
|
107
|
+
* legitimate run (multi-hour tasks are normal on this fleet): everything below
|
|
108
|
+
* this is decided by real state; a button that somehow survives a full day is
|
|
109
|
+
* wreckage, stuck or not.
|
|
110
|
+
*/
|
|
111
|
+
const CANCEL_BTN_MAX_LIFETIME_MS = 24 * 60 * 60_000;
|
|
112
|
+
/** Orphaned-button ledger, swept at startup. Lives in the fleet data dir. */
|
|
113
|
+
const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
|
|
88
114
|
/**
|
|
89
115
|
* How often the cancel button's text is refreshed with elapsed working time.
|
|
90
116
|
*
|
|
@@ -273,12 +299,35 @@ export class FleetManager {
|
|
|
273
299
|
this.startupComplete = true;
|
|
274
300
|
if (this.reloadPending)
|
|
275
301
|
this.scheduleReconcile();
|
|
302
|
+
void this.sweepOrphanedCancelButtons();
|
|
276
303
|
}
|
|
277
304
|
// ── ArchiverContext bridge ────────────────────────────────────────────
|
|
278
305
|
lastActivityMs(name) {
|
|
279
306
|
return this.lastActivity.get(name) ?? 0;
|
|
280
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* Is the instance between turns?
|
|
310
|
+
*
|
|
311
|
+
* Prefers the daemon's pane state machine (debounced, busy-pattern aware) over
|
|
312
|
+
* the control client's raw 2-second output-silence heuristic. The raw heuristic
|
|
313
|
+
* reads every >2s output lull as idle — and long silent tools (a build, a test
|
|
314
|
+
* run) or an LLM pause produce those constantly mid-turn. That misreading is
|
|
315
|
+
* what retired cancel buttons in the middle of long work (the 5-minute backstop
|
|
316
|
+
* fired during a lull) and froze their progress text (ticker skipped "idle"
|
|
317
|
+
* ticks). The silence heuristic remains only as the fallback for instances
|
|
318
|
+
* whose daemon has not reported a state yet.
|
|
319
|
+
*/
|
|
281
320
|
getInstanceIdle(name) {
|
|
321
|
+
// A daemon that is not running cannot be mid-turn. This is what a stale
|
|
322
|
+
// "working" cache after a hard daemon kill (SIGKILL/OOM — no IPC crash
|
|
323
|
+
// report ever arrives) must not override.
|
|
324
|
+
if (this.getInstanceStatus(name) !== "running")
|
|
325
|
+
return true;
|
|
326
|
+
const state = this.getInstanceExecutionState(name);
|
|
327
|
+
if (state === "working" || state === "stuck")
|
|
328
|
+
return false;
|
|
329
|
+
if (state === "idle")
|
|
330
|
+
return true;
|
|
282
331
|
try {
|
|
283
332
|
const widFile = join(this.getInstanceDir(name), "window-id");
|
|
284
333
|
if (!existsSync(widFile))
|
|
@@ -290,6 +339,18 @@ export class FleetManager {
|
|
|
290
339
|
return true;
|
|
291
340
|
}
|
|
292
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* True when the instance claims working/stuck but nothing has refreshed that
|
|
344
|
+
* claim for STATE_REPORT_STALE_MS despite the backstop's per-tick queries.
|
|
345
|
+
* Measures the CACHE's age, not the button's — a healthy multi-hour run
|
|
346
|
+
* answers every query and never trips this.
|
|
347
|
+
*/
|
|
348
|
+
stateReportDead(name) {
|
|
349
|
+
const cached = this.instanceStateCache.get(name);
|
|
350
|
+
if (!cached)
|
|
351
|
+
return false; // no claim to distrust — getInstanceIdle owns this case
|
|
352
|
+
return Date.now() - cached.receivedAt > STATE_REPORT_STALE_MS;
|
|
353
|
+
}
|
|
293
354
|
// ── LifecycleContext bridge methods ──────────────────────────────────────
|
|
294
355
|
webhookEmit(event, name, data) {
|
|
295
356
|
this.webhookEmitter?.emit(event, name, data);
|
|
@@ -518,6 +579,9 @@ export class FleetManager {
|
|
|
518
579
|
unchangedForMs: numberOr(msg.unchangedForMs, previous?.unchangedForMs ?? 0),
|
|
519
580
|
observedAt: numberOr(msg.observedAt, now),
|
|
520
581
|
stateChangedAt: numberOr(msg.stateChangedAt, previous?.state === state ? previous.stateChangedAt : now),
|
|
582
|
+
// Fleet-manager receipt time, NOT the daemon's observation time: staleness
|
|
583
|
+
// asks "is anyone still reporting", which only the receiver can date.
|
|
584
|
+
receivedAt: now,
|
|
521
585
|
});
|
|
522
586
|
for (const check of this.instanceIdleWaiters.get(name) ?? [])
|
|
523
587
|
check();
|
|
@@ -3016,17 +3080,18 @@ export class FleetManager {
|
|
|
3016
3080
|
// Route standard channel tools (reply, react, edit_message, download_attachment)
|
|
3017
3081
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
3018
3082
|
if (tool === "reply") {
|
|
3019
|
-
// A reply is NOT proof the turn is over
|
|
3020
|
-
//
|
|
3021
|
-
//
|
|
3022
|
-
//
|
|
3023
|
-
//
|
|
3024
|
-
// the
|
|
3083
|
+
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
3084
|
+
// more work either. Split the difference: an instance that is clearly
|
|
3085
|
+
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
3086
|
+
// below the reply so it stays last in the channel), with a 2-minute
|
|
3087
|
+
// grace check — if it has NOT resumed working by then, the reply was the
|
|
3088
|
+
// end of the turn and the button goes. A multi-step run that keeps
|
|
3089
|
+
// working sails through the check and keeps its button.
|
|
3025
3090
|
if (this.getInstanceIdle(instanceName)) {
|
|
3026
3091
|
this.clearCancelButton(instanceName);
|
|
3027
3092
|
}
|
|
3028
3093
|
else {
|
|
3029
|
-
void this.sendCancelButton(instanceName);
|
|
3094
|
+
void this.sendCancelButton(instanceName).then(() => this.armReplyGrace(instanceName));
|
|
3030
3095
|
}
|
|
3031
3096
|
this.reactDone(instanceName);
|
|
3032
3097
|
const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
|
|
@@ -4019,8 +4084,13 @@ export class FleetManager {
|
|
|
4019
4084
|
const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
|
|
4020
4085
|
if (!adapter)
|
|
4021
4086
|
return;
|
|
4087
|
+
// Resolve the group through the world fallback (first world when unbound),
|
|
4088
|
+
// NOT through getChannelConfig(binding)?.group_id: on a fleet configured with
|
|
4089
|
+
// `channels:` worlds the primary `channel:` block is empty, so an instance
|
|
4090
|
+
// with no world binding yet (fresh restart, cross-instance delegation)
|
|
4091
|
+
// resolved group_id to undefined and the button silently never appeared.
|
|
4022
4092
|
const adapterId = this.instanceWorldBinding.get(instanceName);
|
|
4023
|
-
const groupId = this.
|
|
4093
|
+
const groupId = this.getGroupIdForInstance(instanceName) || undefined;
|
|
4024
4094
|
const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
|
|
4025
4095
|
let chatId;
|
|
4026
4096
|
let threadId;
|
|
@@ -4036,8 +4106,12 @@ export class FleetManager {
|
|
|
4036
4106
|
if (!chatId && groupId)
|
|
4037
4107
|
chatId = String(groupId);
|
|
4038
4108
|
}
|
|
4039
|
-
if (!chatId)
|
|
4109
|
+
if (!chatId) {
|
|
4110
|
+
// A button that cannot be addressed must say so — this exact silence is how
|
|
4111
|
+
// "the cancel button sometimes never appears" stayed unreported-in-logs.
|
|
4112
|
+
this.logger.warn({ instanceName, topicId, groupId }, "Cannot address cancel button (no chat id resolved)");
|
|
4040
4113
|
return;
|
|
4114
|
+
}
|
|
4041
4115
|
try {
|
|
4042
4116
|
const sent = await adapter.notifyAlert(chatId, {
|
|
4043
4117
|
type: "cancel",
|
|
@@ -4064,6 +4138,10 @@ export class FleetManager {
|
|
|
4064
4138
|
// when the work was handed over — not from the pane's working transition,
|
|
4065
4139
|
// which resets if the CLI blips idle mid-turn.
|
|
4066
4140
|
startedAt: Date.now(),
|
|
4141
|
+
// Matches the text notifyAlert just posted, so the first 60s tick does
|
|
4142
|
+
// not re-edit identical text — which put a "(edited)" mark on Discord
|
|
4143
|
+
// with nothing visibly changed.
|
|
4144
|
+
lastProgressText: "👀 處理中…",
|
|
4067
4145
|
};
|
|
4068
4146
|
this.startProgressTicker(entry);
|
|
4069
4147
|
// Idle-check backstop: every 5min, if the instance is idle, retire the
|
|
@@ -4074,12 +4152,24 @@ export class FleetManager {
|
|
|
4074
4152
|
clearInterval(entry.idleCheckTimer);
|
|
4075
4153
|
return;
|
|
4076
4154
|
}
|
|
4077
|
-
|
|
4078
|
-
this.
|
|
4155
|
+
const reason = this.getInstanceIdle(instanceName) ? "idle"
|
|
4156
|
+
: this.stateReportDead(instanceName) ? "state reports stopped"
|
|
4157
|
+
: Date.now() - (entry.startedAt ?? 0) > CANCEL_BTN_MAX_LIFETIME_MS ? "24h ceiling"
|
|
4158
|
+
: null;
|
|
4159
|
+
if (reason) {
|
|
4160
|
+
this.logger.info({ instanceName, messageId: entry.messageId, reason }, "Cancel button backstop retiring");
|
|
4079
4161
|
this.retireButton(entry);
|
|
4162
|
+
return;
|
|
4080
4163
|
}
|
|
4164
|
+
// Still looks busy. The daemon only broadcasts on transitions, so ask for
|
|
4165
|
+
// a fresh snapshot — a live daemon's answer refreshes receivedAt and keeps
|
|
4166
|
+
// the staleness check honest; a dead one's silence is the evidence.
|
|
4167
|
+
this.instanceIpcClients.get(instanceName)?.send({
|
|
4168
|
+
type: "query_instance_state", requestId: `cancel-btn-${Date.now()}`,
|
|
4169
|
+
});
|
|
4081
4170
|
}, CANCEL_BTN_IDLE_CHECK_INTERVAL_MS);
|
|
4082
4171
|
this.cancelButtons.set(sent.messageId, entry);
|
|
4172
|
+
this.persistCancelButtons();
|
|
4083
4173
|
this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
|
|
4084
4174
|
}
|
|
4085
4175
|
catch (e) {
|
|
@@ -4152,9 +4242,6 @@ export class FleetManager {
|
|
|
4152
4242
|
clearInterval(entry.progressTimer);
|
|
4153
4243
|
return;
|
|
4154
4244
|
}
|
|
4155
|
-
// Idle means the turn ended; the idle-edge handler retires the button.
|
|
4156
|
-
if (this.getInstanceIdle(entry.instanceName))
|
|
4157
|
-
return;
|
|
4158
4245
|
const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
|
|
4159
4246
|
if (text === entry.lastProgressText)
|
|
4160
4247
|
return; // nothing changed — skip the API call
|
|
@@ -4177,6 +4264,29 @@ export class FleetManager {
|
|
|
4177
4264
|
}, PROGRESS_UPDATE_INTERVAL_MS);
|
|
4178
4265
|
entry.progressTimer.unref?.();
|
|
4179
4266
|
}
|
|
4267
|
+
/**
|
|
4268
|
+
* After a reply: give the instance REPLY_RETIRE_GRACE_MS to resume working; if
|
|
4269
|
+
* it has not, retire its button. Re-arming replaces the previous timer, so a
|
|
4270
|
+
* burst of replies ends with exactly one pending check.
|
|
4271
|
+
*/
|
|
4272
|
+
armReplyGrace(instanceName) {
|
|
4273
|
+
for (const entry of this.cancelButtons.values()) {
|
|
4274
|
+
if (entry.instanceName !== instanceName)
|
|
4275
|
+
continue;
|
|
4276
|
+
if (entry.replyGraceTimer)
|
|
4277
|
+
clearTimeout(entry.replyGraceTimer);
|
|
4278
|
+
entry.replyGraceTimer = setTimeout(() => {
|
|
4279
|
+
entry.replyGraceTimer = undefined;
|
|
4280
|
+
if (!this.cancelButtons.has(entry.messageId))
|
|
4281
|
+
return;
|
|
4282
|
+
if (!this.getInstanceIdle(instanceName))
|
|
4283
|
+
return; // resumed — a long run keeps its button
|
|
4284
|
+
this.logger.info({ instanceName, messageId: entry.messageId }, "Cancel button retired — no work resumed after reply");
|
|
4285
|
+
this.retireButton(entry);
|
|
4286
|
+
}, REPLY_RETIRE_GRACE_MS);
|
|
4287
|
+
entry.replyGraceTimer.unref?.();
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4180
4290
|
/** Retire (delete) every cancel button belonging to an instance. */
|
|
4181
4291
|
retireInstanceButtons(instanceName) {
|
|
4182
4292
|
// Snapshot first — retireButton may delete entries from the map on success.
|
|
@@ -4210,7 +4320,68 @@ export class FleetManager {
|
|
|
4210
4320
|
clearInterval(entry.idleCheckTimer);
|
|
4211
4321
|
if (entry.progressTimer)
|
|
4212
4322
|
clearInterval(entry.progressTimer);
|
|
4323
|
+
if (entry.replyGraceTimer)
|
|
4324
|
+
clearTimeout(entry.replyGraceTimer);
|
|
4213
4325
|
this.cancelButtons.delete(entry.messageId);
|
|
4326
|
+
this.persistCancelButtons();
|
|
4327
|
+
}
|
|
4328
|
+
/**
|
|
4329
|
+
* Mirror the live buttons to disk. The map is memory-only, so before this a
|
|
4330
|
+
* fleet restart orphaned every button on screen: frozen "處理中…" text and a
|
|
4331
|
+
* click that did nothing, forever. The ledger is tiny (a handful of rows) and
|
|
4332
|
+
* written on every add/remove — no debounce needed at that rate.
|
|
4333
|
+
*/
|
|
4334
|
+
persistCancelButtons() {
|
|
4335
|
+
try {
|
|
4336
|
+
const rows = [...this.cancelButtons.values()].map(e => ({
|
|
4337
|
+
instanceName: e.instanceName,
|
|
4338
|
+
adapterId: e.adapterId,
|
|
4339
|
+
chatId: e.chatId,
|
|
4340
|
+
messageId: e.messageId,
|
|
4341
|
+
threadId: e.threadId,
|
|
4342
|
+
}));
|
|
4343
|
+
writeFileSync(join(this.dataDir, CANCEL_BTN_LEDGER_FILE), JSON.stringify(rows));
|
|
4344
|
+
}
|
|
4345
|
+
catch (err) {
|
|
4346
|
+
this.logger.debug({ err }, "Cancel button ledger write failed");
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
/**
|
|
4350
|
+
* Delete the previous process's buttons. Runs once adapters are up: nothing
|
|
4351
|
+
* from a previous fleet process can still be mid-turn from this process's
|
|
4352
|
+
* point of view, so every ledger row is an orphan by definition.
|
|
4353
|
+
*/
|
|
4354
|
+
async sweepOrphanedCancelButtons() {
|
|
4355
|
+
const ledgerPath = join(this.dataDir, CANCEL_BTN_LEDGER_FILE);
|
|
4356
|
+
let rows;
|
|
4357
|
+
try {
|
|
4358
|
+
if (!existsSync(ledgerPath))
|
|
4359
|
+
return;
|
|
4360
|
+
rows = JSON.parse(readFileSync(ledgerPath, "utf-8"));
|
|
4361
|
+
}
|
|
4362
|
+
catch {
|
|
4363
|
+
try {
|
|
4364
|
+
unlinkSync(ledgerPath);
|
|
4365
|
+
}
|
|
4366
|
+
catch { /* corrupt ledger — drop it */ }
|
|
4367
|
+
return;
|
|
4368
|
+
}
|
|
4369
|
+
for (const row of rows) {
|
|
4370
|
+
const adapter = (row.adapterId ? this.worlds.get(row.adapterId)?.adapter : undefined)
|
|
4371
|
+
?? this.getAdapterForInstance?.(row.instanceName) ?? this.adapter;
|
|
4372
|
+
if (!adapter?.deleteMessage)
|
|
4373
|
+
continue;
|
|
4374
|
+
try {
|
|
4375
|
+
await adapter.deleteMessage(row.chatId, row.messageId, row.threadId);
|
|
4376
|
+
this.logger.info({ instanceName: row.instanceName, messageId: row.messageId }, "Swept orphaned cancel button from previous run");
|
|
4377
|
+
}
|
|
4378
|
+
catch (err) {
|
|
4379
|
+
// Best effort: the message may already be gone, or too old to delete.
|
|
4380
|
+
this.logger.debug({ err, messageId: row.messageId }, "Orphaned cancel button sweep failed");
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
// The current process owns the ledger from here on.
|
|
4384
|
+
this.persistCancelButtons();
|
|
4214
4385
|
}
|
|
4215
4386
|
/** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
|
|
4216
4387
|
scheduleButtonRetry(entry, err) {
|