@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 +29 -0
- package/package.json +3 -2
- package/src/github.lib.mjs +62 -69
- package/src/log-upload.lib.mjs +161 -0
- package/src/telegram-solve-queue.lib.mjs +68 -19
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
|
+
"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": "
|
|
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}\"",
|
package/src/github.lib.mjs
CHANGED
|
@@ -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
|
|
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
|
|
676
|
+
await log(' ⚠️ Could not determine repository visibility, defaulting to public', { verbose: true });
|
|
674
677
|
}
|
|
675
|
-
// Create
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
715
|
-
|
|
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
|
-
|
|
714
|
+
logUploadComment += `\n- **Reset Time**: ${limitResetTime}`;
|
|
725
715
|
}
|
|
726
716
|
|
|
727
717
|
if (sessionId) {
|
|
728
|
-
|
|
718
|
+
logUploadComment += `\n- **Session ID**: ${sessionId}`;
|
|
729
719
|
}
|
|
730
720
|
|
|
731
|
-
|
|
721
|
+
logUploadComment += '\n\n### 🔄 How to Continue\n';
|
|
732
722
|
|
|
733
723
|
if (limitResetTime) {
|
|
734
|
-
|
|
724
|
+
logUploadComment += `Once the limit resets at **${limitResetTime}**, `;
|
|
735
725
|
} else {
|
|
736
|
-
|
|
726
|
+
logUploadComment += 'Once the limit resets, ';
|
|
737
727
|
}
|
|
738
728
|
|
|
739
729
|
if (resumeCommand) {
|
|
740
|
-
|
|
730
|
+
logUploadComment += `you can resume this session by running:
|
|
741
731
|
\`\`\`bash
|
|
742
732
|
${resumeCommand}
|
|
743
733
|
\`\`\``;
|
|
744
734
|
} else if (sessionId) {
|
|
745
|
-
|
|
735
|
+
logUploadComment += `you can resume this session using session ID: \`${sessionId}\``;
|
|
746
736
|
} else {
|
|
747
|
-
|
|
737
|
+
logUploadComment += 'you can retry the operation.';
|
|
748
738
|
}
|
|
749
739
|
|
|
750
|
-
|
|
740
|
+
logUploadComment += `
|
|
751
741
|
|
|
752
|
-
📎 **Execution log uploaded as
|
|
753
|
-
🔗 [View complete execution log](${
|
|
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
|
|
759
|
-
|
|
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
|
|
765
|
-
🔗 [View complete failure log](${
|
|
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
|
|
759
|
+
// Success log format - use helper function for cost info
|
|
770
760
|
const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
|
|
771
|
-
|
|
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
|
|
774
|
-
🔗 [View complete solution draft log](${
|
|
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
|
|
779
|
-
await fs.writeFile(
|
|
780
|
-
commentResult = await $`gh ${ghCommand} comment ${targetNumber} --repo ${owner}/${repo} --body-file "${
|
|
781
|
-
await fs.unlink(
|
|
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'}
|
|
784
|
-
await log(` 🔗
|
|
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
|
|
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(
|
|
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 (
|
|
799
|
-
reportError(
|
|
800
|
-
context: '
|
|
788
|
+
} catch (uploadError) {
|
|
789
|
+
reportError(uploadError, {
|
|
790
|
+
context: 'upload_log_gh_upload_log',
|
|
801
791
|
level: 'error',
|
|
802
792
|
});
|
|
803
|
-
await log(` ❌ Error
|
|
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
|
-
|
|
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:
|
|
43
|
-
|
|
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
|
|
413
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
602
|
-
|
|
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
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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();
|