@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/agent.lib.mjs +5 -1
  8. package/src/claude.lib.mjs +5 -158
  9. package/src/claude.session-tokens.lib.mjs +180 -0
  10. package/src/codex.diagnostics.lib.mjs +135 -0
  11. package/src/codex.lib.mjs +8 -121
  12. package/src/config.lib.mjs +9 -0
  13. package/src/docker-sidecar.lib.mjs +276 -0
  14. package/src/formal-ai-maintenance.lib.mjs +2 -14
  15. package/src/formal-ai-sidecar.lib.mjs +17 -137
  16. package/src/gemini.lib.mjs +5 -1
  17. package/src/git-push-guard.lib.mjs +230 -0
  18. package/src/git-retry.lib.mjs +97 -0
  19. package/src/github-pr-idempotency.lib.mjs +83 -0
  20. package/src/github-rate-limit.lib.mjs +44 -41
  21. package/src/hive.mjs +8 -150
  22. package/src/hive.repository-fallback.lib.mjs +125 -0
  23. package/src/hive.startup-checks.lib.mjs +57 -0
  24. package/src/isolation-runner.lib.mjs +94 -287
  25. package/src/isolation-runner.parsers.lib.mjs +292 -0
  26. package/src/lib.mjs +79 -18
  27. package/src/opencode.lib.mjs +5 -1
  28. package/src/qwen.lib.mjs +5 -1
  29. package/src/router-isolation.lib.mjs +496 -0
  30. package/src/router-logs.lib.mjs +143 -0
  31. package/src/router-maintenance.lib.mjs +77 -0
  32. package/src/router-session-drain.lib.mjs +153 -0
  33. package/src/router-sidecar.lib.mjs +516 -0
  34. package/src/router-task-isolation.lib.mjs +121 -0
  35. package/src/session-monitor.lib.mjs +12 -272
  36. package/src/session-monitor.queries.lib.mjs +304 -0
  37. package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
  38. package/src/solve.auto-pr.lib.mjs +40 -154
  39. package/src/solve.config.lib.mjs +11 -0
  40. package/src/solve.mjs +8 -158
  41. package/src/solve.mode.lib.mjs +191 -0
  42. package/src/task.config.lib.mjs +5 -0
  43. package/src/task.mjs +1 -0
  44. package/src/telegram-bot.mjs +18 -0
  45. package/src/telegram-solve-queue.lib.mjs +19 -272
  46. package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
  47. 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
- * Patterns matched against an error's combined message/stderr/stdout to decide
292
- * whether the failure is a transient network/edge fault that deserves a retry.
293
- * Mirrors `isTransientNetworkError` in `src/lib.mjs` (issue #1536); duplicated
294
- * here to avoid a circular import `lib.mjs` already imports from this file.
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
- * Note: HTTP 500 is deliberately matched here (and NOT in
322
- * `TRANSIENT_NETWORK_PATTERNS`) because a bare 500 from arbitrary endpoints is
323
- * too broad to retry blindly; it is only safe to treat as transient for the
324
- * compare endpoint, alongside the explicit "not_available" / "heavy server
325
- * load" markers.
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 = ['this diff is temporarily unavailable', 'temporarily unavailable due to heavy server load', 'heavy server load', 'not_available', 'http 500', 'http 502', 'http 503', 'http 504'];
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
- const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxApiRetries;
368
- const transientDelay = options.transientDelay ?? 1000;
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
- if (isTransientNetworkError(error)) {
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) return dollar(strings, ...values);
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
- * Fallback function to fetch issues from organization/user repositories
108
- * when search API hits rate limits
109
- * @param {string} owner - Organization or user name
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
- // Check system resources (disk space and RAM) before starting monitoring (skip in dry-run mode)
1402
- if (argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) {
1403
- await log('⏩ Skipping system resource check (dry-run mode or skip-tool-connection-check enabled)', {
1404
- verbose: true,
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
+ }