@link-assistant/hive-mind 2.13.1 → 2.13.3
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +19 -5
- package/src/buildUserMention.lib.mjs +30 -3
- package/src/claude.connection.lib.mjs +35 -43
- package/src/claude.lib.mjs +44 -13
- package/src/codex.lib.mjs +33 -10
- package/src/config.lib.mjs +23 -5
- package/src/gemini.lib.mjs +19 -5
- package/src/github-url-parser.lib.mjs +26 -1
- package/src/opencode.lib.mjs +19 -5
- package/src/qwen.lib.mjs +19 -5
- package/src/session-monitor.lib.mjs +3 -2
- package/src/telegram-accept-invitations.lib.mjs +5 -3
- package/src/telegram-bot.mjs +18 -9
- package/src/telegram-command-execution.lib.mjs +2 -1
- package/src/telegram-context-safety.lib.mjs +70 -0
- package/src/telegram-fix-command.lib.mjs +68 -4
- package/src/telegram-language-command.lib.mjs +4 -3
- package/src/telegram-log-command.lib.mjs +14 -12
- package/src/telegram-markdown-validator.lib.mjs +192 -0
- package/src/telegram-merge-command.lib.mjs +18 -16
- package/src/telegram-message-filters.lib.mjs +1 -1
- package/src/telegram-safe-reply.lib.mjs +290 -21
- package/src/telegram-solve-queue-command.lib.mjs +2 -1
- package/src/telegram-solve-queue.lib.mjs +16 -7
- package/src/telegram-start-stop-command.lib.mjs +38 -27
- package/src/telegram-subscribers.lib.mjs +6 -4
- package/src/telegram-terminal-watch-command.lib.mjs +8 -7
- package/src/telegram-tokens-command.lib.mjs +2 -1
- package/src/telegram-top-command.lib.mjs +8 -9
- package/src/tool-retry.lib.mjs +117 -6
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
|
|
32
32
|
import { parseGitHubUrl } from './github.lib.mjs';
|
|
33
33
|
import { cleanNonPrintableChars } from './telegram-markdown.lib.mjs';
|
|
34
|
+
// Issue #2166: every reply/edit in this module goes through the one send funnel
|
|
35
|
+
// (validated, logged, plain-text fallback) even when a caller does not inject it.
|
|
36
|
+
import { safeReply as defaultSafeReply, safeEditMessageText as defaultSafeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
34
37
|
|
|
35
38
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
36
39
|
|
|
@@ -146,7 +149,9 @@ function findFirstIssueOrPullUrl(text) {
|
|
|
146
149
|
if (!word) continue;
|
|
147
150
|
const parsed = parseGitHubUrl(word);
|
|
148
151
|
if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
|
|
149
|
-
|
|
152
|
+
// Issue #2166: match on what the bot interprets, not on the fragment the
|
|
153
|
+
// user happened to copy along with the link.
|
|
154
|
+
return parsed.canonical || parsed.normalized;
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
return null;
|
|
@@ -203,14 +208,17 @@ export function extractStopTarget(text, repliedTo) {
|
|
|
203
208
|
* @returns {Promise<boolean>} true when the card was edited
|
|
204
209
|
* @see https://github.com/link-assistant/hive-mind/issues/1783
|
|
205
210
|
*/
|
|
206
|
-
export async function updateQueueCardForCancellation(item, url, tool, stopperName) {
|
|
211
|
+
export async function updateQueueCardForCancellation(item, url, tool, stopperName, { safeEditMessageText = defaultSafeEditMessageText, verbose = false } = {}) {
|
|
207
212
|
if (!item || !item.messageInfo || !item.ctx) return false;
|
|
208
213
|
const toolSuffix = tool ? ` from \`${tool}\` queue` : '';
|
|
209
214
|
const stopperSuffix = stopperName ? ` by ${stopperName}` : '';
|
|
210
215
|
const text = `🗑 *Cancelled*\n\n${url}\n\nRemoved${toolSuffix}${stopperSuffix} via /stop.`;
|
|
211
216
|
try {
|
|
212
217
|
const { chatId, messageId } = item.messageInfo;
|
|
213
|
-
|
|
218
|
+
// Issue #2166: go through the shared safe funnel when the caller provides
|
|
219
|
+
// it, so a card whose URL contains Markdown-significant characters is
|
|
220
|
+
// still updated (as plain text) instead of failing silently.
|
|
221
|
+
await safeEditMessageText(item.ctx.telegram, chatId, messageId, undefined, text, { verbose });
|
|
214
222
|
// Match the consumer's contract: once a card reaches a terminal state we
|
|
215
223
|
// forget the message coordinates so nothing else tries to edit it.
|
|
216
224
|
item.messageInfo = null;
|
|
@@ -270,7 +278,10 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
|
|
|
270
278
|
* See https://github.com/link-assistant/hive-mind/issues/1871.
|
|
271
279
|
*/
|
|
272
280
|
export function registerStartStopCommands(bot, options) {
|
|
273
|
-
|
|
281
|
+
// Issue #2166: every reply goes through the shared safe-send funnel (pre-send
|
|
282
|
+
// validation, audit log, plain-text fallback). The defaults keep the module
|
|
283
|
+
// usable in isolation (tests) without a bot wired up.
|
|
284
|
+
const { VERBOSE = false, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue, safeReply = defaultSafeReply, safeEditMessageText = defaultSafeEditMessageText } = options;
|
|
274
285
|
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
275
286
|
// Issue #1783: look the UUID up in the session monitor so /stop can let the
|
|
276
287
|
// user who started the task stop it (mirrors /terminal_watch from PR #1779).
|
|
@@ -344,25 +355,25 @@ export function registerStartStopCommands(bot, options) {
|
|
|
344
355
|
if (!isGroupChat(ctx)) {
|
|
345
356
|
if (opts.allowPrivate) return { valid: false, isPrivate: true };
|
|
346
357
|
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: not a group chat`);
|
|
347
|
-
await ctx
|
|
358
|
+
await safeReply(ctx, `❌ The ${cmdName} command only works in group chats.`, { reply_to_message_id: ctx.message.message_id });
|
|
348
359
|
return { valid: false };
|
|
349
360
|
}
|
|
350
361
|
const chatId = ctx.chat.id;
|
|
351
362
|
if (!isChatAuthorized(chatId)) {
|
|
352
363
|
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: chat not authorized`);
|
|
353
|
-
await ctx
|
|
364
|
+
await safeReply(ctx, `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`, { reply_to_message_id: ctx.message.message_id });
|
|
354
365
|
return { valid: false };
|
|
355
366
|
}
|
|
356
367
|
try {
|
|
357
368
|
const chatMember = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
358
369
|
if (chatMember.status !== 'creator') {
|
|
359
370
|
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: user is not chat owner`);
|
|
360
|
-
await ctx
|
|
371
|
+
await safeReply(ctx, '❌ This command is only available to the chat owner.', { reply_to_message_id: ctx.message.message_id });
|
|
361
372
|
return { valid: false };
|
|
362
373
|
}
|
|
363
374
|
} catch (error) {
|
|
364
375
|
console.error('[ERROR] Failed to check chat member status:', error);
|
|
365
|
-
await ctx
|
|
376
|
+
await safeReply(ctx, '❌ Failed to verify permissions.', { reply_to_message_id: ctx.message.message_id });
|
|
366
377
|
return { valid: false };
|
|
367
378
|
}
|
|
368
379
|
VERBOSE && console.log(`[VERBOSE] ${cmdName} passed all checks`);
|
|
@@ -395,13 +406,13 @@ export function registerStartStopCommands(bot, options) {
|
|
|
395
406
|
const chatType = ctx.chat?.type;
|
|
396
407
|
if (chatType === 'private') return true;
|
|
397
408
|
if (!isGroupChat(ctx)) {
|
|
398
|
-
await ctx
|
|
409
|
+
await safeReply(ctx, '❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
|
|
399
410
|
return false;
|
|
400
411
|
}
|
|
401
412
|
if (!isChatAuthorized(chatId)) {
|
|
402
413
|
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
403
414
|
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
404
|
-
await ctx
|
|
415
|
+
await safeReply(ctx, errMsg, { reply_to_message_id: message.message_id });
|
|
405
416
|
return false;
|
|
406
417
|
}
|
|
407
418
|
}
|
|
@@ -415,12 +426,12 @@ export function registerStartStopCommands(bot, options) {
|
|
|
415
426
|
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
416
427
|
if (!member || member.status !== 'creator') {
|
|
417
428
|
VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner or task requester`);
|
|
418
|
-
await ctx
|
|
429
|
+
await safeReply(ctx, `❌ /stop <${label}> is only available to the chat owner or the user who started this task.`, { reply_to_message_id: message.message_id });
|
|
419
430
|
return false;
|
|
420
431
|
}
|
|
421
432
|
} catch (error) {
|
|
422
433
|
console.error(`[ERROR] /stop <${label}>: getChatMember failed:`, error);
|
|
423
|
-
await ctx
|
|
434
|
+
await safeReply(ctx, '❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
424
435
|
return false;
|
|
425
436
|
}
|
|
426
437
|
return true;
|
|
@@ -438,7 +449,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
438
449
|
*/
|
|
439
450
|
async function runStopIsolatedSessionFlow(ctx, sessionId) {
|
|
440
451
|
const message = ctx.message;
|
|
441
|
-
const ack = await ctx
|
|
452
|
+
const ack = await safeReply(ctx, `⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
|
|
442
453
|
parse_mode: 'Markdown',
|
|
443
454
|
reply_to_message_id: message.message_id,
|
|
444
455
|
});
|
|
@@ -481,10 +492,10 @@ export function registerStartStopCommands(bot, options) {
|
|
|
481
492
|
}
|
|
482
493
|
|
|
483
494
|
try {
|
|
484
|
-
await ctx.telegram
|
|
495
|
+
await safeEditMessageText(ctx.telegram, ack.chat.id, ack.message_id, undefined, lines.join('\n'), { verbose: VERBOSE });
|
|
485
496
|
} catch (error) {
|
|
486
497
|
console.error('[ERROR] /stop: editMessageText failed, falling back to reply:', error);
|
|
487
|
-
await ctx
|
|
498
|
+
await safeReply(ctx, lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
488
499
|
}
|
|
489
500
|
}
|
|
490
501
|
|
|
@@ -621,13 +632,13 @@ export function registerStartStopCommands(bot, options) {
|
|
|
621
632
|
// running-but-non-stoppable (non-isolation) session, say so; otherwise
|
|
622
633
|
// fall back to the UUID hint.
|
|
623
634
|
if (runningSession) {
|
|
624
|
-
await ctx
|
|
635
|
+
await safeReply(ctx, `⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
|
|
625
636
|
parse_mode: 'Markdown',
|
|
626
637
|
reply_to_message_id: message.message_id,
|
|
627
638
|
});
|
|
628
639
|
return;
|
|
629
640
|
}
|
|
630
|
-
await ctx
|
|
641
|
+
await safeReply(ctx, `ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
|
|
631
642
|
parse_mode: 'Markdown',
|
|
632
643
|
reply_to_message_id: message.message_id,
|
|
633
644
|
});
|
|
@@ -639,13 +650,13 @@ export function registerStartStopCommands(bot, options) {
|
|
|
639
650
|
// have forwarded CTRL+C above). If it tracked a non-isolation session,
|
|
640
651
|
// explain why it can't be stopped; otherwise report not found.
|
|
641
652
|
if (runningSession) {
|
|
642
|
-
await ctx
|
|
653
|
+
await safeReply(ctx, `⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
|
|
643
654
|
parse_mode: 'Markdown',
|
|
644
655
|
reply_to_message_id: message.message_id,
|
|
645
656
|
});
|
|
646
657
|
return;
|
|
647
658
|
}
|
|
648
|
-
await ctx
|
|
659
|
+
await safeReply(ctx, `ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with an isolation backend, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
|
|
649
660
|
parse_mode: 'Markdown',
|
|
650
661
|
reply_to_message_id: message.message_id,
|
|
651
662
|
});
|
|
@@ -661,9 +672,9 @@ export function registerStartStopCommands(bot, options) {
|
|
|
661
672
|
// coordinates on the item itself when the card was first posted —
|
|
662
673
|
// see telegram-bot.mjs where `item.messageInfo` is wired up.
|
|
663
674
|
const stopperName = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || `user ${ctx.from?.id}`;
|
|
664
|
-
await updateQueueCardForCancellation(lookup.item, url, lookup.tool, stopperName);
|
|
675
|
+
await updateQueueCardForCancellation(lookup.item, url, lookup.tool, stopperName, { safeEditMessageText, verbose: VERBOSE });
|
|
665
676
|
|
|
666
|
-
await ctx
|
|
677
|
+
await safeReply(ctx, `🗑 Removed queued task for ${url}${toolLabel}.`, {
|
|
667
678
|
parse_mode: 'Markdown',
|
|
668
679
|
reply_to_message_id: message.message_id,
|
|
669
680
|
});
|
|
@@ -679,7 +690,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
679
690
|
// running-not-isolated: a started, non-isolated screen session. We
|
|
680
691
|
// could shell out to `screen -X -S <name> stuff $'\003'`, but that's
|
|
681
692
|
// brittle and out of scope for #1780. Tell the user how to recover.
|
|
682
|
-
await ctx
|
|
693
|
+
await safeReply(ctx, `⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time run it with the default isolation backend or pass \`--isolation docker\` to make this task interruptible via \`/stop\`.`, {
|
|
683
694
|
parse_mode: 'Markdown',
|
|
684
695
|
reply_to_message_id: message.message_id,
|
|
685
696
|
});
|
|
@@ -707,7 +718,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
707
718
|
alreadyStoppedMsg += `\nReason: ${stopInfo.reason}`;
|
|
708
719
|
}
|
|
709
720
|
alreadyStoppedMsg += '\n\nUse /start to resume accepting tasks.';
|
|
710
|
-
await ctx
|
|
721
|
+
await safeReply(ctx, alreadyStoppedMsg, {
|
|
711
722
|
reply_to_message_id: ctx.message.message_id,
|
|
712
723
|
});
|
|
713
724
|
return;
|
|
@@ -739,7 +750,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
739
750
|
}
|
|
740
751
|
stopMessage += '*Disabled commands:*\n' + '• /solve - No new issues will be accepted\n' + '• /hive - No new hive commands will be accepted\n' + '• /merge - No new merge operations will be accepted\n\n' + '*Still available:*\n' + '• /help - Show help\n' + '• /limits - Show usage limits\n' + '• /version - Show version info\n' + '• /start - Resume accepting tasks (owner only)\n\n' + '💡 Any tasks already in queue will continue to process.';
|
|
741
752
|
|
|
742
|
-
await ctx
|
|
753
|
+
await safeReply(ctx, stopMessage, {
|
|
743
754
|
parse_mode: 'Markdown',
|
|
744
755
|
reply_to_message_id: ctx.message.message_id,
|
|
745
756
|
});
|
|
@@ -755,7 +766,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
755
766
|
// In private chats, show a welcome message instead
|
|
756
767
|
if (check.isPrivate) {
|
|
757
768
|
VERBOSE && console.log('[VERBOSE] /start in private chat: showing welcome');
|
|
758
|
-
await ctx
|
|
769
|
+
await safeReply(ctx, '👋 *Welcome to SwarmMindBot!*\n\n' + 'This bot helps solve GitHub issues using AI.\n\n' + 'To use this bot:\n' + '1. Add me to a group chat\n' + '2. Make me an admin\n' + '3. Use /solve to solve GitHub issues\n\n' + 'Use /help in a group chat for more information.', { parse_mode: 'Markdown' });
|
|
759
770
|
}
|
|
760
771
|
return;
|
|
761
772
|
}
|
|
@@ -763,7 +774,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
763
774
|
|
|
764
775
|
// Check if already running (not stopped)
|
|
765
776
|
if (!isChatStopped(chatId)) {
|
|
766
|
-
await ctx
|
|
777
|
+
await safeReply(ctx, 'ℹ️ Bot is already accepting tasks in this chat.\n\nUse /help to see available commands.', {
|
|
767
778
|
reply_to_message_id: ctx.message.message_id,
|
|
768
779
|
});
|
|
769
780
|
return;
|
|
@@ -791,7 +802,7 @@ export function registerStartStopCommands(bot, options) {
|
|
|
791
802
|
}
|
|
792
803
|
}
|
|
793
804
|
|
|
794
|
-
await ctx
|
|
805
|
+
await safeReply(ctx, '✅ *Bot Started*\n\n' + 'This bot is now accepting tasks in this chat.\n\n' + (durationStr ? `Bot was stopped for ${durationStr}.\n\n` : '') + 'Use /help to see available commands.', {
|
|
795
806
|
parse_mode: 'Markdown',
|
|
796
807
|
reply_to_message_id: ctx.message.message_id,
|
|
797
808
|
});
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { safeReply } from './telegram-safe-reply.lib.mjs';
|
|
18
|
+
|
|
17
19
|
// Map<userId, { username, firstName, subscribedAt, sourceChatId }>
|
|
18
20
|
const subscribers = new Map();
|
|
19
21
|
|
|
@@ -196,9 +198,9 @@ export function registerSubscribeCommands(bot, options = {}) {
|
|
|
196
198
|
message = 'ℹ️ You are already subscribed.\n\nUse /unsubscribe to stop receiving private notifications when /solve commands finish.';
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
await ctx
|
|
200
|
-
parse_mode: 'Markdown',
|
|
201
|
+
await safeReply(ctx, message, {
|
|
201
202
|
reply_to_message_id: ctx.message?.message_id,
|
|
203
|
+
verbose: VERBOSE,
|
|
202
204
|
});
|
|
203
205
|
|
|
204
206
|
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) added (new=${wasNew}); total=${getSubscriberCount()}`);
|
|
@@ -210,9 +212,9 @@ export function registerSubscribeCommands(bot, options = {}) {
|
|
|
210
212
|
const userId = ctx.from.id;
|
|
211
213
|
const wasRemoved = removeSubscriber(userId);
|
|
212
214
|
|
|
213
|
-
await ctx
|
|
214
|
-
parse_mode: 'Markdown',
|
|
215
|
+
await safeReply(ctx, wasRemoved ? UNSUBSCRIBE_CONFIRMATION : NOT_SUBSCRIBED_MESSAGE, {
|
|
215
216
|
reply_to_message_id: ctx.message?.message_id,
|
|
217
|
+
verbose: VERBOSE,
|
|
216
218
|
});
|
|
217
219
|
|
|
218
220
|
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) removed=${wasRemoved}; total=${getSubscriberCount()}`);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import { extractSessionIdFromText, decideLogDestination, resolveLogPath } from './telegram-log-command.lib.mjs';
|
|
10
10
|
import { parseSessionExitFooter } from './isolation-runner.lib.mjs';
|
|
11
|
+
import { safeReply, safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
11
12
|
import { classifyExitStatus, isFailureSessionStatus } from './session-status.lib.mjs';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_WIDTH = 120;
|
|
@@ -248,7 +249,7 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
|
|
|
248
249
|
const message = formatTerminalWatchMessage({ sessionId, statusResult, logText, options, updateCount, completed, repoDescription });
|
|
249
250
|
const shouldEdit = !lastMessage || snapshotChanged || (completed && message !== lastMessage);
|
|
250
251
|
if (shouldEdit && message !== lastMessage) {
|
|
251
|
-
await bot.telegram
|
|
252
|
+
await safeEditMessageText(bot.telegram, chatId, messageId, undefined, message, { verbose });
|
|
252
253
|
lastMessage = message;
|
|
253
254
|
}
|
|
254
255
|
lastSnapshot = snapshot;
|
|
@@ -281,9 +282,9 @@ function buildUsage() {
|
|
|
281
282
|
|
|
282
283
|
async function createWatchMessage({ ctx, targetChatId, replyToMessageId, text }) {
|
|
283
284
|
if (targetChatId === ctx.chat.id) {
|
|
284
|
-
return await ctx
|
|
285
|
+
return await safeReply(ctx, text, { reply_to_message_id: replyToMessageId });
|
|
285
286
|
}
|
|
286
|
-
return await ctx.telegram
|
|
287
|
+
return await safeSendMessage(ctx.telegram, targetChatId, text, replyToMessageId ? { reply_to_message_id: replyToMessageId } : {});
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
async function forwardOrCopyToDm(ctx, sourceMessage) {
|
|
@@ -324,7 +325,7 @@ async function startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult
|
|
|
324
325
|
watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose, initialStatusResult: statusResult, initialLogText, initialMessage: initialText });
|
|
325
326
|
|
|
326
327
|
if (!auto && decision.destination === 'dm' && ctx.chat.type !== 'private') {
|
|
327
|
-
await ctx
|
|
328
|
+
await safeReply(ctx, `📬 Started terminal watch for \`${sessionId}\` in your direct messages.`, { reply_to_message_id: ctx.message.message_id, verbose });
|
|
328
329
|
}
|
|
329
330
|
return { started: true, messageId: watchMessage.message_id, sessionInfo };
|
|
330
331
|
}
|
|
@@ -371,13 +372,13 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
371
372
|
|
|
372
373
|
const parsedArgs = parseTerminalWatchArgs(message.text || '');
|
|
373
374
|
if (parsedArgs.errors.length > 0) {
|
|
374
|
-
await ctx
|
|
375
|
+
await safeReply(ctx, `❌ Invalid /terminal_watch options:\n${parsedArgs.errors.map(e => `• ${e}`).join('\n')}\n\n${buildUsage()}`, { reply_to_message_id: message.message_id, verbose: VERBOSE });
|
|
375
376
|
return;
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
const sessionId = parsedArgs.sessionId || extractSessionIdFromText(message.reply_to_message?.text || message.reply_to_message?.caption || '');
|
|
379
380
|
if (!sessionId) {
|
|
380
|
-
await ctx
|
|
381
|
+
await safeReply(ctx, `❌ /terminal_watch requires a session id.\n\n${buildUsage()}`, { reply_to_message_id: message.message_id, verbose: VERBOSE });
|
|
381
382
|
return;
|
|
382
383
|
}
|
|
383
384
|
|
|
@@ -417,7 +418,7 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
417
418
|
}
|
|
418
419
|
|
|
419
420
|
if (!statusResult?.exists) {
|
|
420
|
-
await ctx
|
|
421
|
+
await safeReply(ctx, `❌ Session \`${sessionId}\` is not known to start-command.`, { reply_to_message_id: message.message_id, verbose: VERBOSE });
|
|
421
422
|
return;
|
|
422
423
|
}
|
|
423
424
|
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { getAllKnownLocalTokens } from './token-sanitization.lib.mjs';
|
|
26
26
|
import { maskToken } from './lib.mjs';
|
|
27
|
+
import { safeReply } from './telegram-safe-reply.lib.mjs';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Resolve allowed chat IDs into an array of numeric IDs the user could own.
|
|
@@ -146,7 +147,7 @@ export const registerTokensCommand = (bot, options = {}) => {
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
const message = formatTokenList(tokens);
|
|
149
|
-
await ctx
|
|
150
|
+
await safeReply(ctx, message);
|
|
150
151
|
});
|
|
151
152
|
};
|
|
152
153
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { promisify } from 'util';
|
|
18
18
|
import { exec as execCallback } from 'child_process';
|
|
19
|
+
import { safeReply, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
19
20
|
|
|
20
21
|
const exec = promisify(execCallback);
|
|
21
22
|
|
|
@@ -128,9 +129,9 @@ export function registerTopCommand(bot, options) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// Show experimental feature warning
|
|
131
|
-
await ctx
|
|
132
|
-
parse_mode: 'Markdown',
|
|
132
|
+
await safeReply(ctx, '🧪 *EXPERIMENTAL FEATURE*\n\nThis command is experimental and may have issues. Use with caution.', {
|
|
133
133
|
reply_to_message_id: ctx.message.message_id,
|
|
134
|
+
verbose: VERBOSE,
|
|
134
135
|
});
|
|
135
136
|
|
|
136
137
|
// Check if there's already an active top session for this chat
|
|
@@ -188,11 +189,11 @@ export function registerTopCommand(bot, options) {
|
|
|
188
189
|
const firstOutput = await captureTopOutput(chatId);
|
|
189
190
|
if (firstOutput) {
|
|
190
191
|
try {
|
|
191
|
-
await ctx.telegram
|
|
192
|
-
parse_mode: 'Markdown',
|
|
192
|
+
await safeEditMessageText(ctx.telegram, chatId, initialMessage.message_id, undefined, `\`\`\`\n${firstOutput}\n\`\`\``, {
|
|
193
193
|
reply_markup: {
|
|
194
194
|
inline_keyboard: [[{ text: '🛑 Stop', callback_data: `stop_top_${chatId}` }]],
|
|
195
195
|
},
|
|
196
|
+
verbose: VERBOSE,
|
|
196
197
|
});
|
|
197
198
|
} catch (error) {
|
|
198
199
|
console.error('[ERROR] Failed to update message:', error);
|
|
@@ -204,11 +205,11 @@ export function registerTopCommand(bot, options) {
|
|
|
204
205
|
const output = await captureTopOutput(chatId);
|
|
205
206
|
if (output) {
|
|
206
207
|
try {
|
|
207
|
-
await ctx.telegram
|
|
208
|
-
parse_mode: 'Markdown',
|
|
208
|
+
await safeEditMessageText(ctx.telegram, chatId, initialMessage.message_id, undefined, `\`\`\`\n${output}\n\`\`\``, {
|
|
209
209
|
reply_markup: {
|
|
210
210
|
inline_keyboard: [[{ text: '🛑 Stop', callback_data: `stop_top_${chatId}` }]],
|
|
211
211
|
},
|
|
212
|
+
verbose: VERBOSE,
|
|
212
213
|
});
|
|
213
214
|
} catch (error) {
|
|
214
215
|
// Ignore "message is not modified" errors
|
|
@@ -290,9 +291,7 @@ export function registerTopCommand(bot, options) {
|
|
|
290
291
|
|
|
291
292
|
// Update the message to show it's stopped
|
|
292
293
|
try {
|
|
293
|
-
await ctx.
|
|
294
|
-
parse_mode: 'Markdown',
|
|
295
|
-
});
|
|
294
|
+
await safeEditMessageText(ctx.telegram, chatId, ctx.callbackQuery?.message?.message_id, undefined, '🛑 Top session stopped.', { verbose: VERBOSE });
|
|
296
295
|
} catch (error) {
|
|
297
296
|
console.error('[ERROR] Failed to edit message:', error);
|
|
298
297
|
}
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -24,6 +24,22 @@ const normalizeModelKey = value => {
|
|
|
24
24
|
.trim();
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// Issue #2169: HTTP status codes are only meaningful as errors when they appear in an
|
|
28
|
+
// HTTP-status context. A bare number in prose ("PR #524", "issue #523", "line 502") is
|
|
29
|
+
// not an error — matching it wrongly sent an entire successful session into a multi-hour
|
|
30
|
+
// retry loop (see docs/case-studies/issue-2169). `matchesHttpStatus` accepts:
|
|
31
|
+
// "API Error: 502", "error code: 522", "status 504", "HTTP/1.1 520", "code=524"
|
|
32
|
+
// "502 Bad Gateway", "524 A Timeout Occurred" (status followed by its canonical phrase)
|
|
33
|
+
const GATEWAY_STATUS_PHRASES = ['bad gateway', 'gateway timeout', 'gateway time-out', 'unknown error', 'web server is down', 'connection timed out', 'origin is unreachable', 'a timeout occurred'];
|
|
34
|
+
const HTTP_STATUS_PREFIX = String.raw`(?:http(?:s|/\d(?:\.\d)?)?\s*)?(?:api\s+)?(?:error|status(?:\s*code)?|code|response|returned|got)\s*(?:code\s*)?[:=#]?\s*`;
|
|
35
|
+
export const matchesHttpStatus = (lowerText, codePattern, phrases = []) => {
|
|
36
|
+
if (!lowerText) return false;
|
|
37
|
+
if (new RegExp(`${HTTP_STATUS_PREFIX}(?:${codePattern})\\b`).test(lowerText)) return true;
|
|
38
|
+
if (new RegExp(`\\bhttp\\s*(?:status\\s*)?(?:${codePattern})\\b`).test(lowerText)) return true;
|
|
39
|
+
if (phrases.length > 0 && new RegExp(`\\b(?:${codePattern})\\b[\\s,:;-]*(?:${phrases.join('|')})`).test(lowerText)) return true;
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
|
|
27
43
|
export const classifyRetryableError = value => {
|
|
28
44
|
const message = normalizeMessage(value);
|
|
29
45
|
const lower = message.toLowerCase();
|
|
@@ -163,7 +179,12 @@ export const classifyRetryableError = value => {
|
|
|
163
179
|
// These come from an intermediary (CDN/proxy/load balancer), not from a request the
|
|
164
180
|
// client got wrong, and clear on their own — OpenAI/Anthropic/GitHub all front their
|
|
165
181
|
// APIs with such proxies. Safe to retry the same request after a backoff.
|
|
166
|
-
|
|
182
|
+
// Issue #2169: the bare `/\b52[0-4]\b/` test used here before matched ANY standalone
|
|
183
|
+
// 520-524 in the text, so an agent's own success summary — "PR #524", "issue #523",
|
|
184
|
+
// "(`463c5ca`, PR #522)" — was classified as a gateway error and retried for hours.
|
|
185
|
+
// The number now has to appear in an HTTP-status context ("error code: 522",
|
|
186
|
+
// "API Error: 502", "HTTP 504") or next to the status' canonical phrase.
|
|
187
|
+
if (lower.includes('502 bad gateway') || lower.includes('bad gateway') || lower.includes('504 gateway timeout') || lower.includes('gateway time-out') || lower.includes('gateway timeout') || matchesHttpStatus(lower, '502|504|52[0-4]', GATEWAY_STATUS_PHRASES)) {
|
|
167
188
|
return { message, isRetryable: true, isCapacity: false, label: 'Gateway error (502/504/52x)' };
|
|
168
189
|
}
|
|
169
190
|
|
|
@@ -201,7 +222,9 @@ export const classifyRetryableError = value => {
|
|
|
201
222
|
// Issue #1955: broadened to also catch the bare "503 Service Unavailable" that
|
|
202
223
|
// GitHub/OpenAI/Anthropic return when a backend is briefly saturated — a
|
|
203
224
|
// transient, self-clearing condition, safe to retry with the same request.
|
|
204
|
-
|
|
225
|
+
// Issue #2169: `matchesHttpStatus` accepts the status only next to an error-ish prefix
|
|
226
|
+
// ("api error: 503", "status code: 503"), never a bare number in prose.
|
|
227
|
+
if (lower.includes('api error: 503') || lower.includes('503 service unavailable') || lower.includes('service unavailable') || matchesHttpStatus(lower, '503') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
|
|
205
228
|
return { message, isRetryable: true, isCapacity: false, label: '503 network error' };
|
|
206
229
|
}
|
|
207
230
|
|
|
@@ -212,8 +235,92 @@ export const classifyRetryableError = value => {
|
|
|
212
235
|
return { message, isRetryable: false, isCapacity: false, label: null };
|
|
213
236
|
};
|
|
214
237
|
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
// Issue #2169: `minDelayMs` is the floor for the transient-API-error paths ("with minimum of
|
|
239
|
+
// 3 minutes"). It defaults to 0 so the deliberately fast paths — capacity retries (15s), a
|
|
240
|
+
// model switch (5s), stream startup/activity timeouts (30s) — keep their short delays.
|
|
241
|
+
export const getRetryDelayMs = ({ retryCount, initialDelayMs = retryLimits.initialTransientErrorDelayMs, maxDelayMs = retryLimits.maxTransientErrorDelayMs, minDelayMs = 0 } = {}) => {
|
|
242
|
+
const backoff = Math.min(initialDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount), maxDelayMs);
|
|
243
|
+
return Math.max(backoff, Math.min(minDelayMs, maxDelayMs));
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Issue #2169: diagnosing a *mis*classification from the log used to be impossible — the retry
|
|
247
|
+
// line printed only the first 200 characters of the message, while the token that actually made
|
|
248
|
+
// the classifier fire ("PR #524", 1.6 KB later) was never shown. This renders the evidence for a
|
|
249
|
+
// classification: how long the message was and the ±40-character context around every HTTP-status
|
|
250
|
+
// -looking token in it, so the next false positive is diagnosable from a single verbose log line.
|
|
251
|
+
export const describeClassificationEvidence = (message, label = null, { maxMatches = 3, contextChars = 40 } = {}) => {
|
|
252
|
+
const text = String(message ?? '');
|
|
253
|
+
const parts = [`label=${JSON.stringify(label)}`, `messageChars=${text.length}`];
|
|
254
|
+
const matches = [];
|
|
255
|
+
for (const match of text.matchAll(/\b(?:4\d{2}|5\d{2})\b/g)) {
|
|
256
|
+
const start = Math.max(0, match.index - contextChars);
|
|
257
|
+
const end = Math.min(text.length, match.index + match[0].length + contextChars);
|
|
258
|
+
matches.push(`@${match.index} ${JSON.stringify(text.slice(start, end).replace(/\s+/g, ' '))}`);
|
|
259
|
+
if (matches.length >= maxMatches) break;
|
|
260
|
+
}
|
|
261
|
+
parts.push(matches.length > 0 ? `statusTokens=[${matches.join(', ')}]` : 'statusTokens=[]');
|
|
262
|
+
return parts.join(' ');
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Issue #2169: human-readable duration for retry logs — "45s", "12 min", "3h 15m", "12h".
|
|
266
|
+
export const formatRetryDuration = ms => {
|
|
267
|
+
if (!Number.isFinite(ms)) return 'unlimited';
|
|
268
|
+
if (ms < 60000) return `${Math.max(0, Math.round(ms / 1000))}s`;
|
|
269
|
+
const totalMinutes = Math.round(ms / 60000);
|
|
270
|
+
if (totalMinutes < 60) return `${totalMinutes} min`;
|
|
271
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
272
|
+
const minutes = totalMinutes % 60;
|
|
273
|
+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Issue #2169: a provider outage can last many hours. The retry loops used to stop after a
|
|
277
|
+
// fixed number of attempts (10), which — with the old 2 min → 30 min backoff — gave up after
|
|
278
|
+
// ~3.5 hours. The budget below turns "how long do we keep trying" into the primary knob: retries
|
|
279
|
+
// continue while the *next* backoff still fits inside `budgetMs` (default 12 h), measured from
|
|
280
|
+
// the first retry of the run. The per-tool retry counts stay as runaway-loop backstops.
|
|
281
|
+
//
|
|
282
|
+
// Usage inside a tool's retry loop (the budget object lives outside `executeWithRetry` so it
|
|
283
|
+
// survives the recursive calls):
|
|
284
|
+
// const budget = createTransientRetryBudget();
|
|
285
|
+
// const decision = budget.evaluate({ retryCount, maxRetries, initialDelayMs, maxDelayMs, minDelayMs });
|
|
286
|
+
// if (decision.allowed) { budget.grant(); ...wait decision.delayMs... } else { fail(decision) }
|
|
287
|
+
export const createTransientRetryBudget = ({ budgetMs = retryLimits.transientErrorRetryBudgetMs, now = () => Date.now() } = {}) => {
|
|
288
|
+
let startedAt = null;
|
|
289
|
+
let retriesGranted = 0;
|
|
290
|
+
const elapsedMs = () => (startedAt === null ? 0 : Math.max(0, now() - startedAt));
|
|
291
|
+
const remainingMs = () => (budgetMs > 0 ? Math.max(0, budgetMs - elapsedMs()) : Infinity);
|
|
292
|
+
return {
|
|
293
|
+
budgetMs,
|
|
294
|
+
elapsedMs,
|
|
295
|
+
remainingMs,
|
|
296
|
+
get retriesGranted() {
|
|
297
|
+
return retriesGranted;
|
|
298
|
+
},
|
|
299
|
+
// Starts the clock on the first granted retry and counts it.
|
|
300
|
+
grant() {
|
|
301
|
+
if (startedAt === null) startedAt = now();
|
|
302
|
+
retriesGranted += 1;
|
|
303
|
+
},
|
|
304
|
+
evaluate({ retryCount = 0, maxRetries = retryLimits.maxTransientErrorRetries, initialDelayMs, maxDelayMs, minDelayMs = 0 } = {}) {
|
|
305
|
+
const delayMs = getRetryDelayMs({ retryCount, initialDelayMs, maxDelayMs, minDelayMs });
|
|
306
|
+
const base = { delayMs, elapsedMs: elapsedMs(), remainingMs: remainingMs(), budgetMs, retryCount, maxRetries };
|
|
307
|
+
if (retryCount >= maxRetries) return { ...base, allowed: false, reason: 'count' };
|
|
308
|
+
// Never start a wait that would run past the budget window.
|
|
309
|
+
if (budgetMs > 0 && delayMs > remainingMs()) return { ...base, allowed: false, reason: 'budget' };
|
|
310
|
+
return { ...base, allowed: true, reason: null };
|
|
311
|
+
},
|
|
312
|
+
// One-line explanation for the "giving up" log, e.g.
|
|
313
|
+
// "retry budget of 12h exhausted after 26 retries over 11h 45m".
|
|
314
|
+
describeExhaustion(decision) {
|
|
315
|
+
const spent = formatRetryDuration(decision?.elapsedMs ?? elapsedMs());
|
|
316
|
+
if (decision?.reason === 'count') return `retry limit of ${decision.maxRetries} attempts reached after ${spent} (budget ${formatRetryDuration(budgetMs)})`;
|
|
317
|
+
return `retry budget of ${formatRetryDuration(budgetMs)} exhausted after ${decision?.retryCount ?? retriesGranted} retries over ${spent}`;
|
|
318
|
+
},
|
|
319
|
+
// Progress suffix for each retry log line, e.g. "budget 25 min/12h used".
|
|
320
|
+
describeProgress() {
|
|
321
|
+
return budgetMs > 0 ? `budget ${formatRetryDuration(elapsedMs())}/${formatRetryDuration(budgetMs)} used` : 'budget disabled';
|
|
322
|
+
},
|
|
323
|
+
};
|
|
217
324
|
};
|
|
218
325
|
|
|
219
326
|
export const waitWithCountdown = async (delayMs, log) => {
|
|
@@ -352,7 +459,7 @@ export const maybeSwitchToFallbackModel = async ({ tool, argv, log, errorMessage
|
|
|
352
459
|
// survives the recursive executeWithRetry calls without each tool tracking extra
|
|
353
460
|
// state. It resets to 0 whenever we actually switch models, so every model in the
|
|
354
461
|
// fallback chain gets its own batch of same-model retries before stepping down.
|
|
355
|
-
export const prepareRetryAfterError = async ({ tool, argv, log, errorMessage, retryCount, initialDelayMs, maxDelayMs } = {}) => {
|
|
462
|
+
export const prepareRetryAfterError = async ({ tool, argv, log, errorMessage, retryCount, initialDelayMs, maxDelayMs, minDelayMs = 0 } = {}) => {
|
|
356
463
|
const classification = classifyRetryableError(errorMessage);
|
|
357
464
|
const isCapacity = classification.isCapacity === true && !!argv?.model;
|
|
358
465
|
const capacityRetryCount = argv?._capacityRetryCount || 0;
|
|
@@ -373,13 +480,17 @@ export const prepareRetryAfterError = async ({ tool, argv, log, errorMessage, re
|
|
|
373
480
|
const switchResult = await maybeSwitchToFallbackModel({ tool, argv, log, errorMessage });
|
|
374
481
|
// A model switch starts a fresh batch of same-model retries for the new model.
|
|
375
482
|
if (switchResult?.switched && argv) argv._capacityRetryCount = 0;
|
|
376
|
-
const delay = switchResult?.switched ? retryLimits.modelSwitchRetryDelayMs : getRetryDelayMs({ retryCount, initialDelayMs, maxDelayMs });
|
|
483
|
+
const delay = switchResult?.switched ? retryLimits.modelSwitchRetryDelayMs : getRetryDelayMs({ retryCount, initialDelayMs, maxDelayMs, minDelayMs });
|
|
377
484
|
return { delay, switched: switchResult?.switched === true };
|
|
378
485
|
};
|
|
379
486
|
|
|
380
487
|
export default {
|
|
381
488
|
classifyRetryableError,
|
|
489
|
+
matchesHttpStatus,
|
|
382
490
|
getRetryDelayMs,
|
|
491
|
+
describeClassificationEvidence,
|
|
492
|
+
formatRetryDuration,
|
|
493
|
+
createTransientRetryBudget,
|
|
383
494
|
waitWithCountdown,
|
|
384
495
|
resolveConfiguredFallbackModel,
|
|
385
496
|
maybeSwitchToFallbackModel,
|