@link-assistant/hive-mind 2.13.4 → 2.14.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/agent.lib.mjs +5 -1
  8. package/src/claude.lib.mjs +5 -158
  9. package/src/claude.session-tokens.lib.mjs +180 -0
  10. package/src/codex.diagnostics.lib.mjs +135 -0
  11. package/src/codex.lib.mjs +8 -121
  12. package/src/config.lib.mjs +9 -0
  13. package/src/docker-sidecar.lib.mjs +276 -0
  14. package/src/formal-ai-maintenance.lib.mjs +2 -14
  15. package/src/formal-ai-sidecar.lib.mjs +17 -137
  16. package/src/gemini.lib.mjs +5 -1
  17. package/src/git-push-guard.lib.mjs +230 -0
  18. package/src/git-retry.lib.mjs +97 -0
  19. package/src/github-pr-idempotency.lib.mjs +83 -0
  20. package/src/github-rate-limit.lib.mjs +44 -41
  21. package/src/hive.mjs +8 -150
  22. package/src/hive.repository-fallback.lib.mjs +125 -0
  23. package/src/hive.startup-checks.lib.mjs +57 -0
  24. package/src/isolation-runner.lib.mjs +94 -287
  25. package/src/isolation-runner.parsers.lib.mjs +292 -0
  26. package/src/lib.mjs +79 -18
  27. package/src/opencode.lib.mjs +5 -1
  28. package/src/qwen.lib.mjs +5 -1
  29. package/src/router-isolation.lib.mjs +496 -0
  30. package/src/router-logs.lib.mjs +143 -0
  31. package/src/router-maintenance.lib.mjs +77 -0
  32. package/src/router-session-drain.lib.mjs +153 -0
  33. package/src/router-sidecar.lib.mjs +516 -0
  34. package/src/router-task-isolation.lib.mjs +121 -0
  35. package/src/session-monitor.lib.mjs +12 -272
  36. package/src/session-monitor.queries.lib.mjs +304 -0
  37. package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
  38. package/src/solve.auto-pr.lib.mjs +40 -154
  39. package/src/solve.config.lib.mjs +11 -0
  40. package/src/solve.mjs +8 -158
  41. package/src/solve.mode.lib.mjs +191 -0
  42. package/src/task.config.lib.mjs +5 -0
  43. package/src/task.mjs +1 -0
  44. package/src/telegram-bot.mjs +18 -0
  45. package/src/telegram-solve-queue.lib.mjs +19 -272
  46. package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
  47. package/src/transient-errors.lib.mjs +238 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Resolve which mode solve.mjs is running in and collect the pull request /
