@link-assistant/hive-mind 2.12.5 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.13.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 736f6f9: Stop reporting a host that ran out of disk space as failed tasks: `/hive` now checks free space before each task, requeues the task as a deferral while peers are still running, reclaims only temp directories that no process is using, and exits with `EX_TEMPFAIL` (75) when work remains blocked. Also fix the false alarms around it — `getLogFile is not a function` in the restart paths, the bogus `.gitkeep` cleanup warning, benign in-session tool results and defaulted source cleanup being reported as problems, merged solution drafts being summarized as `(no PR found)`, and `--auto-cleanup` being a no-op at one call site.
8
+
9
+ ## 2.13.0
10
+
11
+ ### Minor Changes
12
+
13
+ - aaf809a: Recognize subscription/account access blocks from every supported CLI (Claude, Codex, Qwen, Gemini, opencode) as their own error class: stop the run instead of retrying or switching model, auto-commit and push the in-flight work first, report what happened and what to do in the terminal, in the `/solve` exit message and in the Telegram completion message (en/ru/zh/hi), and stop the `/hive` queue so the fleet no longer rediscovers the block once per issue.
14
+
3
15
  ## 2.12.5
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.12.5",
3
+ "version": "2.13.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -356,9 +356,11 @@ export const displaySessionTokenUsage = async ({ sessionId, tempDir, resultModel
356
356
  try {
357
357
  const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
358
358
  if (!tokenUsage) return;
359
- // Issue #1501: Log deduplication stats in verbose mode
359
+ // Issue #1501: Log deduplication stats in verbose mode.
360
+ // Issue #2160: informational, not a warning — the duplicates are a known upstream Claude Code
361
+ // accounting quirk and skipping them is exactly what keeps the token totals correct here.
360
362
  if (tokenUsage.duplicateEntriesSkipped > 0) {
361
- await log(`\n⚠️ JSONL deduplication: skipped ${tokenUsage.duplicateEntriesSkipped} duplicate entries (upstream: anthropics/claude-code#6805)`, { verbose: true });
363
+ await log(`\nℹ️ JSONL deduplication: skipped ${tokenUsage.duplicateEntriesSkipped} duplicate entries so token totals stay correct (known upstream behaviour: anthropics/claude-code#87303)`, { verbose: true });
362
364
  }
363
365
  if (tokenUsage.peakContextUsage > 0) {
364
366
  await log(`📊 Peak restored-context input: ${formatNumber(tokenUsage.peakContextUsage)} tokens`, { verbose: true });
@@ -9,6 +9,7 @@ import { isENOSPC, buildToolErrorMessage } from './lib.mjs';
9
9
  import { reportError } from './sentry.lib.mjs';
10
10
  import { timeouts, retryLimits, claudeCode, getClaudeEnv, getMaxOutputTokensForModel } from './config.lib.mjs';
11
11
  import { detectUsageLimit, formatUsageLimitMessage, isUsageLimitError } from './usage-limit.lib.mjs';
12
+ import { detectSubscriptionError, SUBSCRIPTION_BLOCKED_MARKER } from './subscription-error.lib.mjs'; // Issue #2161
12
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
13
14
  import { setupBidirectionalHandler, finalizeBidirectionalHandler, validateBidirectionalModeConfig, attachStreamingInput } from './bidirectional-interactive.lib.mjs';
14
15
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
@@ -34,6 +35,7 @@ import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
34
35
  import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.lib.mjs'; // Issue #1834 (PR #1835 feedback)
35
36
  import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
36
37
  import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
38
+ import { renameLogToSessionId } from './session-log-rename.lib.mjs'; // Issue #2160
37
39
  import { showResumeCommand } from './claude.resume-output.lib.mjs';
38
40
  import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
39
41
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
@@ -376,6 +378,10 @@ export const executeClaudeCommand = async params => {
376
378
  let isInternalServerError = false;
377
379
  let isRequestTimeout = false;
378
380
  let isRateLimitError = false; // Issue #1924: server-side 429 temporary rate limiting
381
+ // Issue #2161: account/subscription-level block (e.g. oauth_org_not_allowed).
382
+ // Terminal — never retried, never model-switched; carried out to the caller
383
+ // so /solve can stop with a specific diagnosis instead of a generic failure.
384
+ let subscriptionError = null;
379
385
  let apiMarkedNotRetryable = false;
380
386
  let resultNumTurns = 0;
381
387
  let stderrErrors = [];
@@ -388,6 +394,11 @@ export const executeClaudeCommand = async params => {
388
394
  let resultSummary = null;
389
395
  let resultModelUsage = null;
390
396
  let lastToolResultError = null;
397
+ // Issue #2160: an in-session tool failure the AI handles itself (a blocked command, its own
398
+ // Bash timeout, a bare non-zero exit status). Kept apart from lastToolResultError so it is not
399
+ // reported as the session error, but still available as the last-resort detail for a
400
+ // truncated stream that has nothing better to point at (issue #2023).
401
+ let lastBenignToolResultError = null;
391
402
  // Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
392
403
  const subAgentCalls = [];
393
404
  // Issue #1590: Map tool_use_id -> subAgentCalls index for accumulating per-call usage from parent_tool_use_id events
@@ -628,16 +639,16 @@ export const executeClaudeCommand = async params => {
628
639
  if (!sessionId && data.session_id) {
629
640
  sessionId = data.session_id;
630
641
  await log(`📌 Session ID: ${sessionId}`);
631
- let sessionLogFile;
632
- try {
633
- const currentLogFile = getLogFile();
634
- sessionLogFile = path.join(path.dirname(currentLogFile), `${sessionId}.log`);
635
- await fs.rename(currentLogFile, sessionLogFile);
636
- setLogFile(sessionLogFile);
637
- await log(`📁 Log renamed to: ${sessionLogFile}`);
638
- } catch (renameError) {
639
- reportError(renameError, { context: 'rename_session_log', sessionId, sessionLogFile, operation: 'rename_log_file' });
640
- await log(`⚠️ Could not rename log file: ${renameError.message}`, { verbose: true });
642
+ // Issue #2160: shared implementation, so restart/watch iterations rename their
643
+ // logs too and a caller that forgets the accessors gets a named reason.
644
+ const renameResult = await renameLogToSessionId({ sessionId, getLogFile, setLogFile, log });
645
+ if (!renameResult.ok && renameResult.error) {
646
+ reportError(renameResult.error, {
647
+ context: 'rename_session_log',
648
+ sessionId,
649
+ sessionLogFile: renameResult.sessionLogFile,
650
+ operation: 'rename_log_file',
651
+ });
641
652
  }
642
653
  }
643
654
  const eventFacts = collectClaudeStreamEventFacts(data);
@@ -649,9 +660,16 @@ export const executeClaudeCommand = async params => {
649
660
  await log('📝 Captured fallback summary from Claude compaction context', { verbose: true });
650
661
  }
651
662
  if (eventFacts.toolResultError) {
652
- lastToolResultError = eventFacts.toolResultError;
653
- lastMessage = eventFacts.toolResultError;
654
- await log(`⚠️ Tool result error detected: ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
663
+ // Issue #2160: an in-session tool failure the AI handles itself is not a warning,
664
+ // and it must not replace the last assistant message — that message is what a
665
+ // truncated-stream failure is reported "after".
666
+ if (eventFacts.toolResultErrorIsBenign) {
667
+ lastBenignToolResultError = eventFacts.toolResultError;
668
+ await log(`ℹ️ In-session tool result (${eventFacts.toolResultErrorCategory}): ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
669
+ } else {
670
+ lastToolResultError = eventFacts.toolResultError;
671
+ await log(`⚠️ Tool result error detected: ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
672
+ }
655
673
  }
656
674
  // Issue #1708: signal busy/idle to the bidirectional handler so
657
675
  // queue-comments-to-input mode can hold frames until the AI is
@@ -731,6 +749,24 @@ export const executeClaudeCommand = async params => {
731
749
  isRateLimitError = true;
732
750
  await log(`⚠️ Detected server-side rate limiting (429) from Claude CLI (will retry with --resume). request_id=${data.request_id || 'unknown'}`, { verbose: true });
733
751
  }
752
+ // Issue #2161: account/subscription block. `data.error` carries the
753
+ // machine-readable code ("oauth_org_not_allowed" for the reported
754
+ // case) alongside api_error_status 403 — a far stronger signal than
755
+ // the rendered sentence, so it is passed to the detector first.
756
+ if (!subscriptionError) {
757
+ subscriptionError = detectSubscriptionError({
758
+ message: lastMessage,
759
+ tool: 'claude',
760
+ errorCode: typeof data.error === 'string' ? data.error : null,
761
+ apiErrorStatus: data.api_error_status,
762
+ terminalReason: data.terminal_reason,
763
+ });
764
+ if (subscriptionError) {
765
+ // Not verbose: this is the reason the whole run is about to end.
766
+ await log(`${SUBSCRIPTION_BLOCKED_MARKER} — ${subscriptionError.label}`);
767
+ await log(` code=${subscriptionError.code || 'n/a'} http=${data.api_error_status || 'n/a'} terminal_reason=${data.terminal_reason || 'n/a'} request_id=${data.request_id || 'unknown'}`, { verbose: true });
768
+ }
769
+ }
734
770
  // Issue #1834: Detect corrupted extended-thinking-block 400 (un-resumable session).
735
771
  // Capture diagnostics (request id, content path) to aid debugging and upstream reports.
736
772
  if ((lastMessage.includes('thinking') || lastMessage.includes('redacted_thinking')) && lastMessage.includes('cannot be modified')) {
@@ -765,6 +801,26 @@ export const executeClaudeCommand = async params => {
765
801
  await log(`🤖 Sub-agent "${callEntry.description || 'unknown'}" completed: ${data.usage.total_tokens} total tokens`, { verbose: true });
766
802
  }
767
803
  }
804
+ // Issue #2161: Claude Code injects API failures as synthetic assistant
805
+ // messages flagged `is_api_error_message` and carrying the error code.
806
+ // In the reported run this arrived ~40s before the terminal result
807
+ // event, so detecting it here surfaces the diagnosis earlier.
808
+ if (data.type === 'assistant' && data.is_api_error_message === true && !subscriptionError) {
809
+ const apiErrorText = getClaudeMessageContent(data)
810
+ .filter(item => item.type === 'text' && item.text)
811
+ .map(item => item.text)
812
+ .join('\n');
813
+ subscriptionError = detectSubscriptionError({
814
+ message: apiErrorText,
815
+ tool: 'claude',
816
+ errorCode: typeof data.error === 'string' ? data.error : null,
817
+ });
818
+ if (subscriptionError) {
819
+ if (apiErrorText) lastMessage = apiErrorText;
820
+ await log(`${SUBSCRIPTION_BLOCKED_MARKER} — ${subscriptionError.label}`);
821
+ await log(` code=${subscriptionError.code || 'n/a'} request_id=${data.request_id || 'unknown'} uuid=${data.uuid || 'unknown'}`, { verbose: true });
822
+ }
823
+ }
768
824
  if (data.type === 'assistant' && data.message && data.message.content) {
769
825
  const content = getClaudeMessageContent(data);
770
826
  for (const item of content) {
@@ -860,9 +916,11 @@ export const executeClaudeCommand = async params => {
860
916
  toolUseCount += eventFacts.toolUseCountDelta;
861
917
  if (eventFacts.lastText) lastMessage = eventFacts.lastText;
862
918
  if (!resultSummary && eventFacts.compactionSummary) resultSummary = eventFacts.compactionSummary;
863
- if (eventFacts.toolResultError) {
919
+ // Issue #2160: same classification as the streaming path above.
920
+ if (eventFacts.toolResultError && eventFacts.toolResultErrorIsBenign) {
921
+ lastBenignToolResultError = eventFacts.toolResultError;
922
+ } else if (eventFacts.toolResultError) {
864
923
  lastToolResultError = eventFacts.toolResultError;
865
- lastMessage = eventFacts.toolResultError;
866
924
  }
867
925
  if (data?.type === 'result') {
868
926
  resultEventReceived = true;
@@ -963,7 +1021,7 @@ export const executeClaudeCommand = async params => {
963
1021
  }
964
1022
  if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
965
1023
  commandFailed = true;
966
- lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
1024
+ lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage, lastBenignToolResultError });
967
1025
  await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
968
1026
  }
969
1027
  const retryableLastError = classifyRetryableError(lastMessage);
@@ -977,7 +1035,12 @@ export const executeClaudeCommand = async params => {
977
1035
  }
978
1036
  // Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
979
1037
  const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || isRateLimitError || retryableLastError.isRetryable || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
980
- if ((commandFailed || isTransientError) && isTransientError) {
1038
+ // Issue #2161: an account/subscription block short-circuits every retry
1039
+ // path. Stale transient flags from earlier in the run (an overload at hour
1040
+ // one, say) must not schedule a retry that is guaranteed to fail the same
1041
+ // way — and each retry would burn another full startup against a provider
1042
+ // that has already refused the credentials.
1043
+ if ((commandFailed || isTransientError) && isTransientError && !subscriptionError) {
981
1044
  // Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general → 2min–30min
982
1045
  const isTimeoutRetry = isStartupTimeout || isActivityTimeout;
983
1046
  const maxRetries = isTimeoutRetry ? retryLimits.maxTransientErrorRetries : isRequestTimeout ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
@@ -1005,6 +1068,7 @@ export const executeClaudeCommand = async params => {
1005
1068
  resultSummary,
1006
1069
  // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
1007
1070
  errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: 'API explicitly marked error as not retryable', toolLabel: 'Claude' }), exitCode },
1071
+ subscriptionError, // Issue #2161
1008
1072
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1009
1073
  };
1010
1074
  }
@@ -1055,6 +1119,7 @@ export const executeClaudeCommand = async params => {
1055
1119
  resultSummary, // Issue #1263: Include result summary
1056
1120
  // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
1057
1121
  errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted after ${maxRetries} retries`, toolLabel: 'Claude' }), exitCode },
1122
+ subscriptionError, // Issue #2161
1058
1123
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1059
1124
  };
1060
1125
  }
@@ -1123,6 +1188,7 @@ export const executeClaudeCommand = async params => {
1123
1188
  // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content filtering policy").
1124
1189
  // Issue #1941: a lone "}" fragment at interrupt time must not become "CLAUDE execution failed with }".
1125
1190
  errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Claude command failed with exit code ${exitCode}`, toolLabel: 'Claude' }), exitCode },
1191
+ subscriptionError, // Issue #2161: terminal account block — /solve stops and preserves the work
1126
1192
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1127
1193
  };
1128
1194
  }
@@ -19,12 +19,46 @@ const normalizeToolResultError = value => {
19
19
  }
20
20
  };
21
21
 
22
+ /**
23
+ * Issue #2160: not every `tool_result` marked `is_error` says something about the session.
24
+ * Most of them are the AI's own command failing inside the session — the AI sees the result and
25
+ * carries on. Run 4c1dedd8 logged 26 "⚠️ Tool result error detected" lines, all of them of this
26
+ * kind (11 harness-blocked `sleep`s, 9 × `Exit code 143` Bash timeouts, 4 × `Exit code 1`,
27
+ * 2 × `Exit code 127`), and each one also overwrote the
28
+ * last assistant message, so a truncated stream could be reported as having failed "after:
29
+ * Blocked: sleep 240 …" instead of after what the AI actually said.
30
+ *
31
+ * Categories:
32
+ * - `harness_blocked` the AI tool's own harness refused the command (e.g. foreground sleep)
33
+ * - `command_timeout` the AI's command hit its Bash timeout (SIGTERM ⇒ exit code 143)
34
+ * - `command_exit_code` a bare non-zero exit status with no further detail
35
+ * Anything else is left unclassified and keeps being treated as a real error signal.
36
+ *
37
+ * @param {string|null} toolResultError - normalized tool_result error text
38
+ * @returns {{benign: boolean, category: string|null}}
39
+ */
40
+ export const classifyToolResultError = toolResultError => {
41
+ if (typeof toolResultError !== 'string' || !toolResultError.trim()) return { benign: false, category: null };
42
+ const text = toolResultError.trim();
43
+
44
+ if (/^Blocked:/i.test(text)) return { benign: true, category: 'harness_blocked' };
45
+ if (/Command timed out after/i.test(text)) return { benign: true, category: 'command_timeout' };
46
+ // A bare "Exit code 143" is the SIGTERM the AI tool sends when its own Bash timeout fires.
47
+ if (/^Exit code 143\.?$/i.test(text)) return { benign: true, category: 'command_timeout' };
48
+ if (/^Exit code \d+\.?$/i.test(text)) return { benign: true, category: 'command_exit_code' };
49
+
50
+ return { benign: false, category: null };
51
+ };
52
+
22
53
  export const collectClaudeStreamEventFacts = data => {
23
54
  const facts = {
24
55
  messageCountDelta: 0,
25
56
  toolUseCountDelta: 0,
26
57
  lastText: null,
27
58
  toolResultError: null,
59
+ // Issue #2160: set when toolResultError is an in-session, self-handled tool failure.
60
+ toolResultErrorIsBenign: false,
61
+ toolResultErrorCategory: null,
28
62
  compactionSummary: null,
29
63
  };
30
64
  if (!data || typeof data !== 'object') return facts;
@@ -47,6 +81,12 @@ export const collectClaudeStreamEventFacts = data => {
47
81
  facts.toolResultError = data.tool_use_result.trim();
48
82
  }
49
83
 
84
+ if (facts.toolResultError) {
85
+ const classification = classifyToolResultError(facts.toolResultError);
86
+ facts.toolResultErrorIsBenign = classification.benign;
87
+ facts.toolResultErrorCategory = classification.category;
88
+ }
89
+
50
90
  return facts;
51
91
  };
52
92
 
@@ -54,8 +94,22 @@ export const shouldFailClaudeStreamWithoutResult = ({ commandFailed, streamingIn
54
94
  return !commandFailed && !streamingInput && !resultEventReceived;
55
95
  };
56
96
 
57
- export const buildMissingClaudeResultMessage = ({ lastToolResultError, lastMessage }) => {
58
- const detail = lastToolResultError || lastMessage;
97
+ /**
98
+ * Describe a stream that ended without a terminal result event (issue #2023).
99
+ *
100
+ * Detail preference, in order (issue #2160): a real tool error explains the truncation best; the
101
+ * last thing the AI said is next; a benign in-session tool result (a blocked command, a Bash
102
+ * timeout) is only used when there is nothing else, so it stays out of the message whenever the
103
+ * assistant actually said something.
104
+ *
105
+ * @param {Object} params
106
+ * @param {string|null} [params.lastToolResultError] - last non-benign tool_result error
107
+ * @param {string|null} [params.lastMessage] - last assistant text
108
+ * @param {string|null} [params.lastBenignToolResultError] - last self-handled tool_result error
109
+ * @returns {string}
110
+ */
111
+ export const buildMissingClaudeResultMessage = ({ lastToolResultError, lastMessage, lastBenignToolResultError = null }) => {
112
+ const detail = lastToolResultError || lastMessage || lastBenignToolResultError;
59
113
  if (!detail) return 'Claude stream ended without a terminal result event';
60
114
  return `Claude stream ended without a terminal result event after: ${String(detail).slice(0, 500)}`;
61
115
  };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Disk-space guard for solver workspaces (issue #2160).
3
+ *
4
+ * Reported symptom: `hive … --all-issues` finished with `❌ 4 task(s) failed (completed: 6)` even
5
+ * though nothing was wrong with those 4 issues. The run log shows why:
6
+ *
7
+ * - the target repository was public, so solve's auto-cleanup default resolved to OFF and every
8
+ * `/tmp/gh-issue-solver-*` workspace (~10 GB each) was kept;
9
+ * - hive checked free disk space exactly once, at startup (73.2 GB free), and kept dequeuing;
10
+ * - after 6 completed tasks only 9.8 GB were left, so each remaining task tripped solve's
11
+ * pre-flight check (`❌ Insufficient disk space: 10047MB available, 10240MB required`), exited
12
+ * after ~12s, posted a "Solution Draft Failed" comment and was counted as a *task* failure.
13
+ *
14
+ * An exhausted disk is an environment condition: the task is still perfectly solvable once space is
15
+ * available. This module lets the orchestrator (a) reclaim workspaces nobody is using any more,
16
+ * (b) wait for in-flight work to release space, and (c) report the condition as a deferral instead
17
+ * of a task failure.
18
+ *
19
+ * Everything that touches the outside world (df, readdir, rm, clock, sleep) is injectable so the
20
+ * behaviour can be tested without a full disk.
21
+ *
22
+ * @see https://github.com/link-assistant/hive-mind/issues/2160
23
+ */
24
+
25
+ import fsPromises from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { execFile } from 'node:child_process';
28
+ import { promisify } from 'node:util';
29
+
30
+ const execFileAsync = promisify(execFile);
31
+
32
+ /**
33
+ * Exit code a solver uses when it refuses to start because the host is out of disk space.
34
+ * 75 is EX_TEMPFAIL ("temporary failure, the user is invited to retry") from sysexits.h — the
35
+ * closest standard meaning to "nothing is wrong with the request, retry later".
36
+ */
37
+ export const EXIT_CODE_INSUFFICIENT_DISK_SPACE = 75;
38
+
39
+ /** Prefix of the temporary directories solve clones repositories into. */
40
+ export const SOLVER_WORKSPACE_PREFIX = 'gh-issue-solver-';
41
+
42
+ export const DEFAULT_TMP_ROOT = '/tmp';
43
+
44
+ /** A workspace whose contents changed this recently is never reclaimed. */
45
+ export const DEFAULT_MIN_IDLE_MS = 5 * 60 * 1000;
46
+
47
+ const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
48
+
49
+ const defaultRemove = async targetPath => fsPromises.rm(targetPath, { recursive: true, force: true });
50
+
51
+ /**
52
+ * Free space in MB on the filesystem holding `targetPath`, or null when it cannot be determined.
53
+ * `df -Pk` is POSIX-portable output (single line per filesystem, 1K blocks).
54
+ */
55
+ export const getFreeDiskSpaceMB = async (targetPath = DEFAULT_TMP_ROOT, { exec = execFileAsync } = {}) => {
56
+ try {
57
+ const { stdout } = await exec('df', ['-Pk', targetPath]);
58
+ const lines = String(stdout).trim().split('\n');
59
+ if (lines.length < 2) return null;
60
+ const columns = lines[lines.length - 1].trim().split(/\s+/);
61
+ const availableKB = Number.parseInt(columns[3], 10);
62
+ if (!Number.isFinite(availableKB)) return null;
63
+ return Math.floor(availableKB / 1024);
64
+ } catch {
65
+ return null;
66
+ }
67
+ };
68
+
69
+ /** Every `/tmp/gh-issue-solver-*` directory, oldest modification first. */
70
+ export const listSolverWorkspaces = async ({ tmpRoot = DEFAULT_TMP_ROOT, fileSystem = fsPromises } = {}) => {
71
+ let entries;
72
+ try {
73
+ entries = await fileSystem.readdir(tmpRoot);
74
+ } catch {
75
+ return [];
76
+ }
77
+ const workspaces = [];
78
+ for (const entry of entries) {
79
+ const name = typeof entry === 'string' ? entry : entry.name;
80
+ if (!name || !name.startsWith(SOLVER_WORKSPACE_PREFIX)) continue;
81
+ const workspacePath = path.join(tmpRoot, name);
82
+ let stats;
83
+ try {
84
+ stats = await fileSystem.stat(workspacePath);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (typeof stats.isDirectory === 'function' && !stats.isDirectory()) continue;
89
+ workspaces.push({ path: workspacePath, name, mtimeMs: Number(stats.mtimeMs) || 0 });
90
+ }
91
+ return workspaces.sort((a, b) => a.mtimeMs - b.mtimeMs);
92
+ };
93
+
94
+ /**
95
+ * Workspaces that a live process is currently sitting in. The AI tool runs with its workspace as
96
+ * cwd, so /proc/<pid>/cwd is an authoritative "do not touch" signal on Linux. When /proc cannot be
97
+ * read (macOS, restricted container) every workspace is reported as busy — refusing to guess is the
98
+ * only safe answer, since deleting a live workspace would destroy real work.
99
+ */
100
+ export const findBusySolverWorkspaces = async ({ workspaces = [], procRoot = '/proc', fileSystem = fsPromises } = {}) => {
101
+ if (!workspaces.length) return new Set();
102
+ let pids;
103
+ try {
104
+ pids = (await fileSystem.readdir(procRoot)).map(entry => (typeof entry === 'string' ? entry : entry.name)).filter(name => /^\d+$/.test(name));
105
+ } catch {
106
+ return new Set(workspaces.map(workspace => workspace.path));
107
+ }
108
+ const cwds = [];
109
+ for (const pid of pids) {
110
+ try {
111
+ cwds.push(String(await fileSystem.readlink(path.join(procRoot, pid, 'cwd'))));
112
+ } catch {
113
+ // The process exited, or its cwd is not readable by this user — nothing to protect here.
114
+ }
115
+ }
116
+ const busy = new Set();
117
+ for (const workspace of workspaces) {
118
+ if (cwds.some(cwd => cwd === workspace.path || cwd.startsWith(`${workspace.path}/`))) busy.add(workspace.path);
119
+ }
120
+ return busy;
121
+ };
122
+
123
+ /**
124
+ * Entries of the given temp roots that `--auto-cleanup` may delete.
125
+ *
126
+ * The old implementation ran `sudo rm -rf /tmp/* /var/tmp/*`, which also destroys the workspaces,
127
+ * lock directories and log files of any *concurrent* hive/solve run on the same host — the run
128
+ * doing the cleanup is rarely the only tenant of /tmp. This builds an explicit list instead and
129
+ * leaves alone anything a live process is sitting in, anything the caller marked as protected, and
130
+ * the run's own log file.
131
+ *
132
+ * @param {Object} [options]
133
+ * @param {Array<string>} [options.roots=['/tmp','/var/tmp']] - Directories to clean
134
+ * @param {Iterable<string>} [options.protectedPaths] - Paths that must survive
135
+ * @returns {Promise<{remove: Array<string>, keep: Array<{path: string, reason: string}>}>}
136
+ */
137
+ export const listCleanableTempEntries = async ({ roots = ['/tmp', '/var/tmp'], protectedPaths = new Set(), fileSystem = fsPromises, procRoot = '/proc' } = {}) => {
138
+ const protectedSet = new Set(Array.from(protectedPaths).filter(Boolean).map(String));
139
+ const remove = [];
140
+ const keep = [];
141
+ const candidates = [];
142
+ for (const root of roots) {
143
+ let entries;
144
+ try {
145
+ entries = await fileSystem.readdir(root);
146
+ } catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const name = typeof entry === 'string' ? entry : entry.name;
151
+ if (!name || name === '.' || name === '..') continue;
152
+ candidates.push({ path: path.join(root, name), name, mtimeMs: 0 });
153
+ }
154
+ }
155
+ const busy = await findBusySolverWorkspaces({ workspaces: candidates, procRoot, fileSystem });
156
+ for (const candidate of candidates) {
157
+ const isProtected = protectedSet.has(candidate.path) || Array.from(protectedSet).some(protectedPath => protectedPath.startsWith(`${candidate.path}/`));
158
+ if (isProtected) {
159
+ keep.push({ path: candidate.path, reason: 'protected' });
160
+ continue;
161
+ }
162
+ if (busy.has(candidate.path)) {
163
+ keep.push({ path: candidate.path, reason: 'process_cwd' });
164
+ continue;
165
+ }
166
+ remove.push(candidate.path);
167
+ }
168
+ return { remove, keep };
169
+ };
170
+
171
+ /** Workspace paths mentioned in a line of solver output, used to protect in-flight workspaces. */
172
+ export const extractSolverWorkspacePaths = (text, { tmpRoot = DEFAULT_TMP_ROOT } = {}) => {
173
+ if (!text) return [];
174
+ const pattern = new RegExp(`${tmpRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${SOLVER_WORKSPACE_PREFIX}[A-Za-z0-9_-]+`, 'g');
175
+ return Array.from(new Set(String(text).match(pattern) || []));
176
+ };
177
+
178
+ /**
179
+ * Remove idle solver workspaces, oldest first, until `requiredMB` is free.
180
+ * A workspace is skipped when it is in flight, is some process's cwd, or was modified recently.
181
+ */
182
+ export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, now = Date.now, log = async () => {}, fileSystem = fsPromises, procRoot = '/proc', getFreeMB = getFreeDiskSpaceMB, remove = defaultRemove } = {}) => {
183
+ const removed = [];
184
+ const skipped = [];
185
+ let freeMB = await getFreeMB(tmpRoot);
186
+ const workspaces = await listSolverWorkspaces({ tmpRoot, fileSystem });
187
+ if (!workspaces.length) return { removed, skipped, freeMB };
188
+ const busy = await findBusySolverWorkspaces({ workspaces, procRoot, fileSystem });
189
+ const currentTime = now();
190
+ for (const workspace of workspaces) {
191
+ if (freeMB !== null && freeMB >= requiredMB) break;
192
+ if (protectedPaths.has(workspace.path)) {
193
+ skipped.push({ path: workspace.path, reason: 'in_flight' });
194
+ continue;
195
+ }
196
+ if (busy.has(workspace.path)) {
197
+ skipped.push({ path: workspace.path, reason: 'process_cwd' });
198
+ continue;
199
+ }
200
+ if (currentTime - workspace.mtimeMs < minIdleMs) {
201
+ skipped.push({ path: workspace.path, reason: 'recently_modified' });
202
+ continue;
203
+ }
204
+ try {
205
+ await remove(workspace.path);
206
+ removed.push(workspace.path);
207
+ await log(` 🧹 Reclaimed idle solver workspace: ${workspace.path}`);
208
+ } catch (error) {
209
+ skipped.push({ path: workspace.path, reason: 'remove_failed', error });
210
+ await log(` ⚠️ Could not remove ${workspace.path}: ${error.message}`, { level: 'warning' });
211
+ continue;
212
+ }
213
+ freeMB = await getFreeMB(tmpRoot);
214
+ }
215
+ return { removed, skipped, freeMB };
216
+ };
217
+
218
+ /**
219
+ * Make sure `requiredMB` is free before a worker starts a task.
220
+ *
221
+ * Returns `{ ok: true }` when there is (or there now is) enough space, and
222
+ * `{ ok: false, reason: 'insufficient_disk_space' }` when the caller should defer the task instead
223
+ * of spawning a solver that would die in its pre-flight check.
224
+ *
225
+ * An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
226
+ * check, not a replacement for it.
227
+ */
228
+ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
229
+ const startedAt = now();
230
+ const reclaimed = [];
231
+ let freeMB = await getFreeMB(tmpRoot);
232
+ if (freeMB === null) {
233
+ await log(' 💾 Could not determine free disk space — continuing and letting the solver pre-flight check decide', { verbose: true });
234
+ return { ok: true, freeMB: null, reason: 'unknown_free_space', reclaimed, waitedMs: 0 };
235
+ }
236
+ if (freeMB >= requiredMB) {
237
+ await log(` 💾 Disk space before starting work: ${freeMB}MB free (${requiredMB}MB required)`, { verbose: true });
238
+ return { ok: true, freeMB, reason: 'sufficient', reclaimed, waitedMs: 0 };
239
+ }
240
+ await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
241
+ for (;;) {
242
+ const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
243
+ reclaimed.push(...result.removed);
244
+ if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
245
+ if (freeMB >= requiredMB) {
246
+ await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${result.removed.length} workspace(s)`);
247
+ return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
248
+ }
249
+ const elapsedMs = now() - startedAt;
250
+ if (elapsedMs + pollIntervalMs > maxWaitMs) {
251
+ return { ok: false, freeMB, reason: 'insufficient_disk_space', reclaimed, waitedMs: elapsedMs, skipped: result.skipped };
252
+ }
253
+ await log(` ⏳ Still ${freeMB}MB free of the ${requiredMB}MB required — waiting ${Math.round(pollIntervalMs / 1000)}s for in-flight work to release disk space`);
254
+ await sleep(pollIntervalMs);
255
+ }
256
+ };