@link-assistant/hive-mind 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.0.5
4
+
5
+ ### Patch Changes
6
+
7
+ - a68a9f2: fix(queue): simplify queue logic based on PR feedback
8
+ - **Use 5-minute load average for CPU**: Uses `loadAvg5` instead of instantaneous CPU usage,
9
+ providing a more stable metric not affected by transient spikes during claude startup.
10
+ Cache TTL is 2 minutes.
11
+ - **Keep RAM threshold with caching**: RAM_THRESHOLD (50%) is still checked but uses cached
12
+ values only (no uncached rechecks) to simplify the logic.
13
+ - **Increase MIN_START_INTERVAL_MS to 2 minutes**: Allows enough time for solve command to
14
+ start actual claude process, ensuring running processes are counted when API limits are checked.
15
+ - **Increase CONSUMER_POLL_INTERVAL_MS to 1 minute**: Reduces unnecessary system checks.
16
+ One-minute polling is sufficient for queue management.
17
+ - **Running processes not a blocking limit**: Commands can run in parallel as long as actual
18
+ limits (CPU, API, etc.) are not exceeded. Claude process info is only supplementary.
19
+
20
+ Fixes #1078
21
+
22
+ ## 1.0.4
23
+
24
+ ### Patch Changes
25
+
26
+ - 4e5e1ab: Use gh-upload-log for log file uploads (issue #587)
27
+ - Replace custom gist creation with gh-upload-log command
28
+ - Implement smart linking: 1 chunk = direct raw link, >1 chunks = repo link
29
+ - Update case study documentation with gh-upload-log v0.5.0 fixes
30
+ - Remove custom log compression in favor of gh-upload-log auto mode
31
+
3
32
  ## 1.0.3
4
33
 
5
34
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,7 +13,8 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "echo \"Error: no test specified\" && exit 1",
16
+ "test": "node tests/solve-queue.test.mjs",
17
+ "test:queue": "node tests/solve-queue.test.mjs",
17
18
  "lint": "eslint 'src/**/*.{js,mjs,cjs}'",
18
19
  "lint:fix": "eslint 'src/**/*.{js,mjs,cjs}' --fix",
19
20
  "format": "prettier --write \"**/*.{js,mjs,json,md}\"",
@@ -16,6 +16,8 @@ import { reportError } from './sentry.lib.mjs';
16
16
  import { githubLimits, timeouts } from './config.lib.mjs';
17
17
  // Import batch operations from separate module
18
18
  import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
19
+ // Import log upload function from separate module
20
+ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
19
21
 
20
22
  /**
21
23
  * Build cost estimation string for log comments
@@ -55,6 +57,7 @@ const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) =
55
57
  }
56
58
  return costInfo;
57
59
  };
60
+
58
61
  // Helper function to mask GitHub tokens (alias for backward compatibility)
59
62
  export const maskGitHubToken = maskToken;
60
63
  // Helper function to get GitHub tokens from local config files
@@ -649,7 +652,7 @@ ${logContent}
649
652
  let commentResult;
650
653
  if (logComment.length > githubLimits.commentMaxSize) {
651
654
  await log(` ⚠️ Log comment too long (${logComment.length} chars), GitHub limit is ${githubLimits.commentMaxSize} chars`);
652
- await log(' 📎 Uploading log as GitHub Gist instead...');
655
+ await log(' 📎 Uploading log using gh-upload-log...');
653
656
  try {
654
657
  // Check if repository is public or private
655
658
  let isPublicRepo = true;
@@ -670,49 +673,36 @@ ${logContent}
670
673
  repo,
671
674
  });
672
675
  // Default to public if we can't determine visibility
673
- await log(' ⚠️ Could not determine repository visibility, defaulting to public gist', { verbose: true });
676
+ await log(' ⚠️ Could not determine repository visibility, defaulting to public', { verbose: true });
674
677
  }
675
- // Create gist with appropriate visibility
676
- // Note: Gists don't need escaping because they are uploaded as plain text files
678
+ // Create temp log file with sanitized content (no compression, just gh-upload-log)
677
679
  const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
678
- // Use the original sanitized content (before escaping) for gist since it's a text file
680
+ // Use the original sanitized content for upload since it's a plain text file
679
681
  await fs.writeFile(tempLogFile, await sanitizeLogContent(rawLogContent));
680
- const gistCommand = isPublicRepo ? `gh gist create "${tempLogFile}" --public --desc "Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}" --filename "solution-draft-log.txt"` : `gh gist create "${tempLogFile}" --desc "Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}" --filename "solution-draft-log.txt"`;
681
- if (verbose) {
682
- await log(` 🔐 Creating ${isPublicRepo ? 'public' : 'private'} gist...`, { verbose: true });
683
- }
684
- const gistResult = await $(gistCommand);
682
+
683
+ // Use gh-upload-log to upload the log file
684
+ const uploadDescription = `Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}`;
685
+ const uploadResult = await uploadLogWithGhUploadLog({
686
+ logFile: tempLogFile,
687
+ isPublic: isPublicRepo,
688
+ description: uploadDescription,
689
+ verbose,
690
+ });
685
691
  await fs.unlink(tempLogFile).catch(() => {});
686
- if (gistResult.code === 0) {
687
- const gistPageUrl = gistResult.stdout.toString().trim();
688
- // Extract gist ID from URL
689
- const gistId = gistPageUrl.split('/').pop();
690
- // Construct raw file URL
691
- // Format: https://gist.githubusercontent.com/{owner}/{gist_id}/raw/{commit_sha}/{filename}
692
- // We use gh api to get the gist details for owner and commit SHA
693
- let gistUrl = gistPageUrl; // fallback to page URL if we can't get raw URL
694
-
695
- const gistDetailsResult = await $`gh api gists/${gistId} --jq '{owner: .owner.login, files: .files, history: .history}'`;
696
- if (gistDetailsResult.code === 0) {
697
- const gistDetails = JSON.parse(gistDetailsResult.stdout.toString());
698
- const commitSha = gistDetails.history && gistDetails.history[0] ? gistDetails.history[0].version : null;
699
- // Get the actual filename from the gist API response (--filename flag only works with stdin)
700
- const fileNames = gistDetails.files ? Object.keys(gistDetails.files) : [];
701
- const fileName = fileNames.length > 0 ? fileNames[0] : 'solution-draft-log.txt';
702
-
703
- if (commitSha) {
704
- gistUrl = `https://gist.githubusercontent.com/${gistDetails.owner}/${gistId}/raw/${commitSha}/${fileName}`;
705
- } else {
706
- // Fallback: use simpler format without commit SHA (GitHub will redirect to latest)
707
- gistUrl = `https://gist.githubusercontent.com/${gistDetails.owner}/${gistId}/raw/${fileName}`;
708
- }
709
- }
710
- // Create comment with gist link
711
- let gistComment;
692
+
693
+ if (uploadResult.success) {
694
+ // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks)
695
+ // Requirements: 1 chunk = direct raw link, >1 chunks = repo link
696
+ const logUrl = uploadResult.chunks === 1 ? uploadResult.rawUrl : uploadResult.url;
697
+ const uploadTypeLabel = uploadResult.type === 'gist' ? 'Gist' : 'Repository';
698
+ const chunkInfo = uploadResult.chunks > 1 ? ` (${uploadResult.chunks} chunks)` : '';
699
+
700
+ // Create comment with log link
701
+ let logUploadComment;
712
702
  // For usage limit cases, always use the dedicated format regardless of errorMessage
713
703
  if (isUsageLimit) {
714
- // Usage limit error gist format
715
- gistComment = `## ⏳ Usage Limit Reached
704
+ // Usage limit error format
705
+ logUploadComment = `## ⏳ Usage Limit Reached
716
706
 
717
707
  The automated solution draft was interrupted because the ${toolName} usage limit was reached.
718
708
 
@@ -721,86 +711,86 @@ The automated solution draft was interrupted because the ${toolName} usage limit
721
711
  - **Limit Type**: Usage limit exceeded`;
722
712
 
723
713
  if (limitResetTime) {
724
- gistComment += `\n- **Reset Time**: ${limitResetTime}`;
714
+ logUploadComment += `\n- **Reset Time**: ${limitResetTime}`;
725
715
  }
726
716
 
727
717
  if (sessionId) {
728
- gistComment += `\n- **Session ID**: ${sessionId}`;
718
+ logUploadComment += `\n- **Session ID**: ${sessionId}`;
729
719
  }
730
720
 
731
- gistComment += '\n\n### 🔄 How to Continue\n';
721
+ logUploadComment += '\n\n### 🔄 How to Continue\n';
732
722
 
733
723
  if (limitResetTime) {
734
- gistComment += `Once the limit resets at **${limitResetTime}**, `;
724
+ logUploadComment += `Once the limit resets at **${limitResetTime}**, `;
735
725
  } else {
736
- gistComment += 'Once the limit resets, ';
726
+ logUploadComment += 'Once the limit resets, ';
737
727
  }
738
728
 
739
729
  if (resumeCommand) {
740
- gistComment += `you can resume this session by running:
730
+ logUploadComment += `you can resume this session by running:
741
731
  \`\`\`bash
742
732
  ${resumeCommand}
743
733
  \`\`\``;
744
734
  } else if (sessionId) {
745
- gistComment += `you can resume this session using session ID: \`${sessionId}\``;
735
+ logUploadComment += `you can resume this session using session ID: \`${sessionId}\``;
746
736
  } else {
747
- gistComment += 'you can retry the operation.';
737
+ logUploadComment += 'you can retry the operation.';
748
738
  }
749
739
 
750
- gistComment += `
740
+ logUploadComment += `
751
741
 
752
- 📎 **Execution log uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
753
- 🔗 [View complete execution log](${gistUrl})
742
+ 📎 **Execution log uploaded as ${uploadTypeLabel}${chunkInfo}** (${Math.round(logStats.size / 1024)}KB)
743
+ 🔗 [View complete execution log](${logUrl})
754
744
 
755
745
  ---
756
746
  *This session was interrupted due to usage limits. You can resume once the limit resets.*`;
757
747
  } else if (errorMessage) {
758
- // Failure log gist format (non-usage-limit errors)
759
- gistComment = `## 🚨 Solution Draft Failed
748
+ // Failure log format (non-usage-limit errors)
749
+ logUploadComment = `## 🚨 Solution Draft Failed
760
750
  The automated solution draft encountered an error:
761
751
  \`\`\`
762
752
  ${errorMessage}
763
753
  \`\`\`
764
- 📎 **Failure log uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
765
- 🔗 [View complete failure log](${gistUrl})
754
+ 📎 **Failure log uploaded as ${uploadTypeLabel}${chunkInfo}** (${Math.round(logStats.size / 1024)}KB)
755
+ 🔗 [View complete failure log](${logUrl})
766
756
  ---
767
757
  *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
768
758
  } else {
769
- // Success log gist format - use helper function for cost info
759
+ // Success log format - use helper function for cost info
770
760
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
771
- gistComment = `## ${customTitle}
761
+ logUploadComment = `## ${customTitle}
772
762
  This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}
773
- 📎 **Log file uploaded as GitHub Gist** (${Math.round(logStats.size / 1024)}KB)
774
- 🔗 [View complete solution draft log](${gistUrl})
763
+ 📎 **Log file uploaded as ${uploadTypeLabel}${chunkInfo}** (${Math.round(logStats.size / 1024)}KB)
764
+ 🔗 [View complete solution draft log](${logUrl})
775
765
  ---
776
766
  *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
777
767
  }
778
- const tempGistCommentFile = `/tmp/log-gist-comment-${targetType}-${Date.now()}.md`;
779
- await fs.writeFile(tempGistCommentFile, gistComment);
780
- commentResult = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${tempGistCommentFile}"`;
781
- await fs.unlink(tempGistCommentFile).catch(() => {});
768
+ const tempCommentFile = `/tmp/log-upload-comment-${targetType}-${Date.now()}.md`;
769
+ await fs.writeFile(tempCommentFile, logUploadComment);
770
+ commentResult = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${tempCommentFile}"`;
771
+ await fs.unlink(tempCommentFile).catch(() => {});
782
772
  if (commentResult.code === 0) {
783
- await log(` ✅ Solution draft log uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} Gist`);
784
- await log(` 🔗 Gist URL: ${gistUrl}`);
773
+ await log(` ✅ Solution draft log uploaded to ${targetName} as ${isPublicRepo ? 'public' : 'private'} ${uploadTypeLabel}${chunkInfo}`);
774
+ await log(` 🔗 Log URL: ${logUrl}`);
785
775
  await log(` 📊 Log size: ${Math.round(logStats.size / 1024)}KB`);
786
776
  return true;
787
777
  } else {
788
- await log(` ❌ Failed to upload comment with gist link: ${commentResult.stderr ? commentResult.stderr.toString().trim() : 'unknown error'}`);
778
+ await log(` ❌ Failed to post comment with log link: ${commentResult.stderr ? commentResult.stderr.toString().trim() : 'unknown error'}`);
789
779
  return false;
790
780
  }
791
781
  } else {
792
- await log(`Failed to create gist: ${gistResult.stderr ? gistResult.stderr.toString().trim() : 'unknown error'}`);
782
+ await log('gh-upload-log failed');
793
783
 
794
784
  // Fallback to truncated comment
795
785
  await log(' 🔄 Falling back to truncated comment...');
796
786
  return await attachTruncatedLog(options);
797
787
  }
798
- } catch (gistError) {
799
- reportError(gistError, {
800
- context: 'create_gist',
788
+ } catch (uploadError) {
789
+ reportError(uploadError, {
790
+ context: 'upload_log_gh_upload_log',
801
791
  level: 'error',
802
792
  });
803
- await log(` ❌ Error creating gist: ${gistError.message}`);
793
+ await log(` ❌ Error uploading log: ${uploadError.message}`);
804
794
  // Try regular comment as last resort
805
795
  return await attachRegularComment(options, logComment);
806
796
  }
@@ -1471,6 +1461,8 @@ export async function detectRepositoryVisibility(owner, repo) {
1471
1461
  }
1472
1462
  // Re-export batch archived check from separate module
1473
1463
  export const batchCheckArchivedRepositories = batchCheckArchived;
1464
+ // Re-export log upload function from separate module
1465
+ export { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
1474
1466
  // Export all functions as default object too
1475
1467
  export default {
1476
1468
  maskGitHubToken,
@@ -1482,6 +1474,7 @@ export default {
1482
1474
  checkGitHubPermissions,
1483
1475
  checkRepositoryWritePermission,
1484
1476
  attachLogToGitHub,
1477
+ uploadLogWithGhUploadLog,
1485
1478
  fetchAllIssuesWithPagination,
1486
1479
  fetchProjectIssues,
1487
1480
  isRateLimitError,
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Log upload module for hive-mind
4
+ // Uses gh-upload-log for uploading log files to GitHub
5
+
6
+ // Use use-m to dynamically import modules for cross-runtime compatibility
7
+ if (typeof globalThis.use === 'undefined') {
8
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
9
+ }
10
+ const use = globalThis.use;
11
+
12
+ // Use command-stream for consistent $ behavior across runtimes
13
+ const { $ } = await use('command-stream');
14
+
15
+ // Import shared library functions
16
+ const lib = await import('./lib.mjs');
17
+ const { log } = lib;
18
+
19
+ // Import Sentry integration
20
+ const sentryLib = await import('./sentry.lib.mjs');
21
+ const { reportError } = sentryLib;
22
+
23
+ /**
24
+ * Upload a log file using gh-upload-log command
25
+ * @param {Object} options - Upload options
26
+ * @param {string} options.logFile - Path to the log file to upload
27
+ * @param {boolean} options.isPublic - Whether to make the upload public
28
+ * @param {string} options.description - Description for the upload
29
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
30
+ * @returns {Promise<{success: boolean, url: string|null, rawUrl: string|null, type: 'gist'|'repository'|null, chunks: number}>}
31
+ */
32
+ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description, verbose = false }) => {
33
+ const result = { success: false, url: null, rawUrl: null, type: null, chunks: 1 };
34
+
35
+ try {
36
+ // Build command with appropriate flags
37
+ const publicFlag = isPublic ? '--public' : '--private';
38
+ const descFlag = description ? `--description "${description}"` : '';
39
+ const verboseFlag = verbose ? '--verbose' : '';
40
+
41
+ const command = `gh-upload-log "${logFile}" ${publicFlag} ${descFlag} ${verboseFlag}`.trim().replace(/\s+/g, ' ');
42
+
43
+ if (verbose) {
44
+ await log(` 📤 Running: ${command}`, { verbose: true });
45
+ }
46
+
47
+ const uploadResult = await $`gh-upload-log "${logFile}" ${publicFlag} ${verbose ? '--verbose' : ''}`;
48
+ const output = (uploadResult.stdout?.toString() || '') + (uploadResult.stderr?.toString() || '');
49
+
50
+ if (uploadResult.code !== 0) {
51
+ await log(` ❌ gh-upload-log failed: ${output}`);
52
+ return result;
53
+ }
54
+
55
+ // Parse output to extract URL and type
56
+ // Look for the URL line: 🔗 https://...
57
+ const urlMatch = output.match(/🔗\s+(https:\/\/[^\s\n]+)/);
58
+ if (urlMatch) {
59
+ result.url = urlMatch[1].trim();
60
+ }
61
+
62
+ // Determine type from output
63
+ if (output.includes('Type: 📝 Gist') || result.url?.includes('gist.github.com')) {
64
+ result.type = 'gist';
65
+ } else if (output.includes('Type: 📦 Repository') || (result.url?.includes('github.com') && !result.url?.includes('gist'))) {
66
+ result.type = 'repository';
67
+ }
68
+
69
+ // Extract chunk count if mentioned
70
+ const chunkMatch = output.match(/split into (\d+) chunks/i);
71
+ if (chunkMatch) {
72
+ result.chunks = parseInt(chunkMatch[1], 10);
73
+ }
74
+
75
+ // Construct raw URL based on type and chunks
76
+ if (result.url) {
77
+ result.success = true;
78
+
79
+ if (result.type === 'gist') {
80
+ // For gist: get raw URL from gist API
81
+ const gistId = result.url.split('/').pop();
82
+ try {
83
+ const gistDetailsResult = await $`gh api gists/${gistId} --jq '{owner: .owner.login, files: .files, history: .history}'`;
84
+ if (gistDetailsResult.code === 0) {
85
+ const gistDetails = JSON.parse(gistDetailsResult.stdout.toString());
86
+ const gistOwner = gistDetails.owner;
87
+ const commitSha = gistDetails.history?.[0]?.version;
88
+ const fileNames = gistDetails.files ? Object.keys(gistDetails.files) : [];
89
+ const fileName = fileNames.length > 0 ? fileNames[0] : 'log.txt';
90
+
91
+ if (commitSha) {
92
+ result.rawUrl = `https://gist.githubusercontent.com/${gistOwner}/${gistId}/raw/${commitSha}/${fileName}`;
93
+ } else {
94
+ result.rawUrl = `https://gist.githubusercontent.com/${gistOwner}/${gistId}/raw/${fileName}`;
95
+ }
96
+ }
97
+ } catch (apiError) {
98
+ if (verbose) {
99
+ await log(` ⚠️ Could not get gist raw URL: ${apiError.message}`, { verbose: true });
100
+ }
101
+ // Use page URL as fallback
102
+ result.rawUrl = result.url;
103
+ }
104
+ } else if (result.type === 'repository') {
105
+ if (result.chunks === 1) {
106
+ // For single chunk repository: construct raw URL to the file
107
+ // Repository URL format: https://github.com/owner/repo
108
+ // We need to find the actual file name in the repo
109
+ try {
110
+ const repoUrl = result.url;
111
+ const repoPath = repoUrl.replace('https://github.com/', '');
112
+ const contentsResult = await $`gh api repos/${repoPath}/contents --jq '.[].name'`;
113
+ if (contentsResult.code === 0) {
114
+ const files = contentsResult.stdout
115
+ .toString()
116
+ .trim()
117
+ .split('\n')
118
+ .filter(f => f && !f.startsWith('.'));
119
+ if (files.length > 0) {
120
+ const fileName = files[0];
121
+ result.rawUrl = `${repoUrl}/raw/main/${fileName}`;
122
+ }
123
+ }
124
+ } catch (apiError) {
125
+ if (verbose) {
126
+ await log(` ⚠️ Could not get repo file raw URL: ${apiError.message}`, { verbose: true });
127
+ }
128
+ // For single chunk, try common pattern
129
+ result.rawUrl = result.url;
130
+ }
131
+ } else {
132
+ // For multiple chunks: link to repository itself (not raw)
133
+ result.rawUrl = result.url;
134
+ }
135
+ }
136
+ }
137
+
138
+ if (verbose) {
139
+ await log(` ✅ Upload successful: ${result.url}`, { verbose: true });
140
+ await log(` 📊 Type: ${result.type}, Chunks: ${result.chunks}`, { verbose: true });
141
+ if (result.rawUrl !== result.url) {
142
+ await log(` 🔗 Raw URL: ${result.rawUrl}`, { verbose: true });
143
+ }
144
+ }
145
+
146
+ return result;
147
+ } catch (error) {
148
+ reportError(error, {
149
+ context: 'upload_log_with_gh_upload_log',
150
+ logFile,
151
+ operation: 'gh_upload_log_command',
152
+ });
153
+ await log(` ❌ Error running gh-upload-log: ${error.message}`);
154
+ return result;
155
+ }
156
+ };
157
+
158
+ // Export all functions as default object too
159
+ export default {
160
+ uploadLogWithGhUploadLog,
161
+ };
@@ -26,11 +26,16 @@ import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getC
26
26
  /**
27
27
  * Configuration constants for queue throttling
28
28
  * All thresholds use ratios (0.0 - 1.0) representing usage percentage
29
+ *
30
+ * IMPORTANT: Running claude processes is NOT a blocking limit by itself.
31
+ * Commands can run in parallel as long as actual limits (CPU, API, etc.) are not exceeded.
32
+ * See: https://github.com/link-assistant/hive-mind/issues/1078
29
33
  */