3
+ * issue facts that mode implies.
4
+ *
5
+ * Three entry shapes converge here:
6
+ * - `--auto-continue` on an issue URL, which may find an existing pull
7
+ * request (or just a branch left behind by an earlier run);
8
+ * - a pull request URL, whose head branch and linked issue are read from the
9
+ * API;
10
+ * - a plain issue URL, which is the traditional mode.
11
+ *
12
+ * Fork detection is shared by the first two: a pull request whose head
13
+ * repository owner differs from the upstream owner puts solve in fork mode,
14
+ * unless the upstream is private and the user has write access (issue #1716),
15
+ * in which case working directly on the upstream is both possible and safer.
16
+ *
17
+ * Extracted from solve.mjs (issue #2175) so that file stays under the
18
+ * 1350-line early-warning threshold of the CI file-headroom check (long files
19
+ * cause concurrent PR merge conflicts — issue #1593). Behaviour is unchanged,
20
+ * including the `global.createdPR` assignments the error handlers read.
21
+ *
22
+ * @see https://github.com/link-assistant/hive-mind/issues/2175
23
+ */
24
+
25
+ /**
26
+ * @param {object} deps every collaborator solve.mjs already has in scope
27
+ * @returns {Promise<{issueNumber: number|undefined, prNumber: number|undefined, prBranch: string|undefined, mergeStateStatus: string|undefined, prState: string|undefined, forkOwner: string|null, forkRepoName: string|null, isContinueMode: boolean}>}
28
+ */
29
+ export async function resolveSolveMode({ argv, owner, repo, urlNumber, issueUrl, isIssueUrl, isPrUrl, skipForkForPrivateUpstream, shouldAttachLogs, log, safeExit, githubLib, processAutoContinueForIssue, handleMaintainerForkAccess, extractLinkedIssueNumber, reportError, cleanErrorMessage }) {
30
+ let issueNumber;
31
+ let prNumber;
32
+ let prBranch;
33
+ let mergeStateStatus;
34
+ let prState;
35
+ let forkOwner = null;
36
+ let forkRepoName = null;
37
+ let isContinueMode = false;
38
+ // Auto-continue logic: check for existing PRs if --auto-continue is enabled
39
+ const autoContinueResult = await processAutoContinueForIssue(argv, isIssueUrl, urlNumber, owner, repo);
40
+ if (autoContinueResult.isContinueMode) {
41
+ isContinueMode = true;
42
+ prNumber = autoContinueResult.prNumber;
43
+ prBranch = autoContinueResult.prBranch;
44
+ issueNumber = autoContinueResult.issueNumber;
45
+ // Only check PR details if we have a PR number
46
+ if (prNumber) {
47
+ // Store PR info globally for error handlers
48
+ global.createdPR = { number: prNumber };
49
+ // Check if PR is from a fork and get fork owner, merge status, and PR state
50
+ if (argv.verbose) {
51
+ await log(' Checking if PR is from a fork...', { verbose: true });
52
+ }
53
+ try {
54
+ // Issue #2175: routed through githubLib.ghPrView (the same helper the
55
+ // pull-request-URL branch below uses) instead of a direct `$` call to
56
+ // `gh`, so the request goes through the rate-limit-safe wrapper.
57
+ const prCheckResult = await githubLib.ghPrView({ prNumber, owner, repo, jsonFields: 'headRepositoryOwner,headRepository,mergeStateStatus,state' });
58
+ if (prCheckResult.code === 0 && prCheckResult.data) {
59
+ const prCheckData = prCheckResult.data;
60
+ // Extract merge status and PR state
61
+ mergeStateStatus = prCheckData.mergeStateStatus;
62
+ prState = prCheckData.state;
63
+ if (argv.verbose) {
64
+ await log(` PR state: ${prState || 'UNKNOWN'}`, { verbose: true });
65
+ await log(` Merge status: ${mergeStateStatus || 'UNKNOWN'}`, { verbose: true });
66
+ }
67
+ if (prCheckData.headRepositoryOwner && prCheckData.headRepositoryOwner.login !== owner) {
68
+ const detectedForkOwner = prCheckData.headRepositoryOwner.login;
69
+ const detectedForkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
70
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
71
+ if (skipForkForPrivateUpstream) {
72
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
73
+ await log(' Working directly on the private upstream repository (Issue #1716).');
74
+ } else {
75
+ forkOwner = detectedForkOwner;
76
+ // Get actual fork repository name (may be prefixed) and store for use in setupRepository
77
+ forkRepoName = detectedForkRepoName;
78
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
79
+ if (argv.verbose) {
80
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
81
+ await log(' Will clone fork repository for continue mode', { verbose: true });
82
+ }
83
+ }
84
+ // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
85
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
86
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
87
+ }
88
+ }
89
+ }
90
+ } catch (forkCheckError) {
91
+ if (argv.verbose) {
92
+ await log(` Warning: Could not check fork status: ${forkCheckError.message}`, { verbose: true });
93
+ }
94
+ }
95
+ } else {
96
+ // We have a branch but no PR - we'll use the existing branch and create a PR later
97
+ await log(`🔄 Using existing branch: ${prBranch} (no PR yet - will create one)`);
98
+ await log(' This branch was created by an earlier run; this run is reusing it rather than creating a fresh branch.');
99
+ if (argv.verbose) {
100
+ await log(' Branch will be checked out and PR will be created during auto-PR creation phase', {
101
+ verbose: true,
102
+ });
103
+ }
104
+ }
105
+ } else if (isIssueUrl) {
106
+ issueNumber = autoContinueResult.issueNumber || urlNumber;
107
+ }
108
+ if (isPrUrl) {
109
+ isContinueMode = true;
110
+ prNumber = urlNumber;
111
+ // Store PR info globally for error handlers
112
+ global.createdPR = { number: prNumber, url: issueUrl };
113
+ await log(`🔄 Continue mode: Working with PR #${prNumber}`);
114
+ if (argv.verbose) {
115
+ await log(' Continue mode activated: PR URL provided directly', { verbose: true });
116
+ await log(` PR Number set to: ${prNumber}`, { verbose: true });
117
+ await log(' Will fetch PR details and linked issue', { verbose: true });
118
+ }
119
+ // Get PR details to find the linked issue and branch
120
+ try {
121
+ const prResult = await githubLib.ghPrView({
122
+ prNumber,
123
+ owner,
124
+ repo,
125
+ jsonFields: 'headRefName,body,number,mergeStateStatus,state,headRepositoryOwner,headRepository',
126
+ });
127
+ if (prResult.code !== 0 || !prResult.data) {
128
+ await log('Error: Failed to get PR details', { level: 'error' });
129
+ if (prResult.output.includes('Could not resolve to a PullRequest')) {
130
+ await githubLib.handlePRNotFoundError({ prNumber, owner, repo, argv, shouldAttachLogs });
131
+ } else {
132
+ await log(`Error: ${prResult.stderr || 'Unknown error'}`, { level: 'error' });
133
+ }
134
+ await safeExit(1, 'Failed to get PR details');
135
+ }
136
+ const prData = prResult.data;
137
+ prBranch = prData.headRefName;
138
+ mergeStateStatus = prData.mergeStateStatus;
139
+ prState = prData.state;
140
+ // Check if this is a fork PR
141
+ if (prData.headRepositoryOwner && prData.headRepositoryOwner.login !== owner) {
142
+ const detectedForkOwner = prData.headRepositoryOwner.login;
143
+ const detectedForkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
144
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
145
+ if (skipForkForPrivateUpstream) {
146
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
147
+ await log(' Working directly on the private upstream repository (Issue #1716).');
148
+ } else {
149
+ forkOwner = detectedForkOwner;
150
+ // Get actual fork repository name and store for use in setupRepository
151
+ forkRepoName = detectedForkRepoName;
152
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
153
+ if (argv.verbose) {
154
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
155
+ await log(' Will clone fork repository for continue mode', { verbose: true });
156
+ }
157
+ }
158
+ // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
159
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
160
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
161
+ }
162
+ }
163
+ await log(`📝 PR branch: ${prBranch}`);
164
+ const prBody = prData.body || '';
165
+ const extractedIssueNumber = extractLinkedIssueNumber(prBody);
166
+ if (extractedIssueNumber) {
167
+ issueNumber = extractedIssueNumber;
168
+ await log(`🔗 Found linked issue #${issueNumber}`);
169
+ } else {
170
+ // If no linked issue found, we can still continue but warn
171
+ await log('⚠️ Warning: No linked issue found in PR body', { level: 'warning' });
172
+ await log(' The PR should contain "Fixes #123" or similar to link an issue', { level: 'warning' });
173
+ // Set issueNumber to PR number as fallback
174
+ issueNumber = prNumber;
175
+ }
176
+ } catch (error) {
177
+ reportError(error, {
178
+ context: 'pr_processing',
179
+ prNumber,
180
+ operation: 'process_pull_request',
181
+ });
182
+ await log(`Error: Failed to process PR: ${cleanErrorMessage(error)}`, { level: 'error' });
183
+ await safeExit(1, 'Failed to process PR');
184
+ }
185
+ } else {
186
+ // Traditional issue mode
187
+ issueNumber = urlNumber;
188
+ await log(`📝 Issue mode: Working with issue #${issueNumber}`);
189
+ }
190
+ return { issueNumber, prNumber, prBranch, mergeStateStatus, prState, forkOwner, forkRepoName, isContinueMode };
191
+ }
@@ -63,6 +63,11 @@ export const createYargsConfig = yargsInstance =>
63
63
  choices: ['screen', 'none', 'docker'],
64
64
  default: 'docker',
65
65
  })
