@link-assistant/hive-mind 2.13.4 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/gemini.lib.mjs +5 -1
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +94 -287
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
|
@@ -21,6 +21,8 @@ import { promisify } from 'node:util';
|
|
|
21
21
|
import { exec as execCb } from 'node:child_process';
|
|
22
22
|
|
|
23
23
|
import { limitReset, retryLimits } from './config.lib.mjs';
|
|
24
|
+
import { matchGitNetworkCommand } from './git-retry.lib.mjs';
|
|
25
|
+
import { collectErrorText, describeTransientError, formatTransientDiagnostics, isTransientNetworkError, GITHUB_SERVER_TRANSIENT_PATTERNS } from './transient-errors.lib.mjs';
|
|
24
26
|
|
|
25
27
|
const exec = promisify(execCb);
|
|
26
28
|
|
|
@@ -34,24 +36,6 @@ const githubRateLimitLogging = {
|
|
|
34
36
|
lastUsageByResource: null,
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
/**
|
|
38
|
-
* Pull every plausible string out of a thrown error/result so pattern matches
|
|
39
|
-
* survive whatever shape the upstream caller gave us (Error, exec result with
|
|
40
|
-
* stdout/stderr, command-stream result, plain string, etc.).
|
|
41
|
-
*/
|
|
42
|
-
const collectErrorText = error => {
|
|
43
|
-
if (!error) return '';
|
|
44
|
-
if (typeof error === 'string') return error;
|
|
45
|
-
const parts = [];
|
|
46
|
-
if (typeof error.message === 'string') parts.push(error.message);
|
|
47
|
-
if (typeof error.stderr === 'string') parts.push(error.stderr);
|
|
48
|
-
else if (error.stderr && typeof error.stderr.toString === 'function') parts.push(error.stderr.toString());
|
|
49
|
-
if (typeof error.stdout === 'string') parts.push(error.stdout);
|
|
50
|
-
else if (error.stdout && typeof error.stdout.toString === 'function') parts.push(error.stdout.toString());
|
|
51
|
-
if (error.cause) parts.push(collectErrorText(error.cause));
|
|
52
|
-
return parts.join('\n');
|
|
53
|
-
};
|
|
54
|
-
|
|
55
39
|
/**
|
|
56
40
|
* Detect whether `error` represents a GitHub rate-limit response.
|
|
57
41
|
* Recognises both primary (5,000/hr) and secondary (abuse-detection) forms.
|
|
@@ -288,22 +272,18 @@ const sleepWithCountdown = async (ms, log) => {
|
|
|
288
272
|
};
|
|
289
273
|
|
|
290
274
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
275
|
+
* Transient-fault classification now lives in `src/transient-errors.lib.mjs`
|
|
276
|
+
* (issue #2168). Both this module and `src/lib.mjs` import from that leaf
|
|
277
|
+
* module so a pattern added once applies everywhere; previously each file kept
|
|
278
|
+
* its own copy of the list and `GraphQL: Something went wrong while executing
|
|
279
|
+
* your query` was missing from both.
|
|
295
280
|
*
|
|
296
281
|
* Issue #1756: `gh pr create` failed with `HTTP 504: 504 Gateway Timeout
|
|
297
282
|
* (https://api.github.com/graphql)`. `execGhWithRetry`/`ghWithRateLimitRetry`
|
|
298
283
|
* only handled rate-limit errors before — a single 504 was fatal.
|
|
284
|
+
* Issue #2168: the same call failed with GitHub's GraphQL internal error,
|
|
285
|
+
* which is not an HTTP status at all and so was not covered by #1756.
|
|
299
286
|
*/
|
|
300
|
-
const TRANSIENT_NETWORK_PATTERNS = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof'];
|
|
301
|
-
|
|
302
|
-
const isTransientNetworkError = error => {
|
|
303
|
-
const text = collectErrorText(error).toLowerCase();
|
|
304
|
-
if (!text) return false;
|
|
305
|
-
return TRANSIENT_NETWORK_PATTERNS.some(pattern => text.includes(pattern));
|
|
306
|
-
};
|
|
307
287
|
|
|
308
288
|
/**
|
|
309
289
|
* Patterns that identify a *transient* failure of GitHub's compare/diff
|
|
@@ -318,13 +298,15 @@ const isTransientNetworkError = error => {
|
|
|
318
298
|
* used to treat this as fatal and abort the whole session. These patterns let
|
|
319
299
|
* callers recognise the transient case and degrade gracefully instead.
|
|
320
300
|
*
|
|
321
|
-
*
|
|
322
|
-
* `
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
301
|
+
* Issue #2168: these markers are now the shared
|
|
302
|
+
* `GITHUB_SERVER_TRANSIENT_PATTERNS` list. HTTP 500 used to be matched only
|
|
303
|
+
* here, on the theory that a bare 500 is too broad to retry blindly. That
|
|
304
|
+
* carve-out is what let GitHub's GraphQL internal error abort `gh pr create`,
|
|
305
|
+
* so 5xx/GraphQL-internal responses are now retryable everywhere; writes that
|
|
306
|
+
* could double-apply are made idempotent at the call site instead (see
|
|
307
|
+
* `src/github-pr-idempotency.lib.mjs`).
|
|
326
308
|
*/
|
|
327
|
-
const TRANSIENT_COMPARE_API_PATTERNS =
|
|
309
|
+
const TRANSIENT_COMPARE_API_PATTERNS = GITHUB_SERVER_TRANSIENT_PATTERNS;
|
|
328
310
|
|
|
329
311
|
/**
|
|
330
312
|
* Detect whether `error` represents a transient failure of GitHub's
|
|
@@ -364,8 +346,10 @@ const isTransientCompareApiError = error => {
|
|
|
364
346
|
*/
|
|
365
347
|
export const ghWithRateLimitRetry = async (fn, options = {}) => {
|
|
366
348
|
const maxAttempts = options.maxAttempts ?? retryLimits.maxApiRetries;
|
|
367
|
-
|
|
368
|
-
|
|
349
|
+
// Issue #2168: the transient budget used to reuse `maxApiRetries` (3 attempts
|
|
350
|
+
// at 1s + 2s). GitHub's GraphQL internal errors routinely outlast that.
|
|
351
|
+
const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxGitHubTransientRetries;
|
|
352
|
+
const transientDelay = options.transientDelay ?? retryLimits.initialGitHubTransientDelayMs;
|
|
369
353
|
const transientBackoff = options.transientBackoff ?? 2;
|
|
370
354
|
const label = options.label || 'gh';
|
|
371
355
|
const log = options.log || (msg => console.warn(msg));
|
|
@@ -403,18 +387,24 @@ export const ghWithRateLimitRetry = async (fn, options = {}) => {
|
|
|
403
387
|
continue;
|
|
404
388
|
}
|
|
405
389
|
|
|
406
|
-
|
|
390
|
+
// Issue #2168: classify once, and log the classification on every path —
|
|
391
|
+
// including the "we are not retrying this" path — so the next unknown
|
|
392
|
+
// failure mode can be diagnosed from the session log without a rerun.
|
|
393
|
+
const description = describeTransientError(error);
|
|
394
|
+
|
|
395
|
+
if (description.transient) {
|
|
407
396
|
transientAttempts++;
|
|
408
397
|
if (transientAttempts >= transientMaxAttempts) {
|
|
409
|
-
await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up
|
|
398
|
+
await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up. [${formatTransientDiagnostics(description)}]`));
|
|
410
399
|
throw error;
|
|
411
400
|
}
|
|
412
401
|
const waitMs = transientDelay * Math.pow(transientBackoff, transientAttempts - 1);
|
|
413
|
-
await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s
|
|
402
|
+
await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s... [${formatTransientDiagnostics(description)}]`));
|
|
414
403
|
await sleepWithCountdown(waitMs, log);
|
|
415
404
|
continue;
|
|
416
405
|
}
|
|
417
406
|
|
|
407
|
+
await Promise.resolve(log(`ℹ️ ${label}: error is not retryable [${formatTransientDiagnostics(description)}]; propagating to caller.`));
|
|
418
408
|
throw error;
|
|
419
409
|
}
|
|
420
410
|
}
|
|
@@ -480,7 +470,20 @@ export const wrapDollarWithGhRetry = (dollar, options = {}) => {
|
|
|
480
470
|
if (i < values.length) preview += String(values[i] ?? '');
|
|
481
471
|
}
|
|
482
472
|
const isGh = /^\s*gh(?:\s|$)/.test(preview);
|
|
483
|
-
if (!isGh)
|
|
473
|
+
if (!isGh) {
|
|
474
|
+
// Issue #2168: "retry for any git/github operation". Network-facing git
|
|
475
|
+
// commands (`git push`/`fetch`/`pull`/`ls-remote`) get the same treatment
|
|
476
|
+
// here so every module already using the wrapped `$` is covered without
|
|
477
|
+
// touching its call sites. Everything else passes straight through.
|
|
478
|
+
const gitSubcommand = matchGitNetworkCommand(preview);
|
|
479
|
+
if (gitSubcommand) {
|
|
480
|
+
return (async () => {
|
|
481
|
+
const { gitCmdRetry } = await import('./lib.mjs');
|
|
482
|
+
return gitCmdRetry(() => dollar(strings, ...values), { label: `$git (${gitSubcommand})`, ...options });
|
|
483
|
+
})();
|
|
484
|
+
}
|
|
485
|
+
return dollar(strings, ...values);
|
|
486
|
+
}
|
|
484
487
|
return ghWithRateLimitRetry(() => dollar(strings, ...values), {
|
|
485
488
|
label: `$gh (${preview.trim().split(/\s+/).slice(0, 3).join(' ')})`,
|
|
486
489
|
...options,
|
package/src/hive.mjs
CHANGED
|
@@ -103,125 +103,10 @@ if (isRunningDirectly) {
|
|
|
103
103
|
const commandName = process.argv[1] ? process.argv[1].split('/').pop() : '';
|
|
104
104
|
const isLocalScript = commandName.endsWith('.mjs');
|
|
105
105
|
const solveCommand = isLocalScript ? './solve.mjs' : 'solve';
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
* @param {string} scope - 'organization' or 'user'
|
|
111
|
-
* @param {string} monitorTag - Label to filter by (optional)
|
|
112
|
-
* @param {boolean} allIssues - Whether to fetch all issues or only labeled ones
|
|
113
|
-
* @returns {Promise<Array>} Array of issues
|
|
114
|
-
*/
|
|
115
|
-
async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
|
|
116
|
-
try {
|
|
117
|
-
await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
|
|
118
|
-
// Strategy 1: Try GraphQL approach first (faster but has limitations)
|
|
119
|
-
// Only try GraphQL for "all issues" mode, not for labeled issues
|
|
120
|
-
if (fetchAllIssues) {
|
|
121
|
-
const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
|
|
122
|
-
if (graphqlResult.success) {
|
|
123
|
-
await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
|
|
124
|
-
return graphqlResult.issues;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
|
|
128
|
-
await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
|
|
129
|
-
|
|
130
|
-
// Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
|
|
131
|
-
let repoListCmd;
|
|
132
|
-
if (scope === 'organization') {
|
|
133
|
-
repoListCmd = `gh api orgs/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
134
|
-
} else {
|
|
135
|
-
repoListCmd = `gh api users/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
136
|
-
}
|
|
137
|
-
await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
|
|
138
|
-
await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
|
|
139
|
-
// Add delay for rate limiting
|
|
140
|
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
141
|
-
// #1756: route through execGhWithRetry for transient 5xx + rate-limit
|
|
142
|
-
const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
|
|
143
|
-
execOptions: { encoding: 'utf8', env: process.env },
|
|
144
|
-
label: `gh api ${scope} repos (paginated)`,
|
|
145
|
-
});
|
|
146
|
-
// Parse the output line by line, as gh api with --jq outputs one JSON object per line
|
|
147
|
-
const repoLines = repoOutput
|
|
148
|
-
.trim()
|
|
149
|
-
.split('\n')
|
|
150
|
-
.filter(line => line.trim());
|
|
151
|
-
const allRepositories = repoLines.map(line => JSON.parse(line));
|
|
152
|
-
await log(` 📊 Found ${allRepositories.length} repositories`);
|
|
153
|
-
// Filter repositories to only include those owned by the target user/org
|
|
154
|
-
const ownedRepositories = allRepositories.filter(repo => {
|
|
155
|
-
const repoOwner = repo.owner?.login || repo.owner;
|
|
156
|
-
return repoOwner === owner;
|
|
157
|
-
});
|
|
158
|
-
const unownedCount = allRepositories.length - ownedRepositories.length;
|
|
159
|
-
if (unownedCount > 0) {
|
|
160
|
-
await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
|
|
161
|
-
}
|
|
162
|
-
// Filter out archived repositories from owned repositories
|
|
163
|
-
const repositories = ownedRepositories.filter(repo => !repo.isArchived);
|
|
164
|
-
const archivedCount = ownedRepositories.length - repositories.length;
|
|
165
|
-
if (archivedCount > 0) {
|
|
166
|
-
await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
|
|
167
|
-
}
|
|
168
|
-
await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
|
|
169
|
-
let collectedIssues = [];
|
|
170
|
-
let processedRepos = 0;
|
|
171
|
-
// Process repositories in batches to avoid overwhelming the API
|
|
172
|
-
for (const repo of repositories) {
|
|
173
|
-
try {
|
|
174
|
-
const repoName = repo.name;
|
|
175
|
-
const ownerName = repo.owner?.login || owner;
|
|
176
|
-
await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
|
|
177
|
-
// Build the appropriate issue list command
|
|
178
|
-
let issueCmd;
|
|
179
|
-
if (fetchAllIssues) {
|
|
180
|
-
issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --json url,title,number,createdAt`;
|
|
181
|
-
} else {
|
|
182
|
-
issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --label "${monitorTag}" --json url,title,number,createdAt`;
|
|
183
|
-
}
|
|
184
|
-
// Add delay between repository requests
|
|
185
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
186
|
-
const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
|
|
187
|
-
// Add repository information to each issue
|
|
188
|
-
const issuesWithRepo = repoIssues.map(issue => ({
|
|
189
|
-
...issue,
|
|
190
|
-
repository: {
|
|
191
|
-
name: repoName,
|
|
192
|
-
owner: { login: ownerName },
|
|
193
|
-
},
|
|
194
|
-
}));
|
|
195
|
-
collectedIssues.push(...issuesWithRepo);
|
|
196
|
-
processedRepos++;
|
|
197
|
-
if (issuesWithRepo.length > 0) {
|
|
198
|
-
await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
|
|
199
|
-
}
|
|
200
|
-
} catch (repoError) {
|
|
201
|
-
reportError(repoError, {
|
|
202
|
-
context: 'fetchIssuesFromRepositories',
|
|
203
|
-
repo: repo.name,
|
|
204
|
-
operation: 'fetch_repo_issues',
|
|
205
|
-
});
|
|
206
|
-
await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, {
|
|
207
|
-
verbose: true,
|
|
208
|
-
});
|
|
209
|
-
// Continue with other repositories
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
|
|
213
|
-
return collectedIssues;
|
|
214
|
-
} catch (error) {
|
|
215
|
-
reportError(error, {
|
|
216
|
-
context: 'fetchIssuesFromRepositories',
|
|
217
|
-
owner,
|
|
218
|
-
scope,
|
|
219
|
-
operation: 'repository_fallback',
|
|
220
|
-
});
|
|
221
|
-
await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
}
|
|
106
|
+
// Repository-by-repository fallback lives in its own module so hive.mjs stays
|
|
107
|
+
// under the 1350-line early-warning threshold (issue #2175, warning from #1593).
|
|
108
|
+
const repositoryFallbackLib = await import('./hive.repository-fallback.lib.mjs');
|
|
109
|
+
const fetchIssuesFromRepositories = repositoryFallbackLib.createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError });
|
|
225
110
|
// Configure command line arguments - GitHub URL as positional argument
|
|
226
111
|
const rawArgs = normalizeCliArgs(hideBin(process.argv));
|
|
227
112
|
// Use .parse() instead of .argv to ensure .strict() mode works correctly
|
|
@@ -1398,37 +1283,10 @@ if (isRunningDirectly) {
|
|
|
1398
1283
|
delegateSignalHandling(true);
|
|
1399
1284
|
process.on('SIGINT', () => gracefulShutdown('interrupt'));
|
|
1400
1285
|
process.on('SIGTERM', () => gracefulShutdown('termination'));
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
});
|
|
1406
|
-
await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', {
|
|
1407
|
-
verbose: true,
|
|
1408
|
-
});
|
|
1409
|
-
} else {
|
|
1410
|
-
// Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
|
|
1411
|
-
// start, and report an exhausted disk as the environment condition it is (exit 75) instead of
|
|
1412
|
-
// a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
|
|
1413
|
-
// directly, which skips the log-flushing safeExit path and printed no actionable reason.
|
|
1414
|
-
const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
|
|
1415
|
-
const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
|
|
1416
|
-
if (!startupDiskGuard.ok) {
|
|
1417
|
-
await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
|
|
1418
|
-
await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
|
|
1419
|
-
await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
|
|
1420
|
-
}
|
|
1421
|
-
const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
|
|
1422
|
-
if (!systemCheck.success) {
|
|
1423
|
-
await safeExit(1, 'System resource check failed');
|
|
1424
|
-
}
|
|
1425
|
-
// Validate the selected AI tool connection before starting monitoring with the same model that will be used
|
|
1426
|
-
const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
|
|
1427
|
-
if (!isToolConnected) {
|
|
1428
|
-
await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
|
|
1429
|
-
await safeExit(1, 'Error occurred');
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1286
|
+
// Pre-flight checks live in hive.startup-checks.lib.mjs so hive.mjs stays under
|
|
1287
|
+
// the 1350-line early-warning threshold (issue #2175, warning from #1593).
|
|
1288
|
+
const startupChecksLib = await import('./hive.startup-checks.lib.mjs');
|
|
1289
|
+
await startupChecksLib.runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE });
|
|
1432
1290
|
// Wrap monitor function with Sentry error tracking
|
|
1433
1291
|
const monitorWithSentry = !argv.sentry ? monitor : withSentry(monitor, 'hive.monitor', 'command');
|
|
1434
1292
|
// Start monitoring
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-by-repository issue fetching fallback for the hive.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
|
|
5
|
+
* 1350-line early-warning threshold that protects concurrent merges (#1593).
|
|
6
|
+
* The logic is unchanged; it now takes its collaborators as parameters instead
|
|
7
|
+
* of closing over the dynamically imported bindings in hive.mjs, which also
|
|
8
|
+
* makes it unit-testable (see tests/hive-repository-fallback-2175.test.mjs).
|
|
9
|
+
*
|
|
10
|
+
* Used when GitHub's search API is rate-limited: rather than giving up, every
|
|
11
|
+
* repository owned by the org/user is listed and queried directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the repository-fallback fetcher with its dependencies bound.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {Function} deps.log
|
|
19
|
+
* @param {Function} deps.cleanErrorMessage
|
|
20
|
+
* @param {Function} deps.tryFetchIssuesWithGraphQL
|
|
21
|
+
* @param {Function} deps.execGhWithRetry
|
|
22
|
+
* @param {Function} deps.fetchAllIssuesWithPagination
|
|
23
|
+
* @param {Function} deps.reportError
|
|
24
|
+
* @param {(ms: number) => Promise<void>} [deps.sleeper] delay between API calls
|
|
25
|
+
* @returns {(owner: string, scope: string, monitorTag?: string, fetchAllIssues?: boolean) => Promise<Array>}
|
|
26
|
+
*/
|
|
27
|
+
export function createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError, sleeper = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
|
|
28
|
+
/**
|
|
29
|
+
* Fallback function to fetch issues from organization/user repositories
|
|
30
|
+
* when search API hits rate limits
|
|
31
|
+
* @param {string} owner - Organization or user name
|
|
32
|
+
* @param {string} scope - 'organization' or 'user'
|
|
33
|
+
* @param {string} monitorTag - Label to filter by (optional)
|
|
34
|
+
* @param {boolean} fetchAllIssues - Whether to fetch all issues or only labeled ones
|
|
35
|
+
* @returns {Promise<Array>} Array of issues
|
|
36
|
+
*/
|
|
37
|
+
return async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
|
|
38
|
+
try {
|
|
39
|
+
await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
|
|
40
|
+
// Strategy 1: Try GraphQL approach first (faster but has limitations)
|
|
41
|
+
// Only try GraphQL for "all issues" mode, not for labeled issues
|
|
42
|
+
if (fetchAllIssues) {
|
|
43
|
+
const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
|
|
44
|
+
if (graphqlResult.success) {
|
|
45
|
+
await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
|
|
46
|
+
return graphqlResult.issues;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
|
|
50
|
+
await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
|
|
51
|
+
|
|
52
|
+
// Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
|
|
53
|
+
const scopePath = scope === 'organization' ? 'orgs' : 'users';
|
|
54
|
+
const repoListCmd = `gh api ${scopePath}/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
|
|
55
|
+
await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
|
|
56
|
+
await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
|
|
57
|
+
// Add delay for rate limiting
|
|
58
|
+
await sleeper(2000);
|
|
59
|
+
// #1756: route through execGhWithRetry for transient 5xx + rate-limit
|
|
60
|
+
const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
|
|
61
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
62
|
+
label: `gh api ${scope} repos (paginated)`,
|
|
63
|
+
});
|
|
64
|
+
// Parse the output line by line, as gh api with --jq outputs one JSON object per line
|
|
65
|
+
const repoLines = repoOutput
|
|
66
|
+
.trim()
|
|
67
|
+
.split('\n')
|
|
68
|
+
.filter(line => line.trim());
|
|
69
|
+
const allRepositories = repoLines.map(line => JSON.parse(line));
|
|
70
|
+
await log(` 📊 Found ${allRepositories.length} repositories`);
|
|
71
|
+
// Filter repositories to only include those owned by the target user/org
|
|
72
|
+
const ownedRepositories = allRepositories.filter(repo => {
|
|
73
|
+
const repoOwner = repo.owner?.login || repo.owner;
|
|
74
|
+
return repoOwner === owner;
|
|
75
|
+
});
|
|
76
|
+
const unownedCount = allRepositories.length - ownedRepositories.length;
|
|
77
|
+
if (unownedCount > 0) {
|
|
78
|
+
await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
|
|
79
|
+
}
|
|
80
|
+
// Filter out archived repositories from owned repositories
|
|
81
|
+
const repositories = ownedRepositories.filter(repo => !repo.isArchived);
|
|
82
|
+
const archivedCount = ownedRepositories.length - repositories.length;
|
|
83
|
+
if (archivedCount > 0) {
|
|
84
|
+
await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
|
|
85
|
+
}
|
|
86
|
+
await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
|
|
87
|
+
const collectedIssues = [];
|
|
88
|
+
let processedRepos = 0;
|
|
89
|
+
// Process repositories in batches to avoid overwhelming the API
|
|
90
|
+
for (const repo of repositories) {
|
|
91
|
+
try {
|
|
92
|
+
const repoName = repo.name;
|
|
93
|
+
const ownerName = repo.owner?.login || owner;
|
|
94
|
+
await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
|
|
95
|
+
// Build the appropriate issue list command
|
|
96
|
+
const labelFilter = fetchAllIssues ? '' : ` --label "${monitorTag}"`;
|
|
97
|
+
const issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open${labelFilter} --json url,title,number,createdAt`;
|
|
98
|
+
// Add delay between repository requests
|
|
99
|
+
await sleeper(1000);
|
|
100
|
+
const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
|
|
101
|
+
// Add repository information to each issue
|
|
102
|
+
const issuesWithRepo = repoIssues.map(issue => ({
|
|
103
|
+
...issue,
|
|
104
|
+
repository: { name: repoName, owner: { login: ownerName } },
|
|
105
|
+
}));
|
|
106
|
+
collectedIssues.push(...issuesWithRepo);
|
|
107
|
+
processedRepos++;
|
|
108
|
+
if (issuesWithRepo.length > 0) {
|
|
109
|
+
await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
|
|
110
|
+
}
|
|
111
|
+
} catch (repoError) {
|
|
112
|
+
reportError(repoError, { context: 'fetchIssuesFromRepositories', repo: repo.name, operation: 'fetch_repo_issues' });
|
|
113
|
+
await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, { verbose: true });
|
|
114
|
+
// Continue with other repositories
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
|
|
118
|
+
return collectedIssues;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
reportError(error, { context: 'fetchIssuesFromRepositories', owner, scope, operation: 'repository_fallback' });
|
|
121
|
+
await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-flight checks the hive runs before it starts monitoring.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
|
|
5
|
+
* 1350-line early-warning threshold that protects concurrent merges (#1593).
|
|
6
|
+
* Behaviour is unchanged; the collaborators that used to be closure bindings in
|
|
7
|
+
* hive.mjs are parameters, which also makes the sequence unit-testable
|
|
8
|
+
* (see tests/hive-startup-checks-2175.test.mjs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run the startup checks, exiting through `safeExit` when one fails.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} deps
|
|
15
|
+
* @param {object} deps.argv parsed CLI arguments
|
|
16
|
+
* @param {Function} deps.log
|
|
17
|
+
* @param {Function} deps.safeExit
|
|
18
|
+
* @param {Function} deps.ensureDiskSpaceForWorker
|
|
19
|
+
* @param {Function} deps.checkSystem
|
|
20
|
+
* @param {Function} deps.validateToolConnection
|
|
21
|
+
* @param {Function} deps.validateClaudeConnection
|
|
22
|
+
* @param {number} deps.EXIT_CODE_INSUFFICIENT_DISK_SPACE
|
|
23
|
+
* @returns {Promise<{skipped: boolean}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE }) {
|
|
26
|
+
if (argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) {
|
|
27
|
+
await log('⏩ Skipping system resource check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
|
|
28
|
+
await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
|
|
29
|
+
return { skipped: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
|
|
33
|
+
// start, and report an exhausted disk as the environment condition it is (exit 75) instead of
|
|
34
|
+
// a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
|
|
35
|
+
// directly, which skips the log-flushing safeExit path and printed no actionable reason.
|
|
36
|
+
const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
|
|
37
|
+
const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
|
|
38
|
+
if (!startupDiskGuard.ok) {
|
|
39
|
+
await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
|
|
40
|
+
await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
|
|
41
|
+
await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
|
|
45
|
+
if (!systemCheck.success) {
|
|
46
|
+
await safeExit(1, 'System resource check failed');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Validate the selected AI tool connection before starting monitoring with the same model that will be used
|
|
50
|
+
const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
|
|
51
|
+
if (!isToolConnected) {
|
|
52
|
+
await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
|
|
53
|
+
await safeExit(1, 'Error occurred');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { skipped: false };
|
|
57
|
+
}
|