30
34
  export const QUEUE_CONFIG = {
31
35
  // Resource thresholds (usage ratios: 0.0 - 1.0)
32
36
  RAM_THRESHOLD: 0.5, // Stop if RAM usage > 50%
33
- CPU_THRESHOLD: 0.5, // Stop if CPU usage > 50%
37
+ // CPU threshold uses 5-minute load average, not instantaneous CPU usage
38
+ CPU_THRESHOLD: 0.5, // Stop if 5-minute load average > 50% of CPU count
34
39
  DISK_THRESHOLD: 0.95, // One-at-a-time if disk usage > 95%
35
40
 
36
41
  // API limit thresholds (usage ratios: 0.0 - 1.0)
@@ -39,8 +44,11 @@ export const QUEUE_CONFIG = {
39
44
  GITHUB_API_THRESHOLD: 0.8, // Stop if GitHub > 80% with parallel claude
40
45
 
41
46
  // Timing
42
- MIN_START_INTERVAL_MS: 60000, // 1 minute between starts
43
- CONSUMER_POLL_INTERVAL_MS: 5000, // 5 seconds between queue checks
47
+ // MIN_START_INTERVAL_MS: Time to allow solve command to start actual claude process
48
+ // This ensures that when API limits are checked, the running process is counted
49
+ MIN_START_INTERVAL_MS: 120000, // 2 minutes between starts (was 1 minute)
50
+ CONSUMER_POLL_INTERVAL_MS: 60000, // 1 minute between queue checks (was 5 seconds)
51
+ MESSAGE_UPDATE_INTERVAL_MS: 60000, // 1 minute between status message updates
44
52
 
45
53
  // Process detection
46
54
  CLAUDE_PROCESS_NAMES: ['claude'], // Process names to detect
@@ -163,6 +171,9 @@ class SolveQueueItem {
163
171
  this.sessionName = null;
164
172
  // Message tracking - forget after STARTED
165
173
  this.messageInfo = null; // { chatId, messageId }
174
+ // Track when we last updated the Telegram message
175
+ // See: https://github.com/link-assistant/hive-mind/issues/1078
176
+ this.lastMessageUpdateTime = null;
166
177
  }
167
178
 
168
179
  /**
@@ -409,8 +420,10 @@ export class SolveQueue {
409
420
  // "Claude process running" only blocks if there are OTHER reasons too
410
421
  // This allows parallel execution when limits are not exceeded
411
422
  if (hasRunningClaude && reasons.length > 0) {
412
- // Add claude_running info only when combined with actual limit reasons
413
- reasons.unshift(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
423
+ // Add claude_running info at the END (not beginning) of reasons
424
+ // Since it's supplementary info, not the primary blocking reason
425
+ // See: https://github.com/link-assistant/hive-mind/issues/1078
426
+ reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
414
427
  }
415
428
 
416
429
  const canStart = reasons.length === 0;
@@ -430,6 +443,11 @@ export class SolveQueue {
430
443
 
431
444
  /**
432
445
  * Check system resources (RAM, CPU, disk) using cached values
446
+ *
447
+ * Uses 5-minute load average for CPU instead of instantaneous usage.
448
+ * This provides a more stable metric that isn't affected by brief spikes
449
+ * during claude process startup.
450
+ *
433
451
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
434
452
  */
435
453
  async checkSystemResources() {
@@ -446,12 +464,25 @@ export class SolveQueue {
446
464
  }
447
465
  }
448
466
 
449
- // Check CPU (using cached value)
467
+ // Check CPU using 5-minute load average (more stable than 1-minute)
468
+ // Cache TTL is 2 minutes, which is appropriate for this metric
450
469
  const cpuResult = await getCachedCpuInfo(this.verbose);
451
470
  if (cpuResult.success) {
452
- const usedRatio = cpuResult.cpuLoad.usagePercentage / 100;
453
- if (usedRatio > QUEUE_CONFIG.CPU_THRESHOLD) {
454
- reasons.push(formatWaitingReason('cpu', cpuResult.cpuLoad.usagePercentage, QUEUE_CONFIG.CPU_THRESHOLD));
471
+ // Use loadAvg5 (5-minute average) instead of usagePercentage (1-minute based)
472
+ // This provides a more stable metric that isn't affected by transient spikes
473
+ const loadAvg5 = cpuResult.cpuLoad.loadAvg5;
474
+ const cpuCount = cpuResult.cpuLoad.cpuCount;
475
+ // Calculate usage ratio: loadAvg5 / cpuCount
476
+ // Load average of 1.0 per CPU = 100% utilization
477
+ const usageRatio = loadAvg5 / cpuCount;
478
+ const usagePercent = Math.min(100, Math.round(usageRatio * 100));
479
+
480
+ if (this.verbose) {
481
+ this.log(`CPU 5m load avg: ${loadAvg5.toFixed(2)}, cpus: ${cpuCount}, usage: ${usagePercent}%`);
482
+ }
483
+
484
+ if (usageRatio > QUEUE_CONFIG.CPU_THRESHOLD) {
485
+ reasons.push(formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.CPU_THRESHOLD));
455
486
  this.recordThrottle('cpu_high');
456
487
  }
457
488
  }
@@ -564,18 +595,33 @@ export class SolveQueue {
564
595
  * Update item message in Telegram
565
596
  * @param {SolveQueueItem} item
566
597
  * @param {string} text
598
+ * @param {boolean} trackUpdateTime - Whether to track this as a periodic update (default: true)
567
599
  */
568
- async updateItemMessage(item, text) {
600
+ async updateItemMessage(item, text, trackUpdateTime = true) {
569
601
  if (!item.messageInfo || !item.ctx) return;
570
602
 
571
603
  try {
572
604
  const { chatId, messageId } = item.messageInfo;
573
605
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
606
+ if (trackUpdateTime) {
607
+ item.lastMessageUpdateTime = Date.now();
608
+ }
574
609
  } catch (error) {
575
610
  this.log(`Failed to update message: ${error.message}`);
576
611
  }
577
612
  }
578
613
 
614
+ /**
615
+ * Check if an item's message should be updated periodically
616
+ * @param {SolveQueueItem} item
617
+ * @returns {boolean}
618
+ */
619
+ shouldUpdateMessage(item) {
620
+ if (!item.messageInfo || !item.ctx) return false;
621
+ if (!item.lastMessageUpdateTime) return true; // Never updated
622
+ return Date.now() - item.lastMessageUpdateTime >= QUEUE_CONFIG.MESSAGE_UPDATE_INTERVAL_MS;
623
+ }
624
+
579
625
  /**
580
626
  * Consumer loop - processes items from the queue
581
627
  */
@@ -592,14 +638,20 @@ export class SolveQueue {
592
638
 
593
639
  if (!check.canStart) {
594
640
  // Update all queued items to waiting status with reason
641
+ // Also periodically refresh messages to show current status
642
+ // See: https://github.com/link-assistant/hive-mind/issues/1078
595
643
  for (const item of this.queue) {
596
644
  if (item.status === QueueItemStatus.QUEUED || item.status === QueueItemStatus.WAITING) {
597
645
  const previousStatus = item.status;
598
646
  const previousReason = item.waitingReason;
599
647
  item.setWaiting(check.reason);
600
648
 
601
- // Update message if status or reason changed
602
- if (previousStatus !== item.status || previousReason !== item.waitingReason) {
649
+ // Update message if:
650
+ // 1. Status or reason changed
651
+ // 2. OR it's time for a periodic update (every MESSAGE_UPDATE_INTERVAL_MS)
652
+ const shouldUpdate = previousStatus !== item.status || previousReason !== item.waitingReason || this.shouldUpdateMessage(item);
653
+
654
+ if (shouldUpdate) {
603
655
  const position = this.queue.indexOf(item) + 1;
604
656
  await this.updateItemMessage(item, `⏳ Waiting (position #${position})\n\n${item.infoBlock}\n\n*Reason:*\n${check.reason}`);
605
657
  }
@@ -622,13 +674,10 @@ export class SolveQueue {
622
674
  const item = this.queue.shift();
623
675
  if (!item) continue;
624
676
 
625
- // Check if this item uses claude tool and claude is running
626
- if (item.tool === 'claude' && check.claudeProcesses > 0) {
627
- this.queue.unshift(item);
628
- this.log(`Claude tool item queued but claude running, waiting...`);
629
- await this.sleep(QUEUE_CONFIG.CONSUMER_POLL_INTERVAL_MS);
630
- continue;
631
- }
677
+ // NOTE: Running claude processes is NOT a blocking limit by itself
678
+ // Commands can run in parallel as long as actual limits (CPU, API, etc.) are not exceeded
679
+ // The MIN_START_INTERVAL_MS ensures enough time for processes to be counted
680
+ // See: https://github.com/link-assistant/hive-mind/issues/1078
632
681
 
633
682
  // Update status to Starting
634
683
  item.setStarting();