@link-assistant/hive-mind 1.20.0 → 1.21.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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent.lib.mjs +64 -5
- package/src/github.lib.mjs +29 -4
- package/src/queue-config.lib.mjs +256 -19
- package/src/telegram-solve-queue.lib.mjs +161 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6cf54b7: Add configurable queue threshold strategies (reject, enqueue, dequeue-one-at-a-time)
|
|
8
|
+
- Add three handling strategies for each queue threshold:
|
|
9
|
+
- `reject`: Immediately reject the command, no queueing
|
|
10
|
+
- `enqueue`: Block and wait in queue until metric drops
|
|
11
|
+
- `dequeue-one-at-a-time`: Allow one command, block subsequent
|
|
12
|
+
- Support configuration via `HIVE_MIND_QUEUE_CONFIG` environment variable (links notation format)
|
|
13
|
+
- Support individual strategy env vars (e.g., `HIVE_MIND_DISK_STRATEGY`)
|
|
14
|
+
|
|
15
|
+
**Breaking change:** Disk threshold default strategy changed from `dequeue-one-at-a-time` to `reject`
|
|
16
|
+
because the queue is lost on server restart. To restore old behavior: `HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time`
|
|
17
|
+
|
|
18
|
+
## 1.20.1
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 1689caf: Fix agent tool pricing display to show correct provider
|
|
23
|
+
- Add proper model mapping for free models (kimi-k2.5-free, gpt-4o-mini, etc.)
|
|
24
|
+
- Add getProviderName helper function to detect provider from model ID
|
|
25
|
+
- Prioritize provider from model ID over API response to fix issue #1250
|
|
26
|
+
- Display correct provider names: Moonshot AI, OpenAI, Anthropic instead of generic "OpenCode Zen"
|
|
27
|
+
|
|
3
28
|
## 1.20.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -481,9 +481,9 @@ Free Models (with --tool agent):
|
|
|
481
481
|
/solve https://github.com/owner/repo/issues/123 --tool agent --model gpt-5-nano
|
|
482
482
|
/solve https://github.com/owner/repo/issues/123 --tool agent --model glm-4.7-free
|
|
483
483
|
/solve https://github.com/owner/repo/issues/123 --tool agent --model big-pickle
|
|
484
|
+
```
|
|
484
485
|
|
|
485
486
|
> **📖 Free Models Guide**: See [docs/FREE_MODELS.md](./docs/FREE_MODELS.md) for comprehensive information about all free models.
|
|
486
|
-
```
|
|
487
487
|
|
|
488
488
|
#### `/hive` - Run Hive Orchestration
|
|
489
489
|
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -79,17 +79,51 @@ export const parseAgentTokenUsage = output => {
|
|
|
79
79
|
return usage;
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Helper function to get original provider name from provider identifier
|
|
84
|
+
* Used for calculating public pricing estimates based on original provider prices
|
|
85
|
+
* @param {string} providerId - Provider identifier (e.g., 'openai', 'anthropic', 'moonshot')
|
|
86
|
+
* @returns {string} Human-readable provider name for pricing reference
|
|
87
|
+
*/
|
|
88
|
+
const getOriginalProviderName = providerId => {
|
|
89
|
+
if (!providerId) return null;
|
|
90
|
+
|
|
91
|
+
const providerMap = {
|
|
92
|
+
openai: 'OpenAI',
|
|
93
|
+
anthropic: 'Anthropic',
|
|
94
|
+
moonshot: 'Moonshot AI',
|
|
95
|
+
google: 'Google',
|
|
96
|
+
opencode: 'OpenCode Zen',
|
|
97
|
+
grok: 'xAI',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return providerMap[providerId] || providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
|
101
|
+
};
|
|
102
|
+
|
|
82
103
|
/**
|
|
83
104
|
* Calculate pricing for agent tool usage using models.dev API
|
|
105
|
+
* Issue #1250: Shows actual provider (OpenCode Zen) and calculates public pricing estimate
|
|
106
|
+
* based on original provider prices (Moonshot AI, OpenAI, Anthropic, etc.)
|
|
107
|
+
*
|
|
84
108
|
* @param {string} modelId - The model ID used (e.g., 'opencode/grok-code')
|
|
85
109
|
* @param {Object} tokenUsage - Token usage data from parseAgentTokenUsage
|
|
86
|
-
* @returns {Object} Pricing information
|
|
110
|
+
* @returns {Object} Pricing information with:
|
|
111
|
+
* - provider: Always "OpenCode Zen" (actual provider)
|
|
112
|
+
* - originalProvider: The original model provider for pricing reference
|
|
113
|
+
* - totalCostUSD: Public pricing estimate based on original provider prices
|
|
114
|
+
* - opencodeCost: Actual billed cost from OpenCode Zen (free for most models)
|
|
87
115
|
*/
|
|
88
116
|
export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
89
117
|
// Extract the model name from provider/model format
|
|
90
118
|
// e.g., 'opencode/grok-code' -> 'grok-code'
|
|
91
119
|
const modelName = modelId.includes('/') ? modelId.split('/').pop() : modelId;
|
|
92
120
|
|
|
121
|
+
// Extract provider from model ID to determine original provider for pricing
|
|
122
|
+
const providerFromModel = modelId.includes('/') ? modelId.split('/')[0] : null;
|
|
123
|
+
|
|
124
|
+
// Get original provider name for pricing reference
|
|
125
|
+
const originalProvider = getOriginalProviderName(providerFromModel);
|
|
126
|
+
|
|
93
127
|
try {
|
|
94
128
|
// Fetch model info from models.dev API
|
|
95
129
|
const modelInfo = await fetchModelInfo(modelName);
|
|
@@ -97,7 +131,7 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
97
131
|
if (modelInfo && modelInfo.cost) {
|
|
98
132
|
const cost = modelInfo.cost;
|
|
99
133
|
|
|
100
|
-
// Calculate
|
|
134
|
+
// Calculate public pricing estimate based on original provider prices
|
|
101
135
|
// Prices are per 1M tokens, so divide by 1,000,000
|
|
102
136
|
const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
|
|
103
137
|
const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
|
|
@@ -106,10 +140,17 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
106
140
|
|
|
107
141
|
const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
108
142
|
|
|
143
|
+
// Determine if this is a free model from OpenCode Zen
|
|
144
|
+
// Models accessed via OpenCode Zen are free, regardless of original provider pricing
|
|
145
|
+
const isOpencodeFreeModel = providerFromModel === 'opencode' || modelName.toLowerCase().includes('free') || modelName.toLowerCase().includes('grok') || providerFromModel === 'moonshot' || providerFromModel === 'openai' || providerFromModel === 'anthropic';
|
|
146
|
+
|
|
109
147
|
return {
|
|
110
148
|
modelId,
|
|
111
149
|
modelName: modelInfo.name || modelName,
|
|
112
|
-
|
|
150
|
+
// Issue #1250: Always show OpenCode Zen as actual provider
|
|
151
|
+
provider: 'OpenCode Zen',
|
|
152
|
+
// Store original provider for reference in pricing display
|
|
153
|
+
originalProvider: originalProvider || modelInfo.provider || null,
|
|
113
154
|
pricing: {
|
|
114
155
|
inputPerMillion: cost.input || 0,
|
|
115
156
|
outputPerMillion: cost.output || 0,
|
|
@@ -123,18 +164,26 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
123
164
|
cacheRead: cacheReadCost,
|
|
124
165
|
cacheWrite: cacheWriteCost,
|
|
125
166
|
},
|
|
167
|
+
// Public pricing estimate based on original provider prices
|
|
126
168
|
totalCostUSD: totalCost,
|
|
169
|
+
// Actual cost from OpenCode Zen (free for supported models)
|
|
170
|
+
opencodeCost: isOpencodeFreeModel ? 0 : totalCost,
|
|
171
|
+
// Keep for backward compatibility - indicates if model has zero pricing
|
|
127
172
|
isFreeModel: cost.input === 0 && cost.output === 0,
|
|
173
|
+
// New flag to indicate if OpenCode Zen provides this model for free
|
|
174
|
+
isOpencodeFreeModel,
|
|
128
175
|
};
|
|
129
176
|
}
|
|
130
|
-
|
|
131
177
|
// Model not found in API, return what we have
|
|
132
178
|
return {
|
|
133
179
|
modelId,
|
|
134
180
|
modelName,
|
|
135
|
-
provider: '
|
|
181
|
+
provider: 'OpenCode Zen',
|
|
182
|
+
originalProvider,
|
|
136
183
|
tokenUsage,
|
|
137
184
|
totalCostUSD: null,
|
|
185
|
+
opencodeCost: 0, // OpenCode Zen is free
|
|
186
|
+
isOpencodeFreeModel: true,
|
|
138
187
|
error: 'Model not found in models.dev API',
|
|
139
188
|
};
|
|
140
189
|
} catch (error) {
|
|
@@ -142,8 +191,12 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
142
191
|
return {
|
|
143
192
|
modelId,
|
|
144
193
|
modelName,
|
|
194
|
+
provider: 'OpenCode Zen',
|
|
195
|
+
originalProvider,
|
|
145
196
|
tokenUsage,
|
|
146
197
|
totalCostUSD: null,
|
|
198
|
+
opencodeCost: 0, // OpenCode Zen is free
|
|
199
|
+
isOpencodeFreeModel: true,
|
|
147
200
|
error: error.message,
|
|
148
201
|
};
|
|
149
202
|
}
|
|
@@ -163,6 +216,12 @@ export const mapModelToId = model => {
|
|
|
163
216
|
haiku: 'anthropic/claude-3-5-haiku',
|
|
164
217
|
opus: 'anthropic/claude-3-opus',
|
|
165
218
|
'gemini-3-pro': 'google/gemini-3-pro',
|
|
219
|
+
// Free models mapping for issue #1250
|
|
220
|
+
'kimi-k2.5-free': 'moonshot/kimi-k2.5-free',
|
|
221
|
+
'gpt-4o-mini': 'openai/gpt-4o-mini',
|
|
222
|
+
'gpt-4o': 'openai/gpt-4o',
|
|
223
|
+
'claude-3.5-haiku': 'anthropic/claude-3.5-haiku',
|
|
224
|
+
'claude-3.5-sonnet': 'anthropic/claude-3.5-sonnet',
|
|
166
225
|
};
|
|
167
226
|
|
|
168
227
|
// Return mapped model ID if it's an alias, otherwise return as-is
|
package/src/github.lib.mjs
CHANGED
|
@@ -22,25 +22,50 @@ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Build cost estimation string for log comments
|
|
25
|
+
* Issue #1250: Enhanced to show both public pricing estimate and actual provider cost
|
|
26
|
+
*
|
|
25
27
|
* @param {number|null} totalCostUSD - Public pricing estimate
|
|
26
28
|
* @param {number|null} anthropicTotalCostUSD - Cost calculated by Anthropic (Claude-specific)
|
|
27
29
|
* @param {Object|null} pricingInfo - Pricing info from agent tool
|
|
30
|
+
* - opencodeCost: Actual billed cost from OpenCode Zen (for agent tool)
|
|
31
|
+
* - isOpencodeFreeModel: Whether OpenCode Zen provides this model for free
|
|
32
|
+
* - originalProvider: Original provider for pricing reference
|
|
28
33
|
* @returns {string} Formatted cost info string for markdown (empty if no data available)
|
|
29
34
|
*/
|
|
30
35
|
const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
|
|
31
36
|
// Issue #1015: Don't show cost section when all values are unknown (clutters output)
|
|
32
37
|
const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
|
|
33
38
|
const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
|
|
34
|
-
const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel);
|
|
35
|
-
|
|
39
|
+
const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
|
|
40
|
+
// Issue #1250: Check for OpenCode Zen actual cost
|
|
41
|
+
const hasOpencodeCost = pricingInfo?.opencodeCost !== null && pricingInfo?.opencodeCost !== undefined;
|
|
42
|
+
if (!hasPublic && !hasAnthropic && !hasPricing && !hasOpencodeCost) return '';
|
|
36
43
|
let costInfo = '\n\n💰 **Cost estimation:**';
|
|
37
44
|
if (pricingInfo?.modelName) {
|
|
38
45
|
costInfo += `\n- Model: ${pricingInfo.modelName}`;
|
|
39
46
|
if (pricingInfo.provider) costInfo += `\n- Provider: ${pricingInfo.provider}`;
|
|
40
47
|
}
|
|
48
|
+
// Issue #1250: Show public pricing estimate based on original provider prices
|
|
41
49
|
if (hasPublic) {
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
// For models with zero pricing from original provider, show as free
|
|
51
|
+
if (pricingInfo?.isFreeModel && totalCostUSD === 0) {
|
|
52
|
+
costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
|
|
53
|
+
} else {
|
|
54
|
+
// Show actual public pricing estimate with original provider reference
|
|
55
|
+
const originalProviderRef = pricingInfo?.originalProvider ? ` (based on ${pricingInfo.originalProvider} prices)` : '';
|
|
56
|
+
costInfo += `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)}${originalProviderRef}`;
|
|
57
|
+
}
|
|
58
|
+
} else if (hasPricing) {
|
|
59
|
+
costInfo += '\n- Public pricing estimate: unknown';
|
|
60
|
+
}
|
|
61
|
+
// Issue #1250: Show actual cost from OpenCode Zen for agent tool
|
|
62
|
+
if (hasOpencodeCost) {
|
|
63
|
+
if (pricingInfo.isOpencodeFreeModel) {
|
|
64
|
+
costInfo += '\n- Calculated by OpenCode Zen: $0.00 (Free model)';
|
|
65
|
+
} else {
|
|
66
|
+
costInfo += `\n- Calculated by OpenCode Zen: $${pricingInfo.opencodeCost.toFixed(6)}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
44
69
|
if (pricingInfo?.tokenUsage) {
|
|
45
70
|
const u = pricingInfo.tokenUsage;
|
|
46
71
|
let tokenInfo = `\n- Token usage: ${u.inputTokens?.toLocaleString() || 0} input, ${u.outputTokens?.toLocaleString() || 0} output`;
|
package/src/queue-config.lib.mjs
CHANGED
|
@@ -7,7 +7,20 @@
|
|
|
7
7
|
* This module is used by both telegram-solve-queue.lib.mjs (queue logic)
|
|
8
8
|
* and limits.lib.mjs (display formatting).
|
|
9
9
|
*
|
|
10
|
+
* Supports three handling strategies per threshold:
|
|
11
|
+
* - 'reject': Immediately reject the command, no queueing
|
|
12
|
+
* - 'enqueue': Block and wait in queue until metric drops below threshold
|
|
13
|
+
* - 'dequeue-one-at-a-time': Allow exactly one command, block subsequent
|
|
14
|
+
*
|
|
15
|
+
* Configuration can be provided via:
|
|
16
|
+
* 1. HIVE_MIND_QUEUE_CONFIG environment variable (links notation format)
|
|
17
|
+
* 2. Individual environment variables (e.g., HIVE_MIND_DISK_THRESHOLD)
|
|
18
|
+
* 3. Built-in defaults
|
|
19
|
+
*
|
|
20
|
+
* Priority: HIVE_MIND_QUEUE_CONFIG > individual env vars > defaults
|
|
21
|
+
*
|
|
10
22
|
* @see https://github.com/link-assistant/hive-mind/issues/1242
|
|
23
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1253
|
|
11
24
|
*/
|
|
12
25
|
|
|
13
26
|
// Use use-m to dynamically import modules
|
|
@@ -24,6 +37,137 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
const getenv = await use('getenv');
|
|
40
|
+
const linoModule = await use('links-notation');
|
|
41
|
+
const LinoParser = linoModule.Parser || linoModule.default?.Parser;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Valid threshold handling strategies
|
|
45
|
+
* @type {readonly ['reject', 'enqueue', 'dequeue-one-at-a-time']}
|
|
46
|
+
*/
|
|
47
|
+
export const THRESHOLD_STRATEGIES = Object.freeze(['reject', 'enqueue', 'dequeue-one-at-a-time']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a threshold strategy value
|
|
51
|
+
* @param {string} strategy - The strategy to validate
|
|
52
|
+
* @param {string} defaultStrategy - Default strategy if invalid
|
|
53
|
+
* @returns {string} Valid strategy
|
|
54
|
+
*/
|
|
55
|
+
function validateStrategy(strategy, defaultStrategy = 'enqueue') {
|
|
56
|
+
if (!strategy || !THRESHOLD_STRATEGIES.includes(strategy)) {
|
|
57
|
+
return defaultStrategy;
|
|
58
|
+
}
|
|
59
|
+
return strategy;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize metric name from links notation format to camelCase
|
|
64
|
+
* Examples: 'disk' -> 'disk', 'ram' -> 'ram', 'claude-5-hour' -> 'claude5Hour'
|
|
65
|
+
* @param {string} name - Metric name in kebab-case
|
|
66
|
+
* @returns {string} Metric name in normalized form
|
|
67
|
+
*/
|
|
68
|
+
function normalizeMetricName(name) {
|
|
69
|
+
if (!name) return '';
|
|
70
|
+
return name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse queue configuration from links notation
|
|
75
|
+
*
|
|
76
|
+
* Format:
|
|
77
|
+
* ```
|
|
78
|
+
* (
|
|
79
|
+
* (disk (90% reject))
|
|
80
|
+
* (ram (65% enqueue))
|
|
81
|
+
* (cpu (65% enqueue))
|
|
82
|
+
* (claude-5-hour (65% dequeue-one-at-a-time))
|
|
83
|
+
* (claude-weekly (97% dequeue-one-at-a-time))
|
|
84
|
+
* (github-api (75% enqueue))
|
|
85
|
+
* )
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @param {string} linoConfig - Configuration in links notation format
|
|
89
|
+
* @returns {Object} Parsed threshold configurations { [metricName]: { value: number, strategy: string } }
|
|
90
|
+
*/
|
|
91
|
+
export function parseQueueConfig(linoConfig) {
|
|
92
|
+
if (!linoConfig || typeof linoConfig !== 'string') return {};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const parser = new LinoParser();
|
|
96
|
+
const parsed = parser.parse(linoConfig);
|
|
97
|
+
|
|
98
|
+
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) return {};
|
|
99
|
+
|
|
100
|
+
const config = {};
|
|
101
|
+
|
|
102
|
+
// The parser returns: [{ id: null, values: [...] }]
|
|
103
|
+
// We need to drill down to find the metric configurations
|
|
104
|
+
const topLink = parsed[0];
|
|
105
|
+
if (!topLink || !topLink.values) return {};
|
|
106
|
+
|
|
107
|
+
// Helper to extract all ids from a values array recursively
|
|
108
|
+
function extractIds(values) {
|
|
109
|
+
const ids = [];
|
|
110
|
+
if (!values) return ids;
|
|
111
|
+
for (const v of values) {
|
|
112
|
+
if (v.id) ids.push(v.id);
|
|
113
|
+
if (v.values && v.values.length > 0) {
|
|
114
|
+
ids.push(...extractIds(v.values));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return ids;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Process each item in top-level values
|
|
121
|
+
// Structure can be:
|
|
122
|
+
// - Nested: [{ id: null, values: [{ id: 'disk', ... }, { id: null, values: [{ id: '90%' }, { id: 'reject' }] }] }]
|
|
123
|
+
// - Flat: [{ id: 'disk', ... }, { id: '90%', ... }, { id: 'reject', ... }]
|
|
124
|
+
for (const item of topLink.values) {
|
|
125
|
+
// Check if this is a nested config item (no id at this level)
|
|
126
|
+
if (item.id === null && item.values && item.values.length > 0) {
|
|
127
|
+
// Extract all IDs from this nested structure
|
|
128
|
+
const ids = extractIds(item.values);
|
|
129
|
+
|
|
130
|
+
// Find metric name, percentage, and strategy
|
|
131
|
+
let metricName = null;
|
|
132
|
+
let thresholdValue = null;
|
|
133
|
+
let strategy = null;
|
|
134
|
+
|
|
135
|
+
for (const id of ids) {
|
|
136
|
+
// Check for percentage
|
|
137
|
+
const percentMatch = id.match(/^(\d+)%$/);
|
|
138
|
+
if (percentMatch) {
|
|
139
|
+
thresholdValue = parseInt(percentMatch[1], 10) / 100;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check for strategy
|
|
144
|
+
if (THRESHOLD_STRATEGIES.includes(id)) {
|
|
145
|
+
strategy = id;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Otherwise it's likely the metric name
|
|
150
|
+
if (!metricName) {
|
|
151
|
+
metricName = id;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (metricName && thresholdValue !== null) {
|
|
156
|
+
const normalized = normalizeMetricName(metricName);
|
|
157
|
+
config[normalized] = {
|
|
158
|
+
value: thresholdValue,
|
|
159
|
+
strategy: validateStrategy(strategy),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return config;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('[queue-config] Failed to parse HIVE_MIND_QUEUE_CONFIG:', error.message);
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
27
171
|
|
|
28
172
|
// Helper function to safely parse floats with fallback
|
|
29
173
|
const parseFloatWithDefault = (envVar, defaultValue) => {
|
|
@@ -39,6 +183,38 @@ const parseIntWithDefault = (envVar, defaultValue) => {
|
|
|
39
183
|
return isNaN(parsed) ? defaultValue : parsed;
|
|
40
184
|
};
|
|
41
185
|
|
|
186
|
+
// Parse links notation config from environment variable (if provided)
|
|
187
|
+
const linoConfig = parseQueueConfig(getenv('HIVE_MIND_QUEUE_CONFIG', ''));
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Get threshold configuration with priority:
|
|
191
|
+
* 1. HIVE_MIND_QUEUE_CONFIG (links notation) - highest priority
|
|
192
|
+
* 2. Individual environment variables
|
|
193
|
+
* 3. Default values
|
|
194
|
+
*
|
|
195
|
+
* @param {string} linoKey - Key in normalized format for lino config (e.g., 'disk', 'ram')
|
|
196
|
+
* @param {string} envVarThreshold - Environment variable for threshold value
|
|
197
|
+
* @param {string} envVarStrategy - Environment variable for strategy
|
|
198
|
+
* @param {number} defaultThreshold - Default threshold value (0.0 - 1.0)
|
|
199
|
+
* @param {string} defaultStrategy - Default strategy
|
|
200
|
+
* @returns {{ value: number, strategy: string }}
|
|
201
|
+
*/
|
|
202
|
+
function getThresholdConfig(linoKey, envVarThreshold, envVarStrategy, defaultThreshold, defaultStrategy) {
|
|
203
|
+
// Check links notation config first
|
|
204
|
+
if (linoConfig[linoKey]) {
|
|
205
|
+
return {
|
|
206
|
+
value: linoConfig[linoKey].value,
|
|
207
|
+
strategy: linoConfig[linoKey].strategy,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Fall back to individual env vars, then defaults
|
|
212
|
+
return {
|
|
213
|
+
value: parseFloatWithDefault(envVarThreshold, defaultThreshold),
|
|
214
|
+
strategy: validateStrategy(getenv(envVarStrategy, ''), defaultStrategy),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
42
218
|
/**
|
|
43
219
|
* Configuration constants for queue throttling
|
|
44
220
|
* All thresholds use ratios (0.0 - 1.0) representing usage percentage
|
|
@@ -46,21 +222,39 @@ const parseIntWithDefault = (envVar, defaultValue) => {
|
|
|
46
222
|
* IMPORTANT: Running claude processes is NOT a blocking limit by itself.
|
|
47
223
|
* Commands can run in parallel as long as actual limits (CPU, API, etc.) are not exceeded.
|
|
48
224
|
* See: https://github.com/link-assistant/hive-mind/issues/1078
|
|
225
|
+
*
|
|
226
|
+
* NEW in issue #1253: Each threshold now has a configurable strategy:
|
|
227
|
+
* - 'reject': Immediately reject the command (no queueing)
|
|
228
|
+
* - 'enqueue': Block and wait in queue
|
|
229
|
+
* - 'dequeue-one-at-a-time': Allow one command, block subsequent
|
|
230
|
+
*
|
|
231
|
+
* BREAKING CHANGE: Disk threshold default strategy changed from 'dequeue-one-at-a-time' to 'reject'
|
|
232
|
+
* because the queue is lost on server restart anyway, so there's no point in queueing.
|
|
233
|
+
* To restore old behavior: HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time
|
|
49
234
|
*/
|
|
50
235
|
export const QUEUE_CONFIG = {
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
236
|
+
// Threshold configurations with value and strategy
|
|
237
|
+
// Priority: HIVE_MIND_QUEUE_CONFIG > individual env vars > defaults
|
|
238
|
+
thresholds: {
|
|
239
|
+
ram: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue'),
|
|
240
|
+
cpu: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue'),
|
|
241
|
+
// BREAKING: disk default changed from 'dequeue-one-at-a-time' to 'reject'
|
|
242
|
+
// Queue is in RAM and lost on restart - no point enlarging it when disk is full
|
|
243
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1253
|
|
244
|
+
disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject'),
|
|
245
|
+
claude5Hour: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
|
|
246
|
+
claudeWeekly: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time'),
|
|
247
|
+
githubApi: getThresholdConfig('githubApi', 'HIVE_MIND_GITHUB_API_THRESHOLD', 'HIVE_MIND_GITHUB_API_STRATEGY', 0.75, 'enqueue'),
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// Legacy flat threshold values for backward compatibility
|
|
251
|
+
// These are derived from thresholds.{metric}.value
|
|
252
|
+
RAM_THRESHOLD: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue').value,
|
|
253
|
+
CPU_THRESHOLD: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue').value,
|
|
254
|
+
DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject').value,
|
|
255
|
+
CLAUDE_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
|
|
256
|
+
CLAUDE_WEEKLY_THRESHOLD: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time').value,
|
|
257
|
+
GITHUB_API_THRESHOLD: getThresholdConfig('githubApi', 'HIVE_MIND_GITHUB_API_THRESHOLD', 'HIVE_MIND_GITHUB_API_STRATEGY', 0.75, 'enqueue').value,
|
|
64
258
|
|
|
65
259
|
// Timing
|
|
66
260
|
// MIN_START_INTERVAL_MS: Time to allow solve command to start actual claude process
|
|
@@ -89,16 +283,59 @@ export function thresholdToPercent(ratio) {
|
|
|
89
283
|
* @see https://github.com/link-assistant/hive-mind/issues/1242
|
|
90
284
|
*/
|
|
91
285
|
export const DISPLAY_THRESHOLDS = {
|
|
92
|
-
RAM: thresholdToPercent(QUEUE_CONFIG.RAM_THRESHOLD),
|
|
93
|
-
CPU: thresholdToPercent(QUEUE_CONFIG.CPU_THRESHOLD),
|
|
94
|
-
DISK: thresholdToPercent(QUEUE_CONFIG.DISK_THRESHOLD),
|
|
95
|
-
CLAUDE_5_HOUR_SESSION: thresholdToPercent(QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD),
|
|
96
|
-
CLAUDE_WEEKLY: thresholdToPercent(QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD),
|
|
97
|
-
GITHUB_API: thresholdToPercent(QUEUE_CONFIG.GITHUB_API_THRESHOLD),
|
|
286
|
+
RAM: thresholdToPercent(QUEUE_CONFIG.RAM_THRESHOLD),
|
|
287
|
+
CPU: thresholdToPercent(QUEUE_CONFIG.CPU_THRESHOLD),
|
|
288
|
+
DISK: thresholdToPercent(QUEUE_CONFIG.DISK_THRESHOLD),
|
|
289
|
+
CLAUDE_5_HOUR_SESSION: thresholdToPercent(QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD),
|
|
290
|
+
CLAUDE_WEEKLY: thresholdToPercent(QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD),
|
|
291
|
+
GITHUB_API: thresholdToPercent(QUEUE_CONFIG.GITHUB_API_THRESHOLD),
|
|
98
292
|
};
|
|
99
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Get strategy for a specific metric
|
|
296
|
+
* @param {string} metric - Metric name (ram, cpu, disk, claude5Hour, claudeWeekly, githubApi)
|
|
297
|
+
* @returns {string} Strategy ('reject', 'enqueue', 'dequeue-one-at-a-time')
|
|
298
|
+
*/
|
|
299
|
+
export function getStrategy(metric) {
|
|
300
|
+
const threshold = QUEUE_CONFIG.thresholds[metric];
|
|
301
|
+
return threshold ? threshold.strategy : 'enqueue';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Check if a metric uses the reject strategy
|
|
306
|
+
* @param {string} metric - Metric name
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
export function isRejectStrategy(metric) {
|
|
310
|
+
return getStrategy(metric) === 'reject';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Check if a metric uses the enqueue strategy
|
|
315
|
+
* @param {string} metric - Metric name
|
|
316
|
+
* @returns {boolean}
|
|
317
|
+
*/
|
|
318
|
+
export function isEnqueueStrategy(metric) {
|
|
319
|
+
return getStrategy(metric) === 'enqueue';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Check if a metric uses the dequeue-one-at-a-time strategy
|
|
324
|
+
* @param {string} metric - Metric name
|
|
325
|
+
* @returns {boolean}
|
|
326
|
+
*/
|
|
327
|
+
export function isOneAtATimeStrategy(metric) {
|
|
328
|
+
return getStrategy(metric) === 'dequeue-one-at-a-time';
|
|
329
|
+
}
|
|
330
|
+
|
|
100
331
|
export default {
|
|
101
332
|
QUEUE_CONFIG,
|
|
102
333
|
DISPLAY_THRESHOLDS,
|
|
334
|
+
THRESHOLD_STRATEGIES,
|
|
103
335
|
thresholdToPercent,
|
|
336
|
+
parseQueueConfig,
|
|
337
|
+
getStrategy,
|
|
338
|
+
isRejectStrategy,
|
|
339
|
+
isEnqueueStrategy,
|
|
340
|
+
isOneAtATimeStrategy,
|
|
104
341
|
};
|
|
@@ -26,7 +26,8 @@ import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getC
|
|
|
26
26
|
// Import centralized queue configuration
|
|
27
27
|
// This ensures thresholds are consistent between queue logic and display formatting
|
|
28
28
|
// See: https://github.com/link-assistant/hive-mind/issues/1242
|
|
29
|
-
|
|
29
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1253 (configurable strategies)
|
|
30
|
+
export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
|
|
30
31
|
import { QUEUE_CONFIG } from './queue-config.lib.mjs';
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -531,14 +532,22 @@ export class SolveQueue {
|
|
|
531
532
|
* - Processing count for Claude limits only includes Claude items, not agent items.
|
|
532
533
|
* - This allows agent tasks to run in parallel when Claude limits are reached.
|
|
533
534
|
*
|
|
535
|
+
* Logic per issue #1253:
|
|
536
|
+
* - All thresholds now support configurable strategies (reject, enqueue, dequeue-one-at-a-time)
|
|
537
|
+
* - 'reject' strategy immediately rejects the command without queueing
|
|
538
|
+
* - 'enqueue' blocks and waits in queue until metric drops
|
|
539
|
+
* - 'dequeue-one-at-a-time' allows one command while blocking subsequent
|
|
540
|
+
*
|
|
534
541
|
* @param {Object} options - Options for the check
|
|
535
542
|
* @param {string} options.tool - The tool being used ('claude', 'agent', etc.)
|
|
536
|
-
* @returns {Promise<{canStart: boolean, reason?: string, reasons?: string[], oneAtATime?: boolean}>}
|
|
543
|
+
* @returns {Promise<{canStart: boolean, rejected?: boolean, rejectReason?: string, reason?: string, reasons?: string[], oneAtATime?: boolean}>}
|
|
537
544
|
*/
|
|
538
545
|
async canStartCommand(options = {}) {
|
|
539
546
|
const tool = options.tool || 'claude';
|
|
540
547
|
const reasons = [];
|
|
541
548
|
let oneAtATime = false;
|
|
549
|
+
let rejected = false;
|
|
550
|
+
let rejectReason = null;
|
|
542
551
|
|
|
543
552
|
// Check minimum interval since last start FOR THIS TOOL
|
|
544
553
|
// Each tool queue has independent timing to prevent cross-blocking
|
|
@@ -572,23 +581,33 @@ export class SolveQueue {
|
|
|
572
581
|
this.recordThrottle('claude_running');
|
|
573
582
|
}
|
|
574
583
|
|
|
575
|
-
// Check system resources
|
|
584
|
+
// Check system resources with strategy support
|
|
576
585
|
// System resources apply to ALL tools, not just Claude
|
|
577
586
|
// See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
587
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
|
|
578
588
|
const resourceCheck = await this.checkSystemResources(totalProcessing);
|
|
579
|
-
if (
|
|
589
|
+
if (resourceCheck.rejected) {
|
|
590
|
+
rejected = true;
|
|
591
|
+
rejectReason = resourceCheck.rejectReason;
|
|
592
|
+
}
|
|
593
|
+
if (!resourceCheck.ok && !resourceCheck.rejected) {
|
|
580
594
|
reasons.push(...resourceCheck.reasons);
|
|
581
595
|
}
|
|
582
596
|
if (resourceCheck.oneAtATime) {
|
|
583
597
|
oneAtATime = true;
|
|
584
598
|
}
|
|
585
599
|
|
|
586
|
-
// Check API limits (pass hasRunningClaude, claudeProcessingCount, and tool)
|
|
600
|
+
// Check API limits with strategy support (pass hasRunningClaude, claudeProcessingCount, and tool)
|
|
587
601
|
// Claude limits use claudeProcessingCount (only Claude items), not totalProcessing
|
|
588
602
|
// This allows agent tasks to proceed when Claude limits are reached
|
|
589
603
|
// See: https://github.com/link-assistant/hive-mind/issues/1159
|
|
604
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
|
|
590
605
|
const limitCheck = await this.checkApiLimits(hasRunningClaude, claudeProcessingCount, tool);
|
|
591
|
-
if (
|
|
606
|
+
if (limitCheck.rejected) {
|
|
607
|
+
rejected = true;
|
|
608
|
+
rejectReason = limitCheck.rejectReason;
|
|
609
|
+
}
|
|
610
|
+
if (!limitCheck.ok && !limitCheck.rejected) {
|
|
592
611
|
reasons.push(...limitCheck.reasons);
|
|
593
612
|
}
|
|
594
613
|
if (limitCheck.oneAtATime) {
|
|
@@ -604,14 +623,20 @@ export class SolveQueue {
|
|
|
604
623
|
reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
|
|
605
624
|
}
|
|
606
625
|
|
|
607
|
-
const canStart = reasons.length === 0;
|
|
626
|
+
const canStart = reasons.length === 0 && !rejected;
|
|
608
627
|
|
|
609
628
|
if (!canStart && this.verbose) {
|
|
610
|
-
|
|
629
|
+
if (rejected) {
|
|
630
|
+
this.log(`Rejected: ${rejectReason}`);
|
|
631
|
+
} else {
|
|
632
|
+
this.log(`Cannot start: ${reasons.join(', ')}`);
|
|
633
|
+
}
|
|
611
634
|
}
|
|
612
635
|
|
|
613
636
|
return {
|
|
614
637
|
canStart,
|
|
638
|
+
rejected,
|
|
639
|
+
rejectReason,
|
|
615
640
|
reason: reasons.length > 0 ? reasons.join('\n') : undefined,
|
|
616
641
|
reasons,
|
|
617
642
|
oneAtATime,
|
|
@@ -628,33 +653,53 @@ export class SolveQueue {
|
|
|
628
653
|
* This provides a more stable metric that isn't affected by brief spikes
|
|
629
654
|
* during claude process startup.
|
|
630
655
|
*
|
|
631
|
-
* Resource threshold modes:
|
|
632
|
-
* -
|
|
633
|
-
* -
|
|
634
|
-
* -
|
|
656
|
+
* Resource threshold modes are now configurable via HIVE_MIND_QUEUE_CONFIG:
|
|
657
|
+
* - 'reject': Immediately reject the command, no queueing
|
|
658
|
+
* - 'enqueue': Block all commands unconditionally until metric drops
|
|
659
|
+
* - 'dequeue-one-at-a-time': Allow one command when above threshold
|
|
660
|
+
*
|
|
661
|
+
* Default strategies:
|
|
662
|
+
* - RAM: enqueue
|
|
663
|
+
* - CPU: enqueue
|
|
664
|
+
* - DISK: reject (changed from dequeue-one-at-a-time - queue lost on restart)
|
|
635
665
|
*
|
|
636
666
|
* See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
667
|
+
* See: https://github.com/link-assistant/hive-mind/issues/1253
|
|
637
668
|
*
|
|
638
669
|
* @param {number} totalProcessing - Total processing count (queue + external claude processes)
|
|
639
|
-
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
|
|
670
|
+
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
|
|
640
671
|
*/
|
|
641
672
|
async checkSystemResources(totalProcessing = 0) {
|
|
642
673
|
const reasons = [];
|
|
643
674
|
let oneAtATime = false;
|
|
675
|
+
let rejected = false;
|
|
676
|
+
let rejectReason = null;
|
|
644
677
|
|
|
645
678
|
// Check RAM (using cached value)
|
|
646
|
-
// Enqueue mode: blocks all commands unconditionally
|
|
647
679
|
const memResult = await getCachedMemoryInfo(this.verbose);
|
|
648
680
|
if (memResult.success) {
|
|
649
681
|
const usedRatio = memResult.memory.usedPercentage / 100;
|
|
650
|
-
if (usedRatio >= QUEUE_CONFIG.
|
|
651
|
-
|
|
652
|
-
|
|
682
|
+
if (usedRatio >= QUEUE_CONFIG.thresholds.ram.value) {
|
|
683
|
+
const reason = formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.thresholds.ram.value);
|
|
684
|
+
const strategy = QUEUE_CONFIG.thresholds.ram.strategy;
|
|
685
|
+
this.recordThrottle(`ram_${strategy}`);
|
|
686
|
+
|
|
687
|
+
if (strategy === 'reject') {
|
|
688
|
+
rejected = true;
|
|
689
|
+
rejectReason = reason;
|
|
690
|
+
} else if (strategy === 'dequeue-one-at-a-time') {
|
|
691
|
+
oneAtATime = true;
|
|
692
|
+
if (totalProcessing > 0) {
|
|
693
|
+
reasons.push(reason + ' (waiting for current command)');
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// 'enqueue' - block unconditionally
|
|
697
|
+
reasons.push(reason);
|
|
698
|
+
}
|
|
653
699
|
}
|
|
654
700
|
}
|
|
655
701
|
|
|
656
702
|
// Check CPU using 5-minute load average (more stable than 1-minute)
|
|
657
|
-
// Enqueue mode: blocks all commands unconditionally
|
|
658
703
|
// Cache TTL is 2 minutes, which is appropriate for this metric
|
|
659
704
|
const cpuResult = await getCachedCpuInfo(this.verbose);
|
|
660
705
|
if (cpuResult.success) {
|
|
@@ -671,33 +716,55 @@ export class SolveQueue {
|
|
|
671
716
|
this.log(`CPU 5m load avg: ${loadAvg5.toFixed(2)}, cpus: ${cpuCount}, usage: ${usagePercent}%`);
|
|
672
717
|
}
|
|
673
718
|
|
|
674
|
-
if (usageRatio >= QUEUE_CONFIG.
|
|
675
|
-
|
|
676
|
-
|
|
719
|
+
if (usageRatio >= QUEUE_CONFIG.thresholds.cpu.value) {
|
|
720
|
+
const reason = formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.thresholds.cpu.value);
|
|
721
|
+
const strategy = QUEUE_CONFIG.thresholds.cpu.strategy;
|
|
722
|
+
this.recordThrottle(`cpu_${strategy}`);
|
|
723
|
+
|
|
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(reason + ' (waiting for current command)');
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
// 'enqueue' - block unconditionally
|
|
734
|
+
reasons.push(reason);
|
|
735
|
+
}
|
|
677
736
|
}
|
|
678
737
|
}
|
|
679
738
|
|
|
680
739
|
// Check disk space (using cached value)
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
// because we cannot predict how much disk space a task will use
|
|
684
|
-
// See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
740
|
+
// Default strategy changed to 'reject' because queue is lost on restart anyway
|
|
741
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1253
|
|
685
742
|
const diskResult = await getCachedDiskInfo(this.verbose);
|
|
686
743
|
if (diskResult.success) {
|
|
687
744
|
// Calculate usage from free percentage
|
|
688
745
|
const usedPercent = 100 - diskResult.diskSpace.freePercentage;
|
|
689
746
|
const usedRatio = usedPercent / 100;
|
|
690
|
-
if (usedRatio >= QUEUE_CONFIG.
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
747
|
+
if (usedRatio >= QUEUE_CONFIG.thresholds.disk.value) {
|
|
748
|
+
const reason = formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.thresholds.disk.value);
|
|
749
|
+
const strategy = QUEUE_CONFIG.thresholds.disk.strategy;
|
|
750
|
+
this.recordThrottle(`disk_${strategy}`);
|
|
751
|
+
|
|
752
|
+
if (strategy === 'reject') {
|
|
753
|
+
rejected = true;
|
|
754
|
+
rejectReason = reason;
|
|
755
|
+
} else if (strategy === 'dequeue-one-at-a-time') {
|
|
756
|
+
oneAtATime = true;
|
|
757
|
+
if (totalProcessing > 0) {
|
|
758
|
+
reasons.push(reason + ' (waiting for current command)');
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
// 'enqueue' - block unconditionally
|
|
762
|
+
reasons.push(reason);
|
|
696
763
|
}
|
|
697
764
|
}
|
|
698
765
|
}
|
|
699
766
|
|
|
700
|
-
return { ok: reasons.length === 0, reasons, oneAtATime };
|
|
767
|
+
return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
|
|
701
768
|
}
|
|
702
769
|
|
|
703
770
|
/**
|
|
@@ -714,14 +781,20 @@ export class SolveQueue {
|
|
|
714
781
|
* - For Claude limits, only count Claude-specific processing items, not agent items.
|
|
715
782
|
* This allows agent tasks to run in parallel even when Claude limits are reached.
|
|
716
783
|
*
|
|
784
|
+
* Logic per issue #1253:
|
|
785
|
+
* - All thresholds now support configurable strategies (reject, enqueue, dequeue-one-at-a-time)
|
|
786
|
+
* - Configuration via HIVE_MIND_QUEUE_CONFIG or individual env vars
|
|
787
|
+
*
|
|
717
788
|
* @param {boolean} hasRunningClaude - Whether claude processes are running (from pgrep)
|
|
718
789
|
* @param {number} claudeProcessingCount - Count of 'claude' tool items being processed in queue
|
|
719
790
|
* @param {string} tool - The tool being used ('claude', 'agent', etc.)
|
|
720
|
-
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
|
|
791
|
+
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
|
|
721
792
|
*/
|
|
722
793
|
async checkApiLimits(hasRunningClaude = false, claudeProcessingCount = 0, tool = 'claude') {
|
|
723
794
|
const reasons = [];
|
|
724
795
|
let oneAtATime = false;
|
|
796
|
+
let rejected = false;
|
|
797
|
+
let rejectReason = null;
|
|
725
798
|
|
|
726
799
|
// Apply Claude-specific limits only when tool is 'claude'
|
|
727
800
|
// Other tools (like 'agent') use different rate limiting backends and are not
|
|
@@ -744,32 +817,51 @@ export class SolveQueue {
|
|
|
744
817
|
const weeklyPercent = claudeResult.usage.allModels.percentage;
|
|
745
818
|
|
|
746
819
|
// Session limit (5-hour)
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// See: https://github.com/link-assistant/hive-mind/issues/1133, #1159
|
|
820
|
+
// Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY
|
|
821
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
|
|
750
822
|
if (sessionPercent !== null) {
|
|
751
823
|
const sessionRatio = sessionPercent / 100;
|
|
752
|
-
if (sessionRatio >= QUEUE_CONFIG.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
824
|
+
if (sessionRatio >= QUEUE_CONFIG.thresholds.claude5Hour.value) {
|
|
825
|
+
const reason = formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.claude5Hour.value);
|
|
826
|
+
const strategy = QUEUE_CONFIG.thresholds.claude5Hour.strategy;
|
|
827
|
+
this.recordThrottle(sessionRatio >= 1.0 ? 'claude_5_hour_session_100' : `claude_5_hour_session_${strategy}`);
|
|
828
|
+
|
|
829
|
+
if (strategy === 'reject') {
|
|
830
|
+
rejected = true;
|
|
831
|
+
rejectReason = reason;
|
|
832
|
+
} else if (strategy === 'dequeue-one-at-a-time') {
|
|
833
|
+
oneAtATime = true;
|
|
834
|
+
if (totalClaudeProcessing > 0) {
|
|
835
|
+
reasons.push(reason + ' (waiting for current command)');
|
|
836
|
+
}
|
|
837
|
+
} else {
|
|
838
|
+
// 'enqueue' - block unconditionally
|
|
839
|
+
reasons.push(reason);
|
|
758
840
|
}
|
|
759
841
|
}
|
|
760
842
|
}
|
|
761
843
|
|
|
762
844
|
// Weekly limit
|
|
763
|
-
//
|
|
845
|
+
// Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_WEEKLY_STRATEGY
|
|
846
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
|
|
764
847
|
if (weeklyPercent !== null) {
|
|
765
848
|
const weeklyRatio = weeklyPercent / 100;
|
|
766
|
-
if (weeklyRatio >= QUEUE_CONFIG.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
|
|
849
|
+
if (weeklyRatio >= QUEUE_CONFIG.thresholds.claudeWeekly.value) {
|
|
850
|
+
const reason = formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.claudeWeekly.value);
|
|
851
|
+
const strategy = QUEUE_CONFIG.thresholds.claudeWeekly.strategy;
|
|
852
|
+
this.recordThrottle(weeklyRatio >= 1.0 ? 'claude_weekly_100' : `claude_weekly_${strategy}`);
|
|
853
|
+
|
|
854
|
+
if (strategy === 'reject') {
|
|
855
|
+
rejected = true;
|
|
856
|
+
rejectReason = reason;
|
|
857
|
+
} else if (strategy === 'dequeue-one-at-a-time') {
|
|
858
|
+
oneAtATime = true;
|
|
859
|
+
if (totalClaudeProcessing > 0) {
|
|
860
|
+
reasons.push(reason + ' (waiting for current command)');
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
// 'enqueue' - block unconditionally
|
|
864
|
+
reasons.push(reason);
|
|
773
865
|
}
|
|
774
866
|
}
|
|
775
867
|
}
|
|
@@ -779,19 +871,34 @@ export class SolveQueue {
|
|
|
779
871
|
}
|
|
780
872
|
|
|
781
873
|
// Check GitHub limits (only relevant if claude processes running)
|
|
874
|
+
// Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_GITHUB_API_STRATEGY
|
|
782
875
|
if (hasRunningClaude) {
|
|
783
876
|
const githubResult = await getCachedGitHubLimits(this.verbose);
|
|
784
877
|
if (githubResult.success) {
|
|
785
878
|
const usedPercent = githubResult.githubRateLimit.usedPercentage;
|
|
786
879
|
const usedRatio = usedPercent / 100;
|
|
787
|
-
if (usedRatio >= QUEUE_CONFIG.
|
|
788
|
-
|
|
789
|
-
|
|
880
|
+
if (usedRatio >= QUEUE_CONFIG.thresholds.githubApi.value) {
|
|
881
|
+
const reason = formatWaitingReason('github', usedPercent, QUEUE_CONFIG.thresholds.githubApi.value);
|
|
882
|
+
const strategy = QUEUE_CONFIG.thresholds.githubApi.strategy;
|
|
883
|
+
this.recordThrottle(usedRatio >= 1.0 ? 'github_100' : `github_${strategy}`);
|
|
884
|
+
|
|
885
|
+
if (strategy === 'reject') {
|
|
886
|
+
rejected = true;
|
|
887
|
+
rejectReason = reason;
|
|
888
|
+
} else if (strategy === 'dequeue-one-at-a-time') {
|
|
889
|
+
oneAtATime = true;
|
|
890
|
+
if (totalClaudeProcessing > 0) {
|
|
891
|
+
reasons.push(reason + ' (waiting for current command)');
|
|
892
|
+
}
|
|
893
|
+
} else {
|
|
894
|
+
// 'enqueue' - block unconditionally
|
|
895
|
+
reasons.push(reason);
|
|
896
|
+
}
|
|
790
897
|
}
|
|
791
898
|
}
|
|
792
899
|
}
|
|
793
900
|
|
|
794
|
-
return { ok: reasons.length === 0, reasons, oneAtATime };
|
|
901
|
+
return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
|
|
795
902
|
}
|
|
796
903
|
|
|
797
904
|
/**
|