@link-assistant/hive-mind 1.9.0 ā 1.9.2
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 +26 -0
- package/package.json +1 -1
- package/src/telegram-accept-invitations.lib.mjs +142 -34
- package/src/telegram-solve-queue.lib.mjs +33 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.9.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d39bf3e: Fix disk threshold to use one-at-a-time mode instead of blocking all commands
|
|
8
|
+
- When disk usage exceeds threshold (90%), now allows exactly one command to run
|
|
9
|
+
- Previously, disk threshold blocked ALL commands unconditionally (like RAM/CPU)
|
|
10
|
+
- Now matches behavior of Claude API thresholds (CLAUDE_5_HOUR_SESSION_THRESHOLD, CLAUDE_WEEKLY_THRESHOLD)
|
|
11
|
+
- Allows controlled task execution during high disk usage while preventing multiple tasks from exhausting resources
|
|
12
|
+
|
|
13
|
+
Fixes #1155
|
|
14
|
+
|
|
15
|
+
## 1.9.1
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 06da02c: Improve /accept_invites command output with grouped items and real-time updates
|
|
20
|
+
|
|
21
|
+
**Changes:**
|
|
22
|
+
- Group output by "Repositories:" and "Organizations:" instead of repeating "Repository:" for each item
|
|
23
|
+
- Add clickable GitHub links for each repository and organization
|
|
24
|
+
- Implement real-time message updates after each invitation is processed
|
|
25
|
+
- Show progress indicator (e.g., "Processing GitHub Invitations (3/10)") during processing
|
|
26
|
+
|
|
27
|
+
Fixes #1148
|
|
28
|
+
|
|
3
29
|
## 1.9.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* Features:
|
|
8
8
|
* - Accepts all pending repository invitations
|
|
9
9
|
* - Accepts all pending organization invitations
|
|
10
|
-
* -
|
|
10
|
+
* - Groups output by Repositories and Organizations
|
|
11
|
+
* - Provides clickable links to repositories and organizations
|
|
12
|
+
* - Real-time progress updates during processing
|
|
11
13
|
* - Error handling with detailed error messages
|
|
12
14
|
*
|
|
13
15
|
* @see https://docs.github.com/en/rest/collaborators/invitations
|
|
@@ -28,6 +30,83 @@ function escapeMarkdown(text) {
|
|
|
28
30
|
return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Build progress message from current state
|
|
35
|
+
* @param {Object} state - Current state object
|
|
36
|
+
* @param {string[]} state.acceptedRepos - List of accepted repo names
|
|
37
|
+
* @param {string[]} state.acceptedOrgs - List of accepted org names
|
|
38
|
+
* @param {string[]} state.errors - List of errors
|
|
39
|
+
* @param {number} state.totalRepos - Total number of repo invitations
|
|
40
|
+
* @param {number} state.totalOrgs - Total number of org invitations
|
|
41
|
+
* @param {number} state.processedRepos - Number of processed repo invitations
|
|
42
|
+
* @param {number} state.processedOrgs - Number of processed org invitations
|
|
43
|
+
* @param {boolean} state.isComplete - Whether processing is complete
|
|
44
|
+
* @returns {string} Formatted message
|
|
45
|
+
*/
|
|
46
|
+
function buildProgressMessage(state) {
|
|
47
|
+
const { acceptedRepos, acceptedOrgs, errors, totalRepos, totalOrgs, processedRepos, processedOrgs, isComplete } = state;
|
|
48
|
+
|
|
49
|
+
// Calculate totals
|
|
50
|
+
const totalInvitations = totalRepos + totalOrgs;
|
|
51
|
+
const processedTotal = processedRepos + processedOrgs;
|
|
52
|
+
const acceptedTotal = acceptedRepos.length + acceptedOrgs.length;
|
|
53
|
+
|
|
54
|
+
// Build header with progress indicator
|
|
55
|
+
let message = isComplete ? 'ā
*GitHub Invitations Processed*\n\n' : `š *Processing GitHub Invitations* \\(${processedTotal}/${totalInvitations}\\)\n\n`;
|
|
56
|
+
|
|
57
|
+
// Show Repositories section if any
|
|
58
|
+
if (acceptedRepos.length > 0 || (!isComplete && totalRepos > 0)) {
|
|
59
|
+
message += '*Repositories:*\n';
|
|
60
|
+
for (const repoName of acceptedRepos) {
|
|
61
|
+
// Create clickable link: [owner/repo](https://github.com/owner/repo)
|
|
62
|
+
const escapedName = escapeMarkdown(repoName);
|
|
63
|
+
const escapedLink = escapeMarkdown(`https://github.com/${repoName}`);
|
|
64
|
+
message += ` ⢠š¦ [${escapedName}](${escapedLink})\n`;
|
|
65
|
+
}
|
|
66
|
+
// Show pending indicator if still processing repos
|
|
67
|
+
if (!isComplete && processedRepos < totalRepos) {
|
|
68
|
+
const remaining = totalRepos - processedRepos;
|
|
69
|
+
message += ` ⢠_\\.\\.\\. ${remaining} more pending_\n`;
|
|
70
|
+
}
|
|
71
|
+
message += '\n';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Show Organizations section if any
|
|
75
|
+
if (acceptedOrgs.length > 0 || (!isComplete && totalOrgs > 0)) {
|
|
76
|
+
message += '*Organizations:*\n';
|
|
77
|
+
for (const orgName of acceptedOrgs) {
|
|
78
|
+
// Create clickable link: [org](https://github.com/org)
|
|
79
|
+
const escapedName = escapeMarkdown(orgName);
|
|
80
|
+
const escapedLink = escapeMarkdown(`https://github.com/${orgName}`);
|
|
81
|
+
message += ` ⢠š¢ [${escapedName}](${escapedLink})\n`;
|
|
82
|
+
}
|
|
83
|
+
// Show pending indicator if still processing orgs
|
|
84
|
+
if (!isComplete && processedOrgs < totalOrgs) {
|
|
85
|
+
const remaining = totalOrgs - processedOrgs;
|
|
86
|
+
message += ` ⢠_\\.\\.\\. ${remaining} more pending_\n`;
|
|
87
|
+
}
|
|
88
|
+
message += '\n';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Show errors if any
|
|
92
|
+
if (errors.length > 0) {
|
|
93
|
+
message += '*Errors:*\n' + errors.map(e => ` ⢠${escapeMarkdown(e)}`).join('\n') + '\n\n';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Show summary
|
|
97
|
+
if (isComplete) {
|
|
98
|
+
if (acceptedTotal === 0 && errors.length === 0) {
|
|
99
|
+
message += 'No pending invitations found\\.';
|
|
100
|
+
} else if (acceptedTotal > 0 && errors.length === 0) {
|
|
101
|
+
message += `\nš Successfully accepted ${acceptedTotal} invitation\\(s\\)\\!`;
|
|
102
|
+
} else if (acceptedTotal > 0 && errors.length > 0) {
|
|
103
|
+
message += `\nā ļø Accepted ${acceptedTotal} invitation\\(s\\), ${errors.length} error\\(s\\)\\.`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return message;
|
|
108
|
+
}
|
|
109
|
+
|
|
31
110
|
/**
|
|
32
111
|
* Registers the /accept_invites command handler with the bot
|
|
33
112
|
* @param {Object} bot - The Telegraf bot instance
|
|
@@ -61,68 +140,97 @@ export function registerAcceptInvitesCommand(bot, options) {
|
|
|
61
140
|
reply_to_message_id: ctx.message.message_id,
|
|
62
141
|
});
|
|
63
142
|
|
|
64
|
-
const fetchingMessage = await ctx.reply('š Fetching pending GitHub invitations
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
const fetchingMessage = await ctx.reply('š Fetching pending GitHub invitations\\.\\.\\.', {
|
|
144
|
+
reply_to_message_id: ctx.message.message_id,
|
|
145
|
+
parse_mode: 'MarkdownV2',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// State for tracking progress
|
|
149
|
+
const state = {
|
|
150
|
+
acceptedRepos: [],
|
|
151
|
+
acceptedOrgs: [],
|
|
152
|
+
errors: [],
|
|
153
|
+
totalRepos: 0,
|
|
154
|
+
totalOrgs: 0,
|
|
155
|
+
processedRepos: 0,
|
|
156
|
+
processedOrgs: 0,
|
|
157
|
+
isComplete: false,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Helper to update the message safely
|
|
161
|
+
const updateMessage = async () => {
|
|
162
|
+
try {
|
|
163
|
+
const message = buildProgressMessage(state);
|
|
164
|
+
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'MarkdownV2' });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Ignore "message not modified" errors
|
|
167
|
+
if (!err.message?.includes('message is not modified')) {
|
|
168
|
+
VERBOSE && console.log(`[VERBOSE] /accept-invites: Error updating message: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
67
172
|
|
|
68
173
|
try {
|
|
69
174
|
// Fetch repository invitations
|
|
70
175
|
const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"');
|
|
71
176
|
const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
|
|
177
|
+
state.totalRepos = repoInvitations.length;
|
|
72
178
|
VERBOSE && console.log(`[VERBOSE] Found ${repoInvitations.length} pending repo invitations`);
|
|
73
179
|
|
|
74
|
-
//
|
|
180
|
+
// Fetch organization invitations
|
|
181
|
+
const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
|
|
182
|
+
const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
|
|
183
|
+
const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
|
|
184
|
+
state.totalOrgs = pendingOrgs.length;
|
|
185
|
+
VERBOSE && console.log(`[VERBOSE] Found ${pendingOrgs.length} pending org invitations`);
|
|
186
|
+
|
|
187
|
+
// Check if there are any invitations
|
|
188
|
+
if (state.totalRepos === 0 && state.totalOrgs === 0) {
|
|
189
|
+
state.isComplete = true;
|
|
190
|
+
await updateMessage();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Update to show we found invitations
|
|
195
|
+
await updateMessage();
|
|
196
|
+
|
|
197
|
+
// Accept each repo invitation with progress updates
|
|
75
198
|
for (const inv of repoInvitations) {
|
|
76
199
|
const repoName = inv.repository?.full_name || 'unknown';
|
|
77
200
|
try {
|
|
78
201
|
await exec(`gh api -X PATCH /user/repository_invitations/${inv.id}`);
|
|
79
|
-
|
|
202
|
+
state.acceptedRepos.push(repoName);
|
|
80
203
|
VERBOSE && console.log(`[VERBOSE] Accepted repo invitation: ${repoName}`);
|
|
81
204
|
} catch (e) {
|
|
82
|
-
errors.push(`š¦ ${repoName}: ${e.message}`);
|
|
205
|
+
state.errors.push(`š¦ ${repoName}: ${e.message}`);
|
|
83
206
|
VERBOSE && console.log(`[VERBOSE] Failed to accept repo invitation ${repoName}: ${e.message}`);
|
|
84
207
|
}
|
|
208
|
+
state.processedRepos++;
|
|
209
|
+
await updateMessage();
|
|
85
210
|
}
|
|
86
211
|
|
|
87
|
-
//
|
|
88
|
-
const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
|
|
89
|
-
const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
|
|
90
|
-
const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
|
|
91
|
-
VERBOSE && console.log(`[VERBOSE] Found ${pendingOrgs.length} pending org invitations`);
|
|
92
|
-
|
|
93
|
-
// Accept each org invitation
|
|
212
|
+
// Accept each org invitation with progress updates
|
|
94
213
|
for (const membership of pendingOrgs) {
|
|
95
214
|
const orgName = membership.organization?.login || 'unknown';
|
|
96
215
|
try {
|
|
97
216
|
await exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`);
|
|
98
|
-
|
|
217
|
+
state.acceptedOrgs.push(orgName);
|
|
99
218
|
VERBOSE && console.log(`[VERBOSE] Accepted org invitation: ${orgName}`);
|
|
100
219
|
} catch (e) {
|
|
101
|
-
errors.push(`š¢ ${orgName}: ${e.message}`);
|
|
220
|
+
state.errors.push(`š¢ ${orgName}: ${e.message}`);
|
|
102
221
|
VERBOSE && console.log(`[VERBOSE] Failed to accept org invitation ${orgName}: ${e.message}`);
|
|
103
222
|
}
|
|
223
|
+
state.processedOrgs++;
|
|
224
|
+
await updateMessage();
|
|
104
225
|
}
|
|
105
226
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
message += 'No pending invitations found.';
|
|
110
|
-
} else {
|
|
111
|
-
if (accepted.length > 0) {
|
|
112
|
-
message += '*Accepted:*\n' + accepted.map(a => ` ⢠${escapeMarkdown(a)}`).join('\n') + '\n\n';
|
|
113
|
-
}
|
|
114
|
-
if (errors.length > 0) {
|
|
115
|
-
message += '*Errors:*\n' + errors.map(e => ` ⢠${escapeMarkdown(e)}`).join('\n');
|
|
116
|
-
}
|
|
117
|
-
if (accepted.length > 0 && errors.length === 0) {
|
|
118
|
-
message += `\nš Successfully accepted ${accepted.length} invitation(s)!`;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown' });
|
|
227
|
+
// Final update
|
|
228
|
+
state.isComplete = true;
|
|
229
|
+
await updateMessage();
|
|
123
230
|
} catch (error) {
|
|
124
231
|
console.error('Error in /accept-invites:', error);
|
|
125
|
-
|
|
232
|
+
const escapedError = escapeMarkdown(error.message);
|
|
233
|
+
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `ā Error fetching invitations: ${escapedError}\n\nMake sure \`gh\` CLI is installed and authenticated\\.`, { parse_mode: 'MarkdownV2' });
|
|
126
234
|
}
|
|
127
235
|
});
|
|
128
236
|
}
|
|
@@ -34,16 +34,16 @@ import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getC
|
|
|
34
34
|
export const QUEUE_CONFIG = {
|
|
35
35
|
// Resource thresholds (usage ratios: 0.0 - 1.0)
|
|
36
36
|
// All thresholds use >= comparison (inclusive)
|
|
37
|
-
RAM_THRESHOLD: 0.65, //
|
|
37
|
+
RAM_THRESHOLD: 0.65, // Enqueue if RAM usage >= 65%
|
|
38
38
|
// CPU threshold uses 5-minute load average, not instantaneous CPU usage
|
|
39
|
-
CPU_THRESHOLD: 0.
|
|
40
|
-
DISK_THRESHOLD: 0.
|
|
39
|
+
CPU_THRESHOLD: 0.65, // Enqueue if 5-minute load average >= 65% of CPU count
|
|
40
|
+
DISK_THRESHOLD: 0.9, // One-at-a-time if disk usage >= 90%
|
|
41
41
|
|
|
42
42
|
// API limit thresholds (usage ratios: 0.0 - 1.0)
|
|
43
43
|
// All thresholds use >= comparison (inclusive)
|
|
44
|
-
CLAUDE_5_HOUR_SESSION_THRESHOLD: 0.85, //
|
|
44
|
+
CLAUDE_5_HOUR_SESSION_THRESHOLD: 0.85, // One-at-a-time if 5-hour limit >= 85%
|
|
45
45
|
CLAUDE_WEEKLY_THRESHOLD: 0.98, // One-at-a-time if weekly limit >= 98%
|
|
46
|
-
GITHUB_API_THRESHOLD: 0.8, //
|
|
46
|
+
GITHUB_API_THRESHOLD: 0.8, // Enqueue if GitHub >= 80% with parallel claude
|
|
47
47
|
|
|
48
48
|
// Timing
|
|
49
49
|
// MIN_START_INTERVAL_MS: Time to allow solve command to start actual claude process
|
|
@@ -431,11 +431,15 @@ export class SolveQueue {
|
|
|
431
431
|
this.recordThrottle('claude_running');
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
-
// Check system resources (
|
|
435
|
-
|
|
434
|
+
// Check system resources (RAM, CPU block unconditionally; disk uses one-at-a-time mode)
|
|
435
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
436
|
+
const resourceCheck = await this.checkSystemResources(totalProcessing);
|
|
436
437
|
if (!resourceCheck.ok) {
|
|
437
438
|
reasons.push(...resourceCheck.reasons);
|
|
438
439
|
}
|
|
440
|
+
if (resourceCheck.oneAtATime) {
|
|
441
|
+
oneAtATime = true;
|
|
442
|
+
}
|
|
439
443
|
|
|
440
444
|
// Check API limits (pass hasRunningClaude and totalProcessing for uniform checking)
|
|
441
445
|
const limitCheck = await this.checkApiLimits(hasRunningClaude, totalProcessing);
|
|
@@ -478,17 +482,22 @@ export class SolveQueue {
|
|
|
478
482
|
* This provides a more stable metric that isn't affected by brief spikes
|
|
479
483
|
* during claude process startup.
|
|
480
484
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
+
* Resource threshold modes:
|
|
486
|
+
* - RAM_THRESHOLD: Enqueue mode - blocks all commands unconditionally
|
|
487
|
+
* - CPU_THRESHOLD: Enqueue mode - blocks all commands unconditionally
|
|
488
|
+
* - DISK_THRESHOLD: One-at-a-time mode - allows exactly one command when nothing is processing
|
|
489
|
+
*
|
|
490
|
+
* See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
485
491
|
*
|
|
486
|
-
* @
|
|
492
|
+
* @param {number} totalProcessing - Total processing count (queue + external claude processes)
|
|
493
|
+
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
|
|
487
494
|
*/
|
|
488
|
-
async checkSystemResources() {
|
|
495
|
+
async checkSystemResources(totalProcessing = 0) {
|
|
489
496
|
const reasons = [];
|
|
497
|
+
let oneAtATime = false;
|
|
490
498
|
|
|
491
499
|
// Check RAM (using cached value)
|
|
500
|
+
// Enqueue mode: blocks all commands unconditionally
|
|
492
501
|
const memResult = await getCachedMemoryInfo(this.verbose);
|
|
493
502
|
if (memResult.success) {
|
|
494
503
|
const usedRatio = memResult.memory.usedPercentage / 100;
|
|
@@ -499,6 +508,7 @@ export class SolveQueue {
|
|
|
499
508
|
}
|
|
500
509
|
|
|
501
510
|
// Check CPU using 5-minute load average (more stable than 1-minute)
|
|
511
|
+
// Enqueue mode: blocks all commands unconditionally
|
|
502
512
|
// Cache TTL is 2 minutes, which is appropriate for this metric
|
|
503
513
|
const cpuResult = await getCachedCpuInfo(this.verbose);
|
|
504
514
|
if (cpuResult.success) {
|
|
@@ -522,22 +532,26 @@ export class SolveQueue {
|
|
|
522
532
|
}
|
|
523
533
|
|
|
524
534
|
// Check disk space (using cached value)
|
|
525
|
-
//
|
|
526
|
-
// Unlike
|
|
527
|
-
//
|
|
528
|
-
// See: https://github.com/link-assistant/hive-mind/issues/
|
|
535
|
+
// One-at-a-time mode: allows exactly one command when nothing is processing
|
|
536
|
+
// Unlike RAM and CPU which block unconditionally, disk uses one-at-a-time mode
|
|
537
|
+
// because we cannot predict how much disk space a task will use
|
|
538
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1155
|
|
529
539
|
const diskResult = await getCachedDiskInfo(this.verbose);
|
|
530
540
|
if (diskResult.success) {
|
|
531
541
|
// Calculate usage from free percentage
|
|
532
542
|
const usedPercent = 100 - diskResult.diskSpace.freePercentage;
|
|
533
543
|
const usedRatio = usedPercent / 100;
|
|
534
544
|
if (usedRatio >= QUEUE_CONFIG.DISK_THRESHOLD) {
|
|
535
|
-
|
|
545
|
+
oneAtATime = true;
|
|
536
546
|
this.recordThrottle('disk_high');
|
|
547
|
+
// Only block if something is already processing (one-at-a-time mode)
|
|
548
|
+
if (totalProcessing > 0) {
|
|
549
|
+
reasons.push(formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.DISK_THRESHOLD) + ' (waiting for current command)');
|
|
550
|
+
}
|
|
537
551
|
}
|
|
538
552
|
}
|
|
539
553
|
|
|
540
|
-
return { ok: reasons.length === 0, reasons };
|
|
554
|
+
return { ok: reasons.length === 0, reasons, oneAtATime };
|
|
541
555
|
}
|
|
542
556
|
|
|
543
557
|
/**
|