66
+ .option('use-router', {
67
+ type: 'boolean',
68
+ description: '[EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar instead of mounting AI credentials into the task container (issue #2164)',
69
+ default: false,
70
+ })
66
71
  .option('screen-name', {
67
72
  type: 'string',
68
73
  description: 'Screen session name when --isolation screen is used',
package/src/task.mjs CHANGED
@@ -42,6 +42,7 @@ if (earlyArgs.length === 0 || earlyArgs.includes('--help') || earlyArgs.includes
42
42
  console.log(' --tool AI tool for agent-commander read-only mode (claude, codex, opencode, agent, qwen, gemini) [default: claude]');
43
43
  console.log(' --model, -m Model to use');
44
44
  console.log(' --isolation agent-commander isolation mode [default: docker]');
45
+ console.log(' --use-router [EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar (issue #2164)');
45
46
  console.log(' --dry-run Print split output without creating GitHub issues');
46
47
  console.log(' --verbose, -v Enable verbose logging');
47
48
  console.log(' --output-format Output format (text or json) [default: text]');
@@ -255,6 +255,7 @@ const { createHeartbeat, resumeSessionsOnLaunch, createShutdownHandler } = await
255
255
  const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
256
256
  const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
257
257
  const { startFormalAiMaintenance } = await import('./formal-ai-maintenance.lib.mjs');
258
+ const { startRouterMaintenance } = await import('./router-maintenance.lib.mjs');
258
259
  // Initialize Sentry for error tracking
259
260
  await initializeSentry({
260
261
  debug: VERBOSE,
@@ -1198,6 +1199,7 @@ if (VERBOSE) {
1198
1199
  const launchAbortController = new AbortController();
1199
1200
  let sessionMonitoringTimer = null;
1200
1201
  let formalAiMaintenance = null;
1202
+ let routerMaintenance = null;
1201
1203
  let launchAnnouncementShown = false;
1202
1204
 
1203
1205
  function startSessionMonitoringOnce() {
@@ -1220,6 +1222,20 @@ function startFormalAiMaintenanceOnce() {
1220
1222
  });
1221
1223
  }
1222
1224
 
1225
+ // Issue #2164 (R5): the router sidecar must only run while at least one task
1226
+ // uses it. A task that finishes releases its own lease, but a killed task or a
1227
+ // host reboot leaves one behind — this tick reconciles those against Docker and
1228
+ // stops an idle router. The audit data volume is never removed.
1229
+ function startRouterMaintenanceOnce() {
1230
+ if (routerMaintenance) return;
1231
+ routerMaintenance = startRouterMaintenance({
1232
+ verbose: VERBOSE,
1233
+ log: async message => {
1234
+ console.log(message);
1235
+ },
1236
+ });
1237
+ }
1238
+
1223
1239
  // Issue #1927 (requirements #3/#4): a periodic timestamped heartbeat so the "last
1224
1240
  // time the bot was alive" is always discoverable from the log. The heartbeat
1225
1241
  // logic lives in bot-lifecycle.lib.mjs so it can be unit tested.
@@ -1240,6 +1256,7 @@ async function onBotLaunched() {
1240
1256
 
1241
1257
  startSessionMonitoringOnce();
1242
1258
  startFormalAiMaintenanceOnce();
1259
+ startRouterMaintenanceOnce();
1243
1260
  heartbeat.start();
1244
1261
  if (VERBOSE) {
1245
1262
  console.log('[VERBOSE] Bot launched successfully');
@@ -1334,6 +1351,7 @@ const handleShutdownSignal = createShutdownHandler({
1334
1351
  launchAbortController.abort();
1335
1352
  if (sessionMonitoringTimer) clearInterval(sessionMonitoringTimer);
1336
1353
  formalAiMaintenance?.stop();
1354
+ routerMaintenance?.stop();
1337
1355
  heartbeat.stop();
1338
1356
  stopSolveQueue();
1339
1357
  },
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @see https://github.com/link-assistant/hive-mind/issues/1041
11
11
  */
12
- import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
12
+ import { getLimitCache } from './limits.lib.mjs';
13
13
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
14
14
  import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems, groupQueueItemsByTool, reportDequeueDecision } from './telegram-solve-queue.helpers.lib.mjs';
15
15
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
@@ -20,6 +20,8 @@ import { canonicalizeGitHubUrl as canonicalizeQueueUrl } from './github-url-pars
20
20
  import { t } from './i18n.lib.mjs';
21
21
  import { safeEditMessageText } from './telegram-safe-reply.lib.mjs';
22
22
  import { lt } from './limits-i18n.lib.mjs';
23
+ // Issue #2175: throttling decisions live in their own module to keep this file under the 1350-line warning threshold.
24
+ import { checkApiLimits as checkApiLimitsImpl, checkSystemResources as checkSystemResourcesImpl, getLocale } from './telegram-solve-queue.throttling.lib.mjs';
23
25
  export const QueueItemStatus = {
24
26
  QUEUED: 'queued',
25
27
  WAITING: 'waiting',
@@ -28,13 +30,6 @@ export const QueueItemStatus = {
28
30
  FAILED: 'failed',
29
31
  CANCELLED: 'cancelled',
30
32
  };
31
- function getLocale(options = {}) {
32
- if (typeof options === 'string') return options;
33
- return options?.locale || null;
34
- }
35
- function appendWaitingForCurrentCommand(reason, locale) {
36
- return `${reason} (${lt('queue_waiting_current_command', {}, { locale })})`;
37
- }
38
33
  function appendRemainingDuration(reason, ms, locale) {
39
34
  return `${reason} (${lt('remaining', { duration: formatDuration(ms, { locale }) }, { locale })})`;
40
35
  }
@@ -652,282 +647,34 @@ export class SolveQueue {
652
647
  };
653
648
  }
654
649
  /**
655
- * Check system resources (RAM, CPU, disk) using cached values
656
- *
657
- * Uses 5-minute load average for CPU instead of instantaneous usage.
658
- * This provides a more stable metric that isn't affected by brief spikes
659
- * during claude process startup.
660
- *
661
- * Resource threshold modes are now configurable via HIVE_MIND_QUEUE_CONFIG:
662
- * - 'reject': Immediately reject the command, no queueing
663
- * - 'enqueue': Block all commands unconditionally until metric drops
664
- * - 'dequeue-one-at-a-time': Allow one command when above threshold
650
+ * Check system resources (RAM, CPU, disk) using cached values.
665
651
  *
666
- * Default strategies:
667
- * - RAM: enqueue
668
- * - CPU: enqueue
669
- * - DISK: enqueue (waits until disk drops below the threshold)
652
+ * Issue #2175: the implementation lives in
653
+ * telegram-solve-queue.throttling.lib.mjs so this file stays under the
654
+ * 1350-line early-warning threshold (issue #1593).
670
655
  *
671
- * See: https://github.com/link-assistant/hive-mind/issues/1155
672
- * See: https://github.com/link-assistant/hive-mind/issues/1253
673
- * See: https://github.com/link-assistant/hive-mind/issues/1981
674
- *
675
- * @param {number} totalProcessing - Total processing count (queue + external claude processes)
656
+ * @param {number} totalProcessing - Total processing count (queue + external tool processes)
657
+ * @param {object} [options]
676
658
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
677
659
  */
678
660
  async checkSystemResources(totalProcessing = 0, options = {}) {
679
- const locale = getLocale(options);
680
- const reasons = [];
681
- let oneAtATime = false;
682
- let rejected = false;
683
- let rejectReason = null;
684
- // Check RAM (using cached value)
685
- const memResult = await getCachedMemoryInfo(this.verbose);
686
- if (memResult.success) {
687
- const usedRatio = memResult.memory.usedPercentage / 100;
688
- if (usedRatio >= QUEUE_CONFIG.thresholds.ram.value) {
689
- const reason = formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.thresholds.ram.value, { locale });
690
- const strategy = QUEUE_CONFIG.thresholds.ram.strategy;
691
- this.recordThrottle(`ram_${strategy}`);
692
- if (strategy === 'reject') {
693
- rejected = true;
694
- rejectReason = reason;
695
- } else if (strategy === 'dequeue-one-at-a-time') {
696
- oneAtATime = true;
697
- if (totalProcessing > 0) {
698
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
699
- }
700
- } else {
701
- // 'enqueue' - block unconditionally
702
- reasons.push(reason);
703
- }
704
- }
705
- }
706
- // Check CPU using 5-minute load average (more stable than 1-minute)
707
- const cpuResult = await getCachedCpuInfo(this.verbose);
708
- if (cpuResult.success) {
709
- // Use loadAvg5 (5-minute average) instead of usagePercentage (1-minute based)
710
- // This provides a more stable metric that isn't affected by transient spikes
711
- const loadAvg5 = cpuResult.cpuLoad.loadAvg5;
712
- const cpuCount = cpuResult.cpuLoad.cpuCount;
713
- // Calculate usage ratio: loadAvg5 / cpuCount
714
- // Load average of 1.0 per CPU = 100% utilization
715
- const usageRatio = loadAvg5 / cpuCount;
716
- const usagePercent = Math.min(100, Math.round(usageRatio * 100));
717
- if (this.verbose) {
718
- this.log(`CPU 5m load avg: ${loadAvg5.toFixed(2)}, cpus: ${cpuCount}, usage: ${usagePercent}%`);
719
- }
720
- if (usageRatio >= QUEUE_CONFIG.thresholds.cpu.value) {
721
- const reason = formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.thresholds.cpu.value, { locale });
722
- const strategy = QUEUE_CONFIG.thresholds.cpu.strategy;
723
- this.recordThrottle(`cpu_${strategy}`);
724
- if (strategy === 'reject') {
725
- rejected = true;
726
- rejectReason = reason;
727
- } else if (strategy === 'dequeue-one-at-a-time') {
728
- oneAtATime = true;
729
- if (totalProcessing > 0) {
730
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
731
- }
732
- } else {
733
- // 'enqueue' - block unconditionally
734
- reasons.push(reason);
735
- }
736
- }
737
- }
738
- // Check disk space (using cached value)
739
- // Default strategy changed to 'reject' because queue is lost on restart anyway
740
- // See: https://github.com/link-assistant/hive-mind/issues/1253
741
- const diskResult = await getCachedDiskInfo(this.verbose);
742
- if (diskResult.success) {
743
- // Calculate usage from free percentage
744
- const usedPercent = 100 - diskResult.diskSpace.freePercentage;
745
- const usedRatio = usedPercent / 100;
746
- if (usedRatio >= QUEUE_CONFIG.thresholds.disk.value) {
747
- const reason = formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.thresholds.disk.value, { locale });
748
- const strategy = QUEUE_CONFIG.thresholds.disk.strategy;
749
- this.recordThrottle(`disk_${strategy}`);
750
- if (strategy === 'reject') {
751
- rejected = true;
752
- rejectReason = reason;
753
- } else if (strategy === 'dequeue-one-at-a-time') {
754
- oneAtATime = true;
755
- if (totalProcessing > 0) {
756
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
757
- }
758
- } else {
759
- // 'enqueue' - block unconditionally
760
- reasons.push(reason);
761
- }
762
- }
763
- }
764
- return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
661
+ return checkSystemResourcesImpl(this, totalProcessing, options);
765
662
  }
766
663
  /**
767
- * Check API limits (Claude, GitHub) using cached values
664
+ * Check API limits (Claude, Codex, GitHub) using cached values.
768
665
  *
769
- * Logic per issue #1133:
770
- * - CLAUDE_5_HOUR_SESSION_THRESHOLD and CLAUDE_WEEKLY_THRESHOLD use one-at-a-time mode:
771
- * when above threshold, allow exactly one command, block if claudeProcessing > 0
772
- * - GitHub threshold blocks unconditionally when exceeded (ultimate restriction)
666
+ * Issue #2175: the implementation lives in
667
+ * telegram-solve-queue.throttling.lib.mjs so this file stays under the
668
+ * 1350-line early-warning threshold (issue #1593).
773
669
  *
774
- * Logic per issue #1159:
775
- * - When tool is 'agent', 'gemini', or 'qwen', skip Claude-specific limits entirely since these tools use
776
- * different rate limiting backends. Only system resources and GitHub limits apply.
777
- * - For Claude limits, only count Claude-specific processing items, not agent/codex/gemini/qwen items.
778
- * This allows non-Claude tasks to run in parallel even when Claude limits are reached.
779
- *
780
- * Logic per issue #1253:
781
- * - All thresholds now support configurable strategies (reject, enqueue, dequeue-one-at-a-time)
782
- * - Configuration via HIVE_MIND_QUEUE_CONFIG or individual env vars
783
- *
784
- * @param {boolean} hasRunningToolProcess - Whether matching tool processes are running (from pgrep)
785
- * @param {number} toolProcessingCount - Count of matching tool items being processed in queue
786
- * @param {string} tool - The tool being used ('claude', 'agent', 'codex', 'gemini', 'qwen', etc.)
670
+ * @param {boolean} hasRunningToolProcess
671
+ * @param {number} toolProcessingCount
672
+ * @param {string} tool
673
+ * @param {object} [options]
787
674
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
788
675
  */
789
676
  async checkApiLimits(hasRunningToolProcess = false, toolProcessingCount = 0, tool = 'claude', options = {}) {
790
- const locale = getLocale(options);
791
- const reasons = [];
792
- let oneAtATime = false;
793
- let rejected = false;
794
- let rejectReason = null;
795
- // Apply Claude-specific limits only when tool is 'claude'
796
- // Other tools (like 'agent', 'gemini', and 'qwen') use different rate limiting backends and are not
797
- // affected by Claude API limits (5-hour session, weekly limits)
798
- // See: https://github.com/link-assistant/hive-mind/issues/1159
799
- const applyClaudeLimits = tool === 'claude';
800
- const applyCodexLimits = tool === 'codex';
801
- const totalToolProcessing = toolProcessingCount + (hasRunningToolProcess ? 1 : 0);
802
- // Check Claude limits (using cached value)
803
- // Only applied when tool is 'claude'
804
- if (applyClaudeLimits) {
805
- const claudeResult = await getCachedClaudeLimits(this.verbose);
806
- if (claudeResult.success) {
807
- const sessionPercent = claudeResult.usage.currentSession.percentage;
808
- const weeklyPercent = claudeResult.usage.allModels.percentage;
809
- // Session limit (5-hour)
810
- // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY
811
- // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
812
- if (sessionPercent !== null) {
813
- const sessionRatio = sessionPercent / 100;
814
- if (sessionRatio >= QUEUE_CONFIG.thresholds.claude5Hour.value) {
815
- const reason = formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.claude5Hour.value, { locale });
816
- const strategy = QUEUE_CONFIG.thresholds.claude5Hour.strategy;
817
- this.recordThrottle(sessionRatio >= 1.0 ? 'claude_5_hour_session_100' : `claude_5_hour_session_${strategy}`);
818
- if (strategy === 'reject') {
819
- rejected = true;
820
- rejectReason = reason;
821
- } else if (strategy === 'dequeue-one-at-a-time') {
822
- oneAtATime = true;
823
- if (totalToolProcessing > 0) {
824
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
825
- }
826
- } else {
827
- // 'enqueue' - block unconditionally
828
- reasons.push(reason);
829
- }
830
- }
831
- }
832
- // Weekly limit
833
- // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_WEEKLY_STRATEGY
834
- // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
835
- if (weeklyPercent !== null) {
836
- const weeklyRatio = weeklyPercent / 100;
837
- if (weeklyRatio >= QUEUE_CONFIG.thresholds.claudeWeekly.value) {
838
- const reason = formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.claudeWeekly.value, { locale });
839
- const strategy = QUEUE_CONFIG.thresholds.claudeWeekly.strategy;
840
- this.recordThrottle(weeklyRatio >= 1.0 ? 'claude_weekly_100' : `claude_weekly_${strategy}`);
841
- if (strategy === 'reject') {
842
- rejected = true;
843
- rejectReason = reason;
844
- } else if (strategy === 'dequeue-one-at-a-time') {
845
- oneAtATime = true;
846
- if (totalToolProcessing > 0) {
847
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
848
- }
849
- } else {
850
- // 'enqueue' - block unconditionally
851
- reasons.push(reason);
852
- }
853
- }
854
- }
855
- }
856
- } else if (applyCodexLimits) {
857
- const codexResult = await getCachedCodexLimits(this.verbose);
858
- if (codexResult.success) {
859
- const sessionPercent = codexResult.usage.currentSession.percentage;
860
- const weeklyPercent = codexResult.usage.allModels.percentage;
861
- if (sessionPercent !== null) {
862
- const sessionRatio = sessionPercent / 100;
863
- if (sessionRatio >= QUEUE_CONFIG.thresholds.codex5Hour.value) {
864
- const reason = formatWaitingReason('codex_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.codex5Hour.value, { locale });
865
- const strategy = QUEUE_CONFIG.thresholds.codex5Hour.strategy;
866
- this.recordThrottle(sessionRatio >= 1.0 ? 'codex_5_hour_session_100' : `codex_5_hour_session_${strategy}`);
867
- if (strategy === 'reject') {
868
- rejected = true;
869
- rejectReason = reason;
870
- } else if (strategy === 'dequeue-one-at-a-time') {
871
- oneAtATime = true;
872
- if (totalToolProcessing > 0) {
873
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
874
- }
875
- } else {
876
- reasons.push(reason);
877
- }
878
- }
879
- }
880
- if (weeklyPercent !== null) {
881
- const weeklyRatio = weeklyPercent / 100;
882
- if (weeklyRatio >= QUEUE_CONFIG.thresholds.codexWeekly.value) {
883
- const reason = formatWaitingReason('codex_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.codexWeekly.value, { locale });
884
- const strategy = QUEUE_CONFIG.thresholds.codexWeekly.strategy;
885
- this.recordThrottle(weeklyRatio >= 1.0 ? 'codex_weekly_100' : `codex_weekly_${strategy}`);
886
- if (strategy === 'reject') {
887
- rejected = true;
888
- rejectReason = reason;
889
- } else if (strategy === 'dequeue-one-at-a-time') {
890
- oneAtATime = true;
891
- if (totalToolProcessing > 0) {
892
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
893
- }
894
- } else {
895
- reasons.push(reason);
896
- }
897
- }
898
- }
899
- }
900
- } else if (this.verbose) {
901
- this.log(`Claude limits not applied for --tool ${tool}`);
902
- }
903
- // Check GitHub limits when the active tool already has a running process.
904
- // This keeps the queue behavior aligned with the existing one-at-a-time throttling model.
905
- // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_GITHUB_API_STRATEGY
906
- if (hasRunningToolProcess) {
907
- const githubResult = await getCachedGitHubLimits(this.verbose);
908
- if (githubResult.success) {
909
- const usedPercent = githubResult.githubRateLimit.usedPercentage;
910
- const usedRatio = usedPercent / 100;
911
- if (usedRatio >= QUEUE_CONFIG.thresholds.githubApi.value) {
912
- const reason = formatWaitingReason('github', usedPercent, QUEUE_CONFIG.thresholds.githubApi.value, { locale });
913
- const strategy = QUEUE_CONFIG.thresholds.githubApi.strategy;
914
- this.recordThrottle(usedRatio >= 1.0 ? 'github_100' : `github_${strategy}`);
915
- if (strategy === 'reject') {
916
- rejected = true;
917
- rejectReason = reason;
918
- } else if (strategy === 'dequeue-one-at-a-time') {
919
- oneAtATime = true;
920
- if (totalToolProcessing > 0) {
921
- reasons.push(appendWaitingForCurrentCommand(reason, locale));
922
- }
923
- } else {
924
- // 'enqueue' - block unconditionally
925
- reasons.push(reason);
926
- }
927
- }
928
- }
929
- }
930
- return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
677
+ return checkApiLimitsImpl(this, hasRunningToolProcess, toolProcessingCount, tool, options);
931
678
  }
932
679
  /**
933
680
  * Record a throttle event for statistics