@link-assistant/hive-mind 1.20.1 → 1.21.1
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 +27 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +155 -27
- package/src/github.lib.mjs +12 -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,32 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.21.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fbfc0c3: Fix `--tool agent` pricing display for free models (Issue #1250)
|
|
8
|
+
- Add base model pricing lookup for free model variants (e.g., `kimi-k2.5-free` → `kimi-k2.5`)
|
|
9
|
+
- Show actual market price as "Public pricing estimate" based on the underlying paid model
|
|
10
|
+
- Display base model reference in cost output: "(based on Moonshot AI kimi-k2.5 prices)"
|
|
11
|
+
- Distinguish between truly free models and free access to paid models
|
|
12
|
+
- Fix token usage showing "0 input, 0 output" by accumulating tokens during streaming
|
|
13
|
+
- Token accumulation now happens in real-time as step_finish events arrive, avoiding NDJSON concatenation issues
|
|
14
|
+
|
|
15
|
+
## 1.21.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- 6cf54b7: Add configurable queue threshold strategies (reject, enqueue, dequeue-one-at-a-time)
|
|
20
|
+
- Add three handling strategies for each queue threshold:
|
|
21
|
+
- `reject`: Immediately reject the command, no queueing
|
|
22
|
+
- `enqueue`: Block and wait in queue until metric drops
|
|
23
|
+
- `dequeue-one-at-a-time`: Allow one command, block subsequent
|
|
24
|
+
- Support configuration via `HIVE_MIND_QUEUE_CONFIG` environment variable (links notation format)
|
|
25
|
+
- Support individual strategy env vars (e.g., `HIVE_MIND_DISK_STRATEGY`)
|
|
26
|
+
|
|
27
|
+
**Breaking change:** Disk threshold default strategy changed from `dequeue-one-at-a-time` to `reject`
|
|
28
|
+
because the queue is lost on server restart. To restore old behavior: `HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time`
|
|
29
|
+
|
|
3
30
|
## 1.20.1
|
|
4
31
|
|
|
5
32
|
### Patch Changes
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -100,11 +100,59 @@ const getOriginalProviderName = providerId => {
|
|
|
100
100
|
return providerMap[providerId] || providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Issue #1250: Normalize model name and find base model for pricing lookup
|
|
105
|
+
* Free models like "kimi-k2.5-free" should use pricing from base model "kimi-k2.5"
|
|
106
|
+
*
|
|
107
|
+
* @param {string} modelName - The model name (e.g., 'kimi-k2.5-free')
|
|
108
|
+
* @returns {Object} Object with:
|
|
109
|
+
* - baseModelName: The base model name for pricing lookup
|
|
110
|
+
* - isFreeVariant: Whether this is a free variant
|
|
111
|
+
*/
|
|
112
|
+
const getBaseModelForPricing = modelName => {
|
|
113
|
+
// Known mappings for free models to their base paid versions
|
|
114
|
+
const freeToBaseMap = {
|
|
115
|
+
'kimi-k2.5-free': 'kimi-k2.5',
|
|
116
|
+
'glm-4.7-free': 'glm-4.7',
|
|
117
|
+
'minimax-m2.1-free': 'minimax-m2.1',
|
|
118
|
+
'trinity-large-preview-free': 'trinity-large-preview',
|
|
119
|
+
// Grok models don't have a paid equivalent with same name
|
|
120
|
+
// These are kept as-is since they're truly free
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Check if there's a direct mapping
|
|
124
|
+
if (freeToBaseMap[modelName]) {
|
|
125
|
+
return {
|
|
126
|
+
baseModelName: freeToBaseMap[modelName],
|
|
127
|
+
isFreeVariant: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Try removing "-free" suffix
|
|
132
|
+
if (modelName.endsWith('-free')) {
|
|
133
|
+
return {
|
|
134
|
+
baseModelName: modelName.replace(/-free$/, ''),
|
|
135
|
+
isFreeVariant: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Not a free variant
|
|
140
|
+
return {
|
|
141
|
+
baseModelName: modelName,
|
|
142
|
+
isFreeVariant: false,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
103
146
|
/**
|
|
104
147
|
* Calculate pricing for agent tool usage using models.dev API
|
|
105
148
|
* Issue #1250: Shows actual provider (OpenCode Zen) and calculates public pricing estimate
|
|
106
149
|
* based on original provider prices (Moonshot AI, OpenAI, Anthropic, etc.)
|
|
107
150
|
*
|
|
151
|
+
* For free models like "kimi-k2.5-free", this function:
|
|
152
|
+
* 1. First fetches the free model info to get the model name
|
|
153
|
+
* 2. Then fetches the base model (e.g., "kimi-k2.5") for actual pricing
|
|
154
|
+
* 3. Calculates public pricing estimate based on the base model's cost
|
|
155
|
+
*
|
|
108
156
|
* @param {string} modelId - The model ID used (e.g., 'opencode/grok-code')
|
|
109
157
|
* @param {Object} tokenUsage - Token usage data from parseAgentTokenUsage
|
|
110
158
|
* @returns {Object} Pricing information with:
|
|
@@ -122,40 +170,66 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
122
170
|
const providerFromModel = modelId.includes('/') ? modelId.split('/')[0] : null;
|
|
123
171
|
|
|
124
172
|
// Get original provider name for pricing reference
|
|
125
|
-
|
|
173
|
+
let originalProvider = getOriginalProviderName(providerFromModel);
|
|
126
174
|
|
|
127
175
|
try {
|
|
128
176
|
// Fetch model info from models.dev API
|
|
129
|
-
|
|
177
|
+
let modelInfo = await fetchModelInfo(modelName);
|
|
178
|
+
|
|
179
|
+
// Issue #1250: Check if model has zero pricing (free model from OpenCode Zen)
|
|
180
|
+
// If so, look up the base model for actual public pricing estimate
|
|
181
|
+
const { baseModelName, isFreeVariant } = getBaseModelForPricing(modelName);
|
|
182
|
+
let baseModelInfo = null;
|
|
183
|
+
let pricingCost = modelInfo?.cost;
|
|
184
|
+
|
|
185
|
+
if (modelInfo && modelInfo.cost && modelInfo.cost.input === 0 && modelInfo.cost.output === 0 && baseModelName !== modelName) {
|
|
186
|
+
// This is a free model with zero pricing - look up base model for public pricing
|
|
187
|
+
baseModelInfo = await fetchModelInfo(baseModelName);
|
|
188
|
+
if (baseModelInfo && baseModelInfo.cost) {
|
|
189
|
+
// Use base model pricing for public estimate
|
|
190
|
+
pricingCost = baseModelInfo.cost;
|
|
191
|
+
// Update original provider from base model if available
|
|
192
|
+
if (baseModelInfo.provider && !originalProvider) {
|
|
193
|
+
originalProvider = baseModelInfo.provider;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
130
197
|
|
|
131
|
-
if (modelInfo
|
|
132
|
-
const
|
|
198
|
+
if (modelInfo || baseModelInfo) {
|
|
199
|
+
const effectiveModelInfo = modelInfo || baseModelInfo;
|
|
200
|
+
const cost = pricingCost || { input: 0, output: 0, cache_read: 0, cache_write: 0, reasoning: 0 };
|
|
133
201
|
|
|
134
202
|
// Calculate public pricing estimate based on original provider prices
|
|
135
203
|
// Prices are per 1M tokens, so divide by 1,000,000
|
|
204
|
+
// All priced components from models.dev: input, output, cache_read, cache_write, reasoning
|
|
136
205
|
const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
|
|
137
206
|
const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
|
|
138
207
|
const cacheReadCost = (tokenUsage.cacheReadTokens * (cost.cache_read || 0)) / 1_000_000;
|
|
139
208
|
const cacheWriteCost = (tokenUsage.cacheWriteTokens * (cost.cache_write || 0)) / 1_000_000;
|
|
209
|
+
const reasoningCost = (tokenUsage.reasoningTokens * (cost.reasoning || 0)) / 1_000_000;
|
|
140
210
|
|
|
141
|
-
const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
211
|
+
const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost + reasoningCost;
|
|
142
212
|
|
|
143
213
|
// Determine if this is a free model from OpenCode Zen
|
|
144
214
|
// 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';
|
|
215
|
+
const isOpencodeFreeModel = providerFromModel === 'opencode' || isFreeVariant || modelName.toLowerCase().includes('free') || modelName.toLowerCase().includes('grok') || providerFromModel === 'moonshot' || providerFromModel === 'openai' || providerFromModel === 'anthropic';
|
|
216
|
+
|
|
217
|
+
// Use base model's provider for original provider reference if available
|
|
218
|
+
const effectiveOriginalProvider = baseModelInfo?.provider || originalProvider || effectiveModelInfo?.provider || null;
|
|
146
219
|
|
|
147
220
|
return {
|
|
148
221
|
modelId,
|
|
149
|
-
modelName:
|
|
222
|
+
modelName: effectiveModelInfo?.name || modelName,
|
|
150
223
|
// Issue #1250: Always show OpenCode Zen as actual provider
|
|
151
224
|
provider: 'OpenCode Zen',
|
|
152
225
|
// Store original provider for reference in pricing display
|
|
153
|
-
originalProvider:
|
|
226
|
+
originalProvider: effectiveOriginalProvider,
|
|
154
227
|
pricing: {
|
|
155
228
|
inputPerMillion: cost.input || 0,
|
|
156
229
|
outputPerMillion: cost.output || 0,
|
|
157
230
|
cacheReadPerMillion: cost.cache_read || 0,
|
|
158
231
|
cacheWritePerMillion: cost.cache_write || 0,
|
|
232
|
+
reasoningPerMillion: cost.reasoning || 0,
|
|
159
233
|
},
|
|
160
234
|
tokenUsage,
|
|
161
235
|
breakdown: {
|
|
@@ -163,15 +237,18 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
|
|
|
163
237
|
output: outputCost,
|
|
164
238
|
cacheRead: cacheReadCost,
|
|
165
239
|
cacheWrite: cacheWriteCost,
|
|
240
|
+
reasoning: reasoningCost,
|
|
166
241
|
},
|
|
167
|
-
// Public pricing estimate based on original
|
|
242
|
+
// Public pricing estimate based on original/base model prices
|
|
168
243
|
totalCostUSD: totalCost,
|
|
169
244
|
// Actual cost from OpenCode Zen (free for supported models)
|
|
170
245
|
opencodeCost: isOpencodeFreeModel ? 0 : totalCost,
|
|
171
|
-
// Keep for backward compatibility - indicates if model has zero pricing
|
|
172
|
-
isFreeModel: cost
|
|
246
|
+
// Keep for backward compatibility - indicates if the accessed model has zero pricing
|
|
247
|
+
isFreeModel: modelInfo?.cost?.input === 0 && modelInfo?.cost?.output === 0,
|
|
173
248
|
// New flag to indicate if OpenCode Zen provides this model for free
|
|
174
249
|
isOpencodeFreeModel,
|
|
250
|
+
// Issue #1250: Include base model info for transparency
|
|
251
|
+
baseModelName: baseModelName !== modelName ? baseModelName : null,
|
|
175
252
|
};
|
|
176
253
|
}
|
|
177
254
|
// Model not found in API, return what we have
|
|
@@ -476,11 +553,39 @@ export const executeAgentCommand = async params => {
|
|
|
476
553
|
let limitReached = false;
|
|
477
554
|
let limitResetTime = null;
|
|
478
555
|
let lastMessage = '';
|
|
479
|
-
let fullOutput = ''; // Collect all output for
|
|
556
|
+
let fullOutput = ''; // Collect all output for error detection (kept for backward compatibility)
|
|
480
557
|
// Issue #1201: Track error events detected during streaming for reliable error detection
|
|
481
558
|
// Post-hoc detection on fullOutput can miss errors if NDJSON lines get concatenated without newlines
|
|
482
559
|
let streamingErrorDetected = false;
|
|
483
560
|
let streamingErrorMessage = null;
|
|
561
|
+
// Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
|
|
562
|
+
// This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
|
|
563
|
+
const streamingTokenUsage = {
|
|
564
|
+
inputTokens: 0,
|
|
565
|
+
outputTokens: 0,
|
|
566
|
+
reasoningTokens: 0,
|
|
567
|
+
cacheReadTokens: 0,
|
|
568
|
+
cacheWriteTokens: 0,
|
|
569
|
+
totalCost: 0,
|
|
570
|
+
stepCount: 0,
|
|
571
|
+
};
|
|
572
|
+
// Helper to accumulate tokens from step_finish events during streaming
|
|
573
|
+
const accumulateTokenUsage = data => {
|
|
574
|
+
if (data.type === 'step_finish' && data.part?.tokens) {
|
|
575
|
+
const tokens = data.part.tokens;
|
|
576
|
+
streamingTokenUsage.stepCount++;
|
|
577
|
+
if (tokens.input) streamingTokenUsage.inputTokens += tokens.input;
|
|
578
|
+
if (tokens.output) streamingTokenUsage.outputTokens += tokens.output;
|
|
579
|
+
if (tokens.reasoning) streamingTokenUsage.reasoningTokens += tokens.reasoning;
|
|
580
|
+
if (tokens.cache) {
|
|
581
|
+
if (tokens.cache.read) streamingTokenUsage.cacheReadTokens += tokens.cache.read;
|
|
582
|
+
if (tokens.cache.write) streamingTokenUsage.cacheWriteTokens += tokens.cache.write;
|
|
583
|
+
}
|
|
584
|
+
if (data.part.cost !== undefined) {
|
|
585
|
+
streamingTokenUsage.totalCost += data.part.cost;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
484
589
|
|
|
485
590
|
for await (const chunk of execCommand.stream()) {
|
|
486
591
|
if (chunk.type === 'stdout') {
|
|
@@ -500,6 +605,8 @@ export const executeAgentCommand = async params => {
|
|
|
500
605
|
sessionId = data.sessionID;
|
|
501
606
|
await log(`📌 Session ID: ${sessionId}`);
|
|
502
607
|
}
|
|
608
|
+
// Issue #1250: Accumulate token usage during streaming
|
|
609
|
+
accumulateTokenUsage(data);
|
|
503
610
|
// Issue #1201: Detect error events during streaming for reliable detection
|
|
504
611
|
if (data.type === 'error' || data.type === 'step_error') {
|
|
505
612
|
streamingErrorDetected = true;
|
|
@@ -532,6 +639,8 @@ export const executeAgentCommand = async params => {
|
|
|
532
639
|
sessionId = stderrData.sessionID;
|
|
533
640
|
await log(`📌 Session ID: ${sessionId}`);
|
|
534
641
|
}
|
|
642
|
+
// Issue #1250: Accumulate token usage during streaming (stderr)
|
|
643
|
+
accumulateTokenUsage(stderrData);
|
|
535
644
|
// Issue #1201: Detect error events during streaming (stderr) for reliable detection
|
|
536
645
|
if (stderrData.type === 'error' || stderrData.type === 'step_error') {
|
|
537
646
|
streamingErrorDetected = true;
|
|
@@ -646,8 +755,9 @@ export const executeAgentCommand = async params => {
|
|
|
646
755
|
await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
|
|
647
756
|
await log(` Load: ${resourcesAfter.load}`, { verbose: true });
|
|
648
757
|
|
|
649
|
-
//
|
|
650
|
-
|
|
758
|
+
// Issue #1250: Use streaming-accumulated token usage instead of re-parsing fullOutput
|
|
759
|
+
// This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
|
|
760
|
+
const tokenUsage = streamingTokenUsage;
|
|
651
761
|
const pricingInfo = await calculateAgentPricing(mappedModel, tokenUsage);
|
|
652
762
|
|
|
653
763
|
return {
|
|
@@ -664,29 +774,47 @@ export const executeAgentCommand = async params => {
|
|
|
664
774
|
|
|
665
775
|
await log('\n\n✅ Agent command completed');
|
|
666
776
|
|
|
667
|
-
//
|
|
668
|
-
|
|
777
|
+
// Issue #1250: Use streaming-accumulated token usage instead of re-parsing fullOutput
|
|
778
|
+
// This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
|
|
779
|
+
const tokenUsage = streamingTokenUsage;
|
|
669
780
|
const pricingInfo = await calculateAgentPricing(mappedModel, tokenUsage);
|
|
670
781
|
|
|
671
|
-
// Log pricing information
|
|
782
|
+
// Log pricing information (similar to --tool claude breakdown)
|
|
672
783
|
if (tokenUsage.stepCount > 0) {
|
|
673
784
|
await log('\n💰 Token Usage Summary:');
|
|
674
|
-
await log(` 📊 ${pricingInfo.modelName || mappedModel}:`);
|
|
675
|
-
await log(` Input tokens:
|
|
676
|
-
await log(` Output tokens:
|
|
785
|
+
await log(` 📊 ${pricingInfo.modelName || mappedModel} (${tokenUsage.stepCount} steps):`);
|
|
786
|
+
await log(` Input tokens: ${tokenUsage.inputTokens.toLocaleString()}`);
|
|
787
|
+
await log(` Output tokens: ${tokenUsage.outputTokens.toLocaleString()}`);
|
|
677
788
|
if (tokenUsage.reasoningTokens > 0) {
|
|
678
789
|
await log(` Reasoning tokens: ${tokenUsage.reasoningTokens.toLocaleString()}`);
|
|
679
790
|
}
|
|
680
791
|
if (tokenUsage.cacheReadTokens > 0 || tokenUsage.cacheWriteTokens > 0) {
|
|
681
|
-
await log(` Cache read:
|
|
682
|
-
await log(` Cache write:
|
|
792
|
+
await log(` Cache read: ${tokenUsage.cacheReadTokens.toLocaleString()}`);
|
|
793
|
+
await log(` Cache write: ${tokenUsage.cacheWriteTokens.toLocaleString()}`);
|
|
683
794
|
}
|
|
684
795
|
|
|
685
|
-
if (pricingInfo.totalCostUSD !== null) {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
796
|
+
if (pricingInfo.totalCostUSD !== null && pricingInfo.breakdown) {
|
|
797
|
+
// Show per-component cost breakdown (similar to --tool claude)
|
|
798
|
+
await log(' Cost breakdown:');
|
|
799
|
+
await log(` Input: $${pricingInfo.breakdown.input.toFixed(6)} (${(pricingInfo.pricing?.inputPerMillion || 0).toFixed(2)}/M tokens)`);
|
|
800
|
+
await log(` Output: $${pricingInfo.breakdown.output.toFixed(6)} (${(pricingInfo.pricing?.outputPerMillion || 0).toFixed(2)}/M tokens)`);
|
|
801
|
+
if (tokenUsage.cacheReadTokens > 0) {
|
|
802
|
+
await log(` Cache read: $${pricingInfo.breakdown.cacheRead.toFixed(6)} (${(pricingInfo.pricing?.cacheReadPerMillion || 0).toFixed(2)}/M tokens)`);
|
|
803
|
+
}
|
|
804
|
+
if (tokenUsage.cacheWriteTokens > 0) {
|
|
805
|
+
await log(` Cache write: $${pricingInfo.breakdown.cacheWrite.toFixed(6)} (${(pricingInfo.pricing?.cacheWritePerMillion || 0).toFixed(2)}/M tokens)`);
|
|
806
|
+
}
|
|
807
|
+
if (tokenUsage.reasoningTokens > 0 && pricingInfo.breakdown.reasoning > 0) {
|
|
808
|
+
await log(` Reasoning: $${pricingInfo.breakdown.reasoning.toFixed(6)} (${(pricingInfo.pricing?.reasoningPerMillion || 0).toFixed(2)}/M tokens)`);
|
|
809
|
+
}
|
|
810
|
+
// Show public pricing estimate
|
|
811
|
+
const pricingRef = pricingInfo.baseModelName && pricingInfo.originalProvider ? ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)` : pricingInfo.originalProvider ? ` (based on ${pricingInfo.originalProvider} prices)` : '';
|
|
812
|
+
await log(` Public pricing estimate: $${pricingInfo.totalCostUSD.toFixed(6)}${pricingRef}`);
|
|
813
|
+
// Show actual OpenCode Zen cost
|
|
814
|
+
if (pricingInfo.isOpencodeFreeModel) {
|
|
815
|
+
await log(' Calculated by OpenCode Zen: $0.00 (Free model)');
|
|
816
|
+
} else if (pricingInfo.opencodeCost !== undefined) {
|
|
817
|
+
await log(` Calculated by OpenCode Zen: $${pricingInfo.opencodeCost.toFixed(6)}`);
|
|
690
818
|
}
|
|
691
819
|
await log(` Provider: ${pricingInfo.provider || 'OpenCode Zen'}`);
|
|
692
820
|
} else {
|
package/src/github.lib.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
|
|
|
30
30
|
* - opencodeCost: Actual billed cost from OpenCode Zen (for agent tool)
|
|
31
31
|
* - isOpencodeFreeModel: Whether OpenCode Zen provides this model for free
|
|
32
32
|
* - originalProvider: Original provider for pricing reference
|
|
33
|
+
* - baseModelName: Base model name if pricing was derived from base model (Issue #1250)
|
|
33
34
|
* @returns {string} Formatted cost info string for markdown (empty if no data available)
|
|
34
35
|
*/
|
|
35
36
|
const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
|
|
@@ -47,13 +48,20 @@ const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) =
|
|
|
47
48
|
}
|
|
48
49
|
// Issue #1250: Show public pricing estimate based on original provider prices
|
|
49
50
|
if (hasPublic) {
|
|
50
|
-
// For models
|
|
51
|
-
if
|
|
51
|
+
// Issue #1250: For free models accessed via OpenCode Zen, show pricing based on base model
|
|
52
|
+
// Only show as completely free if the base model also has no pricing
|
|
53
|
+
if (pricingInfo?.isFreeModel && totalCostUSD === 0 && !pricingInfo?.baseModelName) {
|
|
52
54
|
costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
|
|
53
55
|
} else {
|
|
54
56
|
// Show actual public pricing estimate with original provider reference
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
// Issue #1250: Include base model reference when pricing comes from base model
|
|
58
|
+
let pricingRef = '';
|
|
59
|
+
if (pricingInfo?.baseModelName && pricingInfo?.originalProvider) {
|
|
60
|
+
pricingRef = ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)`;
|
|
61
|
+
} else if (pricingInfo?.originalProvider) {
|
|
62
|
+
pricingRef = ` (based on ${pricingInfo.originalProvider} prices)`;
|
|
63
|
+
}
|
|
64
|
+
costInfo += `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)}${pricingRef}`;
|
|
57
65
|
}
|
|
58
66
|
} else if (hasPricing) {
|
|
59
67
|
costInfo += '\n- Public pricing estimate: unknown';
|
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
|
/**
|