@link-assistant/hive-mind 1.78.9 → 1.78.11

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 CHANGED
@@ -1,5 +1,101 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.78.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 24fb17e: fix(retry): auto-resume on server-side 429 "Server is temporarily limiting requests" rate-limit errors (#1924)
8
+
9
+ A long-running solve session (177 turns, ~72 min) was thrown away when the Claude
10
+ CLI surfaced a **server-side temporary rate limit**:
11
+
12
+ ```
13
+ API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited
14
+ ```
15
+
16
+ The CLI reports this as a `result` event with `is_error: true` and
17
+ `api_error_status: 429`, and the HTTP response carries `x-should-retry: true`.
18
+ This is a transient throttle that clears on its own — distinct from an account
19
+ usage/quota limit (the message literally says "not your usage limit", and there
20
+ is no reset time to wait for).
21
+
22
+ Root cause: the error matched neither `classifyRetryableError` (no pattern for
23
+ the 429 throttle wording) nor `isUsageLimitError` (correctly, since it is not a
24
+ quota limit), so it fell through to a hard failure with exit code 1 and **no
25
+ auto-resume**, unlike every other transient class (overload 500/529, 503,
26
+ internal server error, request timeout, socket drops).
27
+
28
+ Fix: `classifyRetryableError` (in `src/tool-retry.lib.mjs`, the shared classifier
29
+ used by every tool wrapper — claude, codex, gemini, opencode, qwen, agent) now
30
+ recognises this throttle and marks it retryable (`isCapacity: false`, so no model
31
+ switch), so it retries with the session preserved (`--resume`) after a backoff.
32
+ `src/claude.lib.mjs` additionally detects the structured `api_error_status === 429`
33
+ directly (robust to wording changes) and logs a verbose diagnostic with the
34
+ `request_id`. The matcher is narrow so genuine account usage limits stay on the
35
+ usage-limit reset-time path.
36
+
37
+ Added `tests/test-issue-1924-rate-limit-retry.mjs` (18 assertions) and a full
38
+ case study with timeline, root-cause analysis, upstream references
39
+ (anthropics/claude-code#53915, #53922), and the captured logs under
40
+ `docs/case-studies/issue-1924`.
41
+
42
+ ## 1.78.10
43
+
44
+ ### Patch Changes
45
+
46
+ - 02faadb: fix(auto-merge): stop `/merge` from hanging forever on fork PRs with external-only `success` checks (#1918)
47
+
48
+ The `/merge` auto-merge watch loop could spin on the same commit indefinitely
49
+ (observed 73 minutes, 72 identical iterations, before a human killed it). It
50
+ happened on a **fork pull request** whose only repo workflows trigger on `push`
51
+ (which never fires for fork commits in the base repo) while an external app
52
+ (CodeRabbit) reported CI status `success` with **0 workflow runs** for the head
53
+ SHA.
54
+
55
+ Root cause: the watch loop reset its consecutive "no workflow runs" safety-valve
56
+ counter (`consecutiveNoRunsChecks`) on every iteration whenever
57
+ `ciStatus.status !== 'no_checks'`. Because external-only checks make the status
58
+ `'success'`, the counter was pinned at `1` and never reached
59
+ `MAX_NO_RUNS_CHECKS`, so the valve that should have ended the wait never fired —
60
+ the loop logged `check 1/5` forever.
61
+
62
+ Fix: `getMergeBlockers()` now returns a `noWorkflowRunsForCommit` flag that is
63
+ true while it is still waiting for PR-triggered workflow runs to register, and a
64
+ new pure helper `shouldResetNoRunsCounter(ciStatus, noWorkflowRunsForCommit)`
65
+ only resets the counter when CI is **not** in that waiting state. The counter
66
+ now climbs `1 → 2 → … → 5`, trips the safety valve in a few minutes, and `/merge`
67
+ proceeds. The #1503 behaviors (reset on new push / on genuine CI runs) are
68
+ preserved and regression-guarded.
69
+
70
+ Added `tests/test-merge-stuck-no-workflow-runs-1918.mjs` and a full case study
71
+ with timeline, root-cause analysis, and the captured logs under
72
+ `docs/case-studies/issue-1918`.
73
+
74
+ - 9e00f14: fix(telegram): never re-execute a forwarded command (`/task`, `/stop`, `/tokens`, `/log`, `/terminal_watch`) (#1922)
75
+
76
+ Forwarding a message that starts with a bot command (for example the bot's own
77
+ `/task <url>` reply, or any `/task https://github.com/owner/repo`) caused the
78
+ Telegram bot to execute the command again — creating a brand-new GitHub issue or
79
+ spawning a session the user never intended. `/task` and `/split` only checked
80
+ `isOldMessage` and never rejected forwarded messages, unlike `/help`, `/solve`,
81
+ `/hive`, `/merge`, etc.
82
+
83
+ Root cause: the existing `isForwardedOrReply` filter rejects _both_ forwards and
84
+ replies, so commands that use the reply feature (`/task` issue creation, `/solve`
85
+ URL extraction, targeted `/stop`) could not use it without breaking replies — and
86
+ were therefore left without any forwarded check at all.
87
+
88
+ Fix: a new dedicated `isForwarded(ctx)` filter detects _only_ forwarded messages
89
+ (new `forward_origin` API + legacy `forward_*` fields) and intentionally ignores
90
+ replies. It is now applied to every command that previously lacked a forwarded
91
+ guard — `/task`, `/split`, `/stop` (including targeted `/stop <uuid>`), `/tokens`,
92
+ `/log`, `/terminal_watch`/`/watch` — and `/solve` was refactored to reuse it
93
+ instead of its ad-hoc inline check. Genuine user replies keep working.
94
+
95
+ Added unit tests for `isForwarded` and for forwarded `/task`/`/split` rejection,
96
+ plus a full case study with timeline and per-command audit under
97
+ `docs/case-studies/issue-1922`.
98
+
3
99
  ## 1.78.9
4
100
 
5
101
  ### Patch Changes
package/README.hi.md CHANGED
@@ -45,7 +45,7 @@ Hive Mind एक **सामान्यवादी AI** (मिनी-AGI) ह
45
45
 
46
46
  Hive Mind में औसत प्रोग्रामर से अलग न पहचानी जा सकने वाली उच्च रचनात्मकता है। यदि आवश्यकताएँ अस्पष्ट हों तो यह प्रश्न पूछता है, और आप PR टिप्पणियों के माध्यम से चलते-चलते स्पष्ट कर सकते हैं।
47
47
 
48
- विस्तृत विशेषताओं और तुलनाओं के लिए, [docs/FEATURES.hi.md](./docs/FEATURES.hi.md) और [docs/COMPARISON.hi.md](./docs/COMPARISON.hi.md) देखें।
48
+ प्रोजेक्ट के दृष्टिकोण और उदाहरण उपयोगकर्ता यात्राओं के लिए, [docs/VISION.hi.md](./docs/VISION.hi.md) देखें। विस्तृत विशेषताओं और तुलनाओं के लिए, [docs/FEATURES.hi.md](./docs/FEATURES.hi.md) और [docs/COMPARISON.hi.md](./docs/COMPARISON.hi.md) देखें।
49
49
 
50
50
  ## ⚠️ चेतावनी
51
51
 
package/README.md CHANGED
@@ -45,7 +45,7 @@ Both tools can be combined in the same hive. Workers can run different tools in
45
45
 
46
46
  Hive Mind has high creativity indistinguishable from average programmers. It asks questions if requirements are unclear, and you can clarify on the go via PR comments.
47
47
 
48
- For detailed features and comparisons, see [docs/FEATURES.md](./docs/FEATURES.md) and [docs/COMPARISON.md](./docs/COMPARISON.md).
48
+ For the project's vision and example user journeys, see [docs/VISION.md](./docs/VISION.md). For detailed features and comparisons, see [docs/FEATURES.md](./docs/FEATURES.md) and [docs/COMPARISON.md](./docs/COMPARISON.md).
49
49
 
50
50
  ## ⚠️ WARNING
51
51
 
package/README.ru.md CHANGED
@@ -45,7 +45,7 @@ Hive Mind — это **универсальный ИИ** (мини-AGI), спо
45
45
 
46
46
  Hive Mind обладает высоким уровнем творчества, неотличимым от среднего программиста. Он задаёт вопросы, если требования неясны, и вы можете уточнять их на ходу через комментарии к PR.
47
47
 
48
- Подробные возможности и сравнения см. в [docs/FEATURES.ru.md](./docs/FEATURES.ru.md) и [docs/COMPARISON.ru.md](./docs/COMPARISON.ru.md).
48
+ Видение проекта и примеры пользовательских сценариев см. в [docs/VISION.ru.md](./docs/VISION.ru.md). Подробные возможности и сравнения см. в [docs/FEATURES.ru.md](./docs/FEATURES.ru.md) и [docs/COMPARISON.ru.md](./docs/COMPARISON.ru.md).
49
49
 
50
50
  ## ⚠️ ПРЕДУПРЕЖДЕНИЕ
51
51
 
package/README.zh.md CHANGED
@@ -45,7 +45,7 @@ Hive Mind 是一款**通用 AI**(迷你 AGI),能够处理广泛的任务
45
45
 
46
46
  Hive Mind 具备与普通程序员无异的高度创造力。当需求不明确时,它会主动提问,您也可以随时通过 PR 评论进行说明补充。
47
47
 
48
- 详细功能和对比信息,请参见 [docs/FEATURES.zh.md](./docs/FEATURES.zh.md) 和 [docs/COMPARISON.zh.md](./docs/COMPARISON.zh.md)。
48
+ 关于项目的愿景和示例用户旅程,请参见 [docs/VISION.zh.md](./docs/VISION.zh.md)。详细功能和对比信息,请参见 [docs/FEATURES.zh.md](./docs/FEATURES.zh.md) 和 [docs/COMPARISON.zh.md](./docs/COMPARISON.zh.md)。
49
49
 
50
50
  ## ⚠️ 警告
51
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.78.9",
3
+ "version": "1.78.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -648,6 +648,7 @@ export const executeClaudeCommand = async params => {
648
648
  let is503Error = false;
649
649
  let isInternalServerError = false;
650
650
  let isRequestTimeout = false;
651
+ let isRateLimitError = false; // Issue #1924: server-side 429 temporary rate limiting
651
652
  let apiMarkedNotRetryable = false;
652
653
  let resultNumTurns = 0;
653
654
  let stderrErrors = [];
@@ -977,6 +978,14 @@ export const executeClaudeCommand = async params => {
977
978
  isRequestTimeout = true;
978
979
  await log('⏱️ Detected request timeout from Claude CLI (will retry with --resume)', { verbose: true });
979
980
  }
981
+ // Issue #1924: Server-side temporary rate limiting (HTTP 429) — a transient
982
+ // throttle, not an account usage limit ("...not your usage limit..."), so retry
983
+ // with --resume. The message text is handled by classifyRetryableError; this also
984
+ // catches the structured api_error_status if the wording ever changes.
985
+ if (data.api_error_status === 429) {
986
+ isRateLimitError = true;
987
+ await log(`⚠️ Detected server-side rate limiting (429) from Claude CLI (will retry with --resume). request_id=${data.request_id || 'unknown'}`, { verbose: true });
988
+ }
980
989
  // Issue #1834: Detect corrupted extended-thinking-block 400 (un-resumable session).
981
990
  // Capture diagnostics (request id, content path) to aid debugging and upstream reports.
982
991
  if ((lastMessage.includes('thinking') || lastMessage.includes('redacted_thinking')) && lastMessage.includes('cannot be modified')) {
@@ -1174,7 +1183,7 @@ export const executeClaudeCommand = async params => {
1174
1183
  return await executeWithRetry();
1175
1184
  }
1176
1185
  // Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
1177
- const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || retryableLastError.isRetryable || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
1186
+ const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || isRateLimitError || retryableLastError.isRetryable || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
1178
1187
  if ((commandFailed || isTransientError) && isTransientError) {
1179
1188
  // Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general → 2min–30min
1180
1189
  const isTimeoutRetry = isStartupTimeout || isActivityTimeout;
@@ -1208,7 +1217,7 @@ export const executeClaudeCommand = async params => {
1208
1217
  }
1209
1218
  if (retryCount < maxRetries) {
1210
1219
  const delay = Math.min(initialDelay * Math.pow(retryLimits.retryBackoffMultiplier, retryCount), maxDelay);
1211
- const errorLabel = isStartupTimeout ? 'Stream startup timeout (Issue #1472/#1475)' : isActivityTimeout ? 'Stream activity timeout (Issue #1472)' : isRequestTimeout ? 'Request timeout' : retryableLastError.label || (isOverloadError || (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) || (lastMessage.includes('API Error: 529') && lastMessage.includes('Overloaded')) ? `API overload (${lastMessage.includes('529') ? '529' : '500'})` : isInternalServerError || lastMessage.includes('Internal server error') ? 'Internal server error (500)' : '503 network error');
1220
+ const errorLabel = isStartupTimeout ? 'Stream startup timeout (Issue #1472/#1475)' : isActivityTimeout ? 'Stream activity timeout (Issue #1472)' : isRequestTimeout ? 'Request timeout' : retryableLastError.label || (isOverloadError || (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) || (lastMessage.includes('API Error: 529') && lastMessage.includes('Overloaded')) ? `API overload (${lastMessage.includes('529') ? '529' : '500'})` : isInternalServerError || lastMessage.includes('Internal server error') ? 'Internal server error (500)' : isRateLimitError ? 'Server rate limited (429)' : '503 network error');
1212
1221
  const notRetryableHint = apiMarkedNotRetryable ? ' (API says not retryable — will stop early if no progress)' : '';
1213
1222
  const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
1214
1223
  const retryMode = isStartupTimeout ? ' (fresh start)' : ' (session preserved)';
@@ -421,9 +421,53 @@ export const trackAuthenticatedUserCommentsSince = async (owner, repo, prNumber,
421
421
  * - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
422
422
  * - no_checks: No CI checks yet (race condition) → wait
423
423
  */
424
+ /**
425
+ * Issue #1918: Decide whether the consecutive "no workflow runs" counter should be
426
+ * reset after a getMergeBlockers() result.
427
+ *
428
+ * The counter (`consecutiveNoRunsChecks` in the watch loop) is a safety valve: after
429
+ * MAX_NO_RUNS_CHECKS consecutive checks where a repo's PR-triggered workflows produced
430
+ * 0 workflow runs, /merge stops waiting and trusts the available signal (external
431
+ * checks / mergeability). For that valve to ever fire, the counter must keep
432
+ * incrementing across watch-loop iterations for the same commit.
433
+ *
434
+ * The previous logic reset the counter whenever `ciStatus.status !== 'no_checks'`.
435
+ * That is wrong when `status === 'success'` comes from EXTERNAL checks only (e.g.
436
+ * CodeRabbit) while the repo's own PR-triggered workflows have 0 runs — for example a
437
+ * fork PR whose only workflow triggers on `push`, which never fires for fork commits in
438
+ * the base repo. In that case getMergeBlockers keeps emitting the "no workflow runs"
439
+ * wait, but the caller reset the counter to 0 every iteration, pinning it at "check
440
+ * 1/5" forever — an infinite watch loop that hung for over an hour (Issue #1918).
441
+ *
442
+ * Fix: keep the counter whenever getMergeBlockers signals it is still inside the
443
+ * no-workflow-runs wait (`noWorkflowRunsForCommit === true`), regardless of ciStatus.
444
+ *
445
+ * @param {{status?: string}|null|undefined} ciStatus - Detailed CI status object.
446
+ * @param {boolean} [noWorkflowRunsForCommit=false] - True when getMergeBlockers is still
447
+ * waiting for PR-triggered workflow runs to register for the current commit.
448
+ * @returns {boolean} true if `consecutiveNoRunsChecks` should be reset to 0.
449
+ */
450
+ export const shouldResetNoRunsCounter = (ciStatus, noWorkflowRunsForCommit = false) => {
451
+ // Still inside the no-workflow-runs safety-valve wait — the counter MUST keep
452
+ // climbing toward MAX_NO_RUNS_CHECKS, so do not reset it.
453
+ if (noWorkflowRunsForCommit) {
454
+ return false;
455
+ }
456
+ // Genuine CI checks exist (pending/success/failure backed by workflow runs) — the
457
+ // "no runs" counter is irrelevant and should be reset.
458
+ return Boolean(ciStatus && ciStatus.status !== 'no_checks');
459
+ };
460
+
424
461
  export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
425
462
  const blockers = [];
426
463
 
464
+ // Issue #1918: Tracks whether we are still waiting for PR-triggered workflow runs to
465
+ // register for the current commit (0 runs observed). When true, the caller must NOT
466
+ // reset its consecutive-no-runs counter, otherwise the MAX_NO_RUNS_CHECKS safety valve
467
+ // never fires and /merge loops forever (e.g. fork PR + push-only workflow + a passing
468
+ // external check reporting status 'success').
469
+ let noWorkflowRunsForCommit = false;
470
+
427
471
  // Use detailed CI status to distinguish between all possible states
428
472
  const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
429
473
 
@@ -630,6 +674,8 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
630
674
  if (verbose) {
631
675
  await log(formatAligned('⏳', 'Waiting for CI:', `No workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}) — check ${checkCount}/${MAX_NO_RUNS_CHECKS}, commit age: ${commitInfo.ageSeconds ?? 'unknown'}s`, 2));
632
676
  }
677
+ // Issue #1918: Still waiting for workflow runs to register — keep the counter.
678
+ noWorkflowRunsForCommit = true;
633
679
  blockers.push({
634
680
  type: 'ci_pending',
635
681
  message: `CI/CD workflow runs have not appeared yet — workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}), waiting for GitHub to register workflow runs (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`,
@@ -640,6 +686,8 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
640
686
  if (verbose) {
641
687
  await log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
642
688
  }
689
+ // Issue #1918: Still inside the grace-period wait for workflow runs — keep the counter.
690
+ noWorkflowRunsForCommit = true;
643
691
  blockers.push({
644
692
  type: 'ci_pending',
645
693
  message: `CI/CD workflow runs have not appeared yet — commit is ${commitInfo.ageSeconds}s old, waiting for GitHub to register workflow runs (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s)`,
@@ -717,6 +765,9 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
717
765
  if (verbose) {
718
766
  await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} external checks), but repo has PR-triggered workflows with 0 workflow runs — likely race condition (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`);
719
767
  }
768
+ // Issue #1918: Keep the caller's no-runs counter climbing so the safety valve
769
+ // can fire — otherwise a 'success' from external-only checks resets it forever.
770
+ noWorkflowRunsForCommit = true;
720
771
  // Wait for GitHub Actions to register workflow runs
721
772
  blockers.push({
722
773
  type: 'ci_pending',
@@ -839,11 +890,12 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
839
890
  });
840
891
  }
841
892
 
842
- return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
893
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false, noWorkflowRunsForCommit };
843
894
  };
844
895
 
845
896
  export default {
846
897
  checkForExistingComment,
847
898
  checkForNonBotComments,
848
899
  getMergeBlockers,
900
+ shouldResetNoRunsCounter,
849
901
  };
@@ -54,7 +54,7 @@ import { limitReset } from './config.lib.mjs';
54
54
 
55
55
  // Import helper functions extracted for file size management (Issue #1593)
56
56
  const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
57
- const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
57
+ const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, shouldResetNoRunsCounter, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
58
58
 
59
59
  // Issue #1769: cancelled/stale CI re-run failures need a human action stop, not polling forever.
60
60
  const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
@@ -203,10 +203,15 @@ export const watchUntilMergeable = async params => {
203
203
  consecutiveNoRunsChecks++;
204
204
 
205
205
  // Get merge blockers
206
- const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
207
-
208
- // Issue #1503: Reset counter when CI checks exist (safety valve only for consecutive "no runs")
209
- if (ciStatus && ciStatus.status !== 'no_checks') {
206
+ const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus, noWorkflowRunsForCommit } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
207
+
208
+ // Issue #1503/#1918: Reset counter when CI checks exist (safety valve only for
209
+ // consecutive "no runs"). Issue #1918: do NOT reset while getMergeBlockers is still
210
+ // waiting for PR-triggered workflow runs to register (noWorkflowRunsForCommit). A
211
+ // 'success' status from external-only checks (e.g. CodeRabbit) on a fork PR whose
212
+ // workflow only triggers on `push` previously reset the counter every iteration,
213
+ // pinning it at "check 1/5" forever and hanging /merge for over an hour.
214
+ if (shouldResetNoRunsCounter(ciStatus, noWorkflowRunsForCommit)) {
210
215
  // CI checks exist (pending, success, failure, etc.) — the "no runs" counter is irrelevant
211
216
  consecutiveNoRunsChecks = 0;
212
217
  } else if (noCiConfigured || noCiTriggered) {
@@ -40,7 +40,7 @@ const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-s
40
40
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
41
41
  const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
42
42
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
43
- const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
43
+ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
44
44
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
45
45
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
46
46
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
@@ -369,6 +369,13 @@ function isForwardedOrReply(ctx) {
369
369
  return _isForwardedOrReply(ctx, { verbose: VERBOSE });
370
370
  }
371
371
 
372
+ // Forwarded-only check (issue #1922). Commands that support the reply feature
373
+ // (e.g. /task, /solve) must still reject forwarded commands without rejecting
374
+ // genuine user replies, so they use this instead of isForwardedOrReply.
375
+ function isForwarded(ctx) {
376
+ return _isForwarded(ctx, { verbose: VERBOSE });
377
+ }
378
+
372
379
  /**
373
380
  * Validates the model name in the args array and returns an error message if invalid
374
381
  * @param {string[]} args - Array of command arguments
@@ -613,7 +620,7 @@ const { registerLanguageCommand } = await import('./telegram-language-command.li
613
620
  registerLanguageCommand(bot, { VERBOSE, isOldMessage, isForwardedOrReply });
614
621
 
615
622
  const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
616
- const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
623
+ const sharedCommandOpts = { VERBOSE, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
617
624
  registerAcceptInvitesCommand(bot, sharedCommandOpts);
618
625
  const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
619
626
  registerMergeCommand(bot, sharedCommandOpts);
@@ -663,12 +670,9 @@ async function handleSolveCommand(ctx) {
663
670
  }
664
671
 
665
672
  // Check if this is a forwarded message (not allowed)
666
- // But allow reply messages for URL extraction feature
673
+ // But allow reply messages for URL extraction feature (issue #1922)
667
674
  const message = ctx.message;
668
- const isForwarded = message.forward_origin && message.forward_origin.type;
669
- const isOldApiForwarded = message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date;
670
-
671
- if (isForwarded || isOldApiForwarded) {
675
+ if (isForwarded(ctx)) {
672
676
  if (VERBOSE) {
673
677
  console.log(`[VERBOSE] ${solveCommandDisplay} ignored: forwarded message`);
674
678
  }
@@ -154,7 +154,7 @@ async function fileSize(filePath) {
154
154
  * @param {Function} [options.parseGitHubUrl] - Override for tests
155
155
  */
156
156
  export async function registerLogCommand(bot, options) {
157
- const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
157
+ const { VERBOSE = false, isOldMessage, isForwarded, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
158
158
  const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
159
159
  const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
160
160
  const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
@@ -167,6 +167,11 @@ export async function registerLogCommand(bot, options) {
167
167
  VERBOSE && console.log('[VERBOSE] /log ignored: old message');
168
168
  return;
169
169
  }
170
+ // Issue #1922: never re-execute a forwarded command.
171
+ if (isForwarded && isForwarded(ctx)) {
172
+ VERBOSE && console.log('[VERBOSE] /log ignored: forwarded message');
173
+ return;
174
+ }
170
175
 
171
176
  const chat = ctx.chat;
172
177
  const message = ctx.message;
@@ -57,31 +57,35 @@ export function isChatAuthorized(chatId, allowedChats) {
57
57
  }
58
58
 
59
59
  /**
60
- * Check if a message is forwarded or a reply to another user's message.
60
+ * Check if a message is a forwarded message.
61
61
  *
62
- * This function distinguishes between:
63
- * 1. Forwarded messages (should be ignored)
64
- * 2. User replies to other messages (should be ignored, except for /solve reply feature)
65
- * 3. Forum topic messages (should NOT be ignored - they have reply_to_message pointing
66
- * to the topic's first message with forum_topic_created)
67
- * 4. Normal messages (should NOT be ignored)
62
+ * Unlike {@link isForwardedOrReply}, this function ONLY detects forwarded
63
+ * messages and intentionally ignores replies. It exists so command handlers
64
+ * that rely on the reply feature (e.g. `/task` issue creation, `/solve` URL
65
+ * extraction) can still reject forwarded commands which must never be
66
+ * executed again while continuing to accept genuine user replies.
67
+ *
68
+ * A forwarded command is dangerous because forwarding the bot's own response
69
+ * (or any message that starts with `/task`, `/solve`, ...) would otherwise
70
+ * trigger a brand-new execution that the user never intended.
68
71
  *
69
72
  * @param {Object} ctx - Telegraf context object
70
73
  * @param {Object} [options] - Options
71
74
  * @param {boolean} [options.verbose] - Enable verbose logging
72
- * @returns {boolean} true if message is forwarded or a reply (and should be filtered)
75
+ * @returns {boolean} true if message is forwarded (and should be filtered)
76
+ * @see https://github.com/link-assistant/hive-mind/issues/1922
73
77
  */
74
- export function isForwardedOrReply(ctx, options = {}) {
78
+ export function isForwarded(ctx, options = {}) {
75
79
  const message = ctx.message;
76
80
  if (!message) {
77
81
  if (options.verbose) {
78
- console.log('[VERBOSE] isForwardedOrReply: No message object');
82
+ console.log('[VERBOSE] isForwarded: No message object');
79
83
  }
80
84
  return false;
81
85
  }
82
86
 
83
87
  if (options.verbose) {
84
- console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
88
+ console.log('[VERBOSE] isForwarded: Checking forwarding fields...');
85
89
  console.log('[VERBOSE] message.forward_origin:', JSON.stringify(message.forward_origin));
86
90
  console.log('[VERBOSE] message.forward_origin?.type:', message.forward_origin?.type);
87
91
  console.log('[VERBOSE] message.forward_from:', JSON.stringify(message.forward_from));
@@ -90,8 +94,6 @@ export function isForwardedOrReply(ctx, options = {}) {
90
94
  console.log('[VERBOSE] message.forward_signature:', message.forward_signature);
91
95
  console.log('[VERBOSE] message.forward_sender_name:', message.forward_sender_name);
92
96
  console.log('[VERBOSE] message.forward_date:', message.forward_date);
93
- console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
94
- console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
95
97
  }
96
98
 
97
99
  // Check if message is forwarded (has forward_origin field with actual content)
@@ -99,15 +101,57 @@ export function isForwardedOrReply(ctx, options = {}) {
99
101
  // which are truthy in JavaScript but don't indicate a forwarded message
100
102
  if (message.forward_origin && message.forward_origin.type) {
101
103
  if (options.verbose) {
102
- console.log('[VERBOSE] isForwardedOrReply: TRUE - forward_origin.type exists:', message.forward_origin.type);
104
+ console.log('[VERBOSE] isForwarded: TRUE - forward_origin.type exists:', message.forward_origin.type);
103
105
  }
104
106
  return true;
105
107
  }
106
108
  // Also check old forwarding API fields for backward compatibility
107
109
  if (message.forward_from || message.forward_from_chat || message.forward_from_message_id || message.forward_signature || message.forward_sender_name || message.forward_date) {
108
110
  if (options.verbose) {
109
- console.log('[VERBOSE] isForwardedOrReply: TRUE - old forwarding API field detected');
111
+ console.log('[VERBOSE] isForwarded: TRUE - old forwarding API field detected');
112
+ }
113
+ return true;
114
+ }
115
+
116
+ if (options.verbose) {
117
+ console.log('[VERBOSE] isForwarded: FALSE - no forwarding detected');
118
+ }
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Check if a message is forwarded or a reply to another user's message.
124
+ *
125
+ * This function distinguishes between:
126
+ * 1. Forwarded messages (should be ignored)
127
+ * 2. User replies to other messages (should be ignored, except for /solve reply feature)
128
+ * 3. Forum topic messages (should NOT be ignored - they have reply_to_message pointing
129
+ * to the topic's first message with forum_topic_created)
130
+ * 4. Normal messages (should NOT be ignored)
131
+ *
132
+ * @param {Object} ctx - Telegraf context object
133
+ * @param {Object} [options] - Options
134
+ * @param {boolean} [options.verbose] - Enable verbose logging
135
+ * @returns {boolean} true if message is forwarded or a reply (and should be filtered)
136
+ */
137
+ export function isForwardedOrReply(ctx, options = {}) {
138
+ const message = ctx.message;
139
+ if (!message) {
140
+ if (options.verbose) {
141
+ console.log('[VERBOSE] isForwardedOrReply: No message object');
110
142
  }
143
+ return false;
144
+ }
145
+
146
+ if (options.verbose) {
147
+ console.log('[VERBOSE] isForwardedOrReply: Checking message fields...');
148
+ console.log('[VERBOSE] message.reply_to_message:', JSON.stringify(message.reply_to_message));
149
+ console.log('[VERBOSE] message.reply_to_message?.message_id:', message.reply_to_message?.message_id);
150
+ }
151
+
152
+ // Forwarded messages are handled by the shared isForwarded() filter so the
153
+ // forwarding detection logic lives in exactly one place (issue #1922).
154
+ if (isForwarded(ctx, options)) {
111
155
  return true;
112
156
  }
113
157
  // Check if message is a reply (has reply_to_message field with actual content)
@@ -270,7 +270,7 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
270
270
  * See https://github.com/link-assistant/hive-mind/issues/1871.
271
271
  */
272
272
  export function registerStartStopCommands(bot, options) {
273
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
273
+ const { VERBOSE = false, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
274
274
  const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
275
275
  // Issue #1783: look the UUID up in the session monitor so /stop can let the
276
276
  // user who started the task stop it (mirrors /terminal_watch from PR #1779).
@@ -529,6 +529,13 @@ export function registerStartStopCommands(bot, options) {
529
529
  VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
530
530
  return;
531
531
  }
532
+ // Issue #1922: a forwarded /stop (even a targeted `/stop <uuid>`) must never
533
+ // be re-executed. Replies are still allowed because the targeted modes use
534
+ // them on purpose (#524, #1780), so we use the forwarded-only filter here.
535
+ if (isForwarded && isForwarded(ctx)) {
536
+ VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded message');
537
+ return;
538
+ }
532
539
 
533
540
  // Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
534
541
  // the chat-level stop, because both targeted modes are intentionally
@@ -95,7 +95,7 @@ function injectLanguageIfMissing(args, locale) {
95
95
  }
96
96
 
97
97
  export function registerTaskCommands(bot, options) {
98
- const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, createTaskIssue: createTaskIssueFn = createTaskIssue, resolveLocale = null } = options;
98
+ const { VERBOSE, taskEnabled, addBreadcrumb, isOldMessage, isForwarded, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, createTaskIssue: createTaskIssueFn = createTaskIssue, resolveLocale = null } = options;
99
99
 
100
100
  async function handleTaskCommand(ctx) {
101
101
  const commandName = getTaskCommandNameFromText(ctx.message?.text) || 'task';
@@ -114,6 +114,13 @@ export function registerTaskCommands(bot, options) {
114
114
  return;
115
115
  }
116
116
  if (isOldMessage(ctx)) return;
117
+ // Issue #1922: a forwarded /task command must never be re-executed. Replies
118
+ // are still allowed because /task uses them for issue creation, so we use the
119
+ // forwarded-only filter instead of isForwardedOrReply.
120
+ if (isForwarded && isForwarded(ctx)) {
121
+ VERBOSE && console.log(`[VERBOSE] ${commandDisplay} ignored: forwarded message`);
122
+ return;
123
+ }
117
124
  if (!isGroupChat(ctx)) {
118
125
  await ctx.reply(`❌ The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
119
126
  return;
@@ -307,7 +307,7 @@ export async function startAutoTerminalWatchForSession({ bot, ctx, sessionId, se
307
307
  }
308
308
 
309
309
  export async function registerTerminalWatchCommand(bot, options) {
310
- const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
310
+ const { VERBOSE = false, isOldMessage, isForwarded, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
311
311
  const runner = !options.querySessionStatus || !options.isTerminalSessionStatus ? await import('./isolation-runner.lib.mjs') : null;
312
312
  const querySessionStatus = options.querySessionStatus || runner.querySessionStatus;
313
313
  const isTerminalSessionStatus = options.isTerminalSessionStatus || runner.isTerminalSessionStatus;
@@ -318,6 +318,11 @@ export async function registerTerminalWatchCommand(bot, options) {
318
318
  const handleTerminalWatchCommand = async ctx => {
319
319
  VERBOSE && console.log('[VERBOSE] /terminal_watch command received');
320
320
  if (isOldMessage && isOldMessage(ctx)) return;
321
+ // Issue #1922: never re-execute a forwarded command.
322
+ if (isForwarded && isForwarded(ctx)) {
323
+ VERBOSE && console.log('[VERBOSE] /terminal_watch ignored: forwarded message');
324
+ return;
325
+ }
321
326
 
322
327
  const chat = ctx.chat;
323
328
  const message = ctx.message;
@@ -90,7 +90,7 @@ export const formatTokenList = tokens => {
90
90
  * @param {Function} [options.fetchTokens] — test override for getAllKnownLocalTokens
91
91
  */
92
92
  export const registerTokensCommand = (bot, options = {}) => {
93
- const { VERBOSE = false, isOldMessage, allowedChats } = options;
93
+ const { VERBOSE = false, isOldMessage, isForwarded, allowedChats } = options;
94
94
  const fetchTokens = options.fetchTokens || getAllKnownLocalTokens;
95
95
 
96
96
  bot.command('tokens', async ctx => {
@@ -98,6 +98,11 @@ export const registerTokensCommand = (bot, options = {}) => {
98
98
  VERBOSE && console.log('[VERBOSE] /tokens ignored: old message');
99
99
  return;
100
100
  }
101
+ // Issue #1922: never re-execute a forwarded command.
102
+ if (isForwarded && isForwarded(ctx)) {
103
+ VERBOSE && console.log('[VERBOSE] /tokens ignored: forwarded message');
104
+ return;
105
+ }
101
106
 
102
107
  const chat = ctx.chat;
103
108
  if (!chat || !ctx.from) return;
@@ -72,6 +72,22 @@ export const classifyRetryableError = value => {
72
72
  return { message, isRetryable: false, isCapacity: false, requiresFreshSession: true, label: 'Corrupted thinking blocks (un-resumable session)' };
73
73
  }
74
74
 
75
+ // Issue #1924: Server-side temporary rate limiting (HTTP 429), distinct from an
76
+ // account usage/quota limit. The Claude CLI surfaces this as a synthetic
77
+ // assistant/result message and an api_error_status of 429:
78
+ // "API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited"
79
+ // The response carries `x-should-retry: true` and the stream emits a
80
+ // `rate_limit_event` with `status: "rejected"`. Because the message explicitly
81
+ // says "not your usage limit", it is NOT a usage-limit reset-time situation and
82
+ // must NOT be routed through detectUsageLimit() (there is no reset time to wait
83
+ // for). It is a transient throttle that clears on its own, so it is safe to
84
+ // retry with the session preserved (--resume) after a backoff. Switching models
85
+ // does not help (the throttle is request-rate, not model capacity), so
86
+ // isCapacity is false.
87
+ if (lower.includes('temporarily limiting requests') || (lower.includes('rate limited') && lower.includes('not your usage limit')) || (lower.includes('rate_limit') && lower.includes('429'))) {
88
+ return { message, isRetryable: true, isCapacity: false, label: 'Server rate limited (429)' };
89
+ }
90
+
75
91
  if (lower.includes('api error: 503') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
76
92
  return { message, isRetryable: true, isCapacity: false, label: '503 network error' };
77
93
  }