@link-assistant/hive-mind 2.13.4 → 2.13.5

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.
@@ -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
+ }
@@ -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