@link-assistant/hive-mind 2.13.0 → 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,11 @@
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
+
3
9
  ## 2.13.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.13.0",
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 });
@@ -35,6 +35,7 @@ import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
35
35
  import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.lib.mjs'; // Issue #1834 (PR #1835 feedback)
36
36
  import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
37
37
  import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
38
+ import { renameLogToSessionId } from './session-log-rename.lib.mjs'; // Issue #2160
38
39
  import { showResumeCommand } from './claude.resume-output.lib.mjs';
39
40
  import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
40
41
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
@@ -393,6 +394,11 @@ export const executeClaudeCommand = async params => {
393
394
  let resultSummary = null;
394
395
  let resultModelUsage = null;
395
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;
396
402
  // Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
397
403
  const subAgentCalls = [];
398
404
  // Issue #1590: Map tool_use_id -> subAgentCalls index for accumulating per-call usage from parent_tool_use_id events
@@ -633,16 +639,16 @@ export const executeClaudeCommand = async params => {
633
639
  if (!sessionId && data.session_id) {
634
640
  sessionId = data.session_id;
635
641
  await log(`📌 Session ID: ${sessionId}`);
636
- let sessionLogFile;
637
- try {
638
- const currentLogFile = getLogFile();
639
- sessionLogFile = path.join(path.dirname(currentLogFile), `${sessionId}.log`);
640
- await fs.rename(currentLogFile, sessionLogFile);
641
- setLogFile(sessionLogFile);
642
- await log(`📁 Log renamed to: ${sessionLogFile}`);
643
- } catch (renameError) {
644
- reportError(renameError, { context: 'rename_session_log', sessionId, sessionLogFile, operation: 'rename_log_file' });
645
- 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
+ });
646
652
  }
647
653
  }
648
654
  const eventFacts = collectClaudeStreamEventFacts(data);
@@ -654,9 +660,16 @@ export const executeClaudeCommand = async params => {
654
660
  await log('📝 Captured fallback summary from Claude compaction context', { verbose: true });
655
661
  }
656
662
  if (eventFacts.toolResultError) {
657
- lastToolResultError = eventFacts.toolResultError;
658
- lastMessage = eventFacts.toolResultError;
659
- 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
+ }
660
673
  }
661
674
  // Issue #1708: signal busy/idle to the bidirectional handler so
662
675
  // queue-comments-to-input mode can hold frames until the AI is
@@ -903,9 +916,11 @@ export const executeClaudeCommand = async params => {
903
916
  toolUseCount += eventFacts.toolUseCountDelta;
904
917
  if (eventFacts.lastText) lastMessage = eventFacts.lastText;
905
918
  if (!resultSummary && eventFacts.compactionSummary) resultSummary = eventFacts.compactionSummary;
906
- 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) {
907
923
  lastToolResultError = eventFacts.toolResultError;
908
- lastMessage = eventFacts.toolResultError;
909
924
  }
910
925
  if (data?.type === 'result') {
911
926
  resultEventReceived = true;
@@ -1006,7 +1021,7 @@ export const executeClaudeCommand = async params => {
1006
1021
  }
1007
1022
  if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
1008
1023
  commandFailed = true;
1009
- lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
1024
+ lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage, lastBenignToolResultError });
1010
1025
  await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
1011
1026
  }
1012
1027
  const retryableLastError = classifyRetryableError(lastMessage);
@@ -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
+ };
@@ -17,19 +17,27 @@ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from
17
17
  export { prClosesIssue };
18
18
 
19
19
  /**
20
- * Extract open pull requests that are linked to an issue with closing keywords.
20
+ * Extract pull requests that are linked to an issue with closing keywords.
21
21
  * Draft pull requests are still open in-progress solution drafts, so they must
22
22
  * count for /hive --skip-issues-with-prs.
23
+ *
24
+ * Issue #2160: reporting needs the opposite default from gating. `--skip-issues-with-prs` only
25
+ * cares about OPEN pull requests, but the end-of-run summary must also see the ones `--auto-merge`
26
+ * already merged, otherwise a merged solution draft is reported as "(no PR found)".
27
+ *
23
28
  * @param {Object} issueData - GraphQL issue node with timelineItems
24
29
  * @param {number} issueNum - Issue number to check
25
30
  * @param {Function} logger - Async logger, defaults to shared log helper
26
- * @returns {Promise<Array<Object>>} Linked open PRs that close the issue
31
+ * @param {Object} [options]
32
+ * @param {Array<string>} [options.includeStates=['OPEN']] - PR states to report
33
+ * @returns {Promise<Array<Object>>} Linked PRs (in the requested states) that close the issue
27
34
  */
28
- export async function extractLinkedPullRequestsForIssue(issueData, issueNum, logger = log) {
35
+ export async function extractLinkedPullRequestsForIssue(issueData, issueNum, logger = log, { includeStates = ['OPEN'] } = {}) {
29
36
  const linkedPRs = [];
37
+ const wantedStates = new Set(includeStates);
30
38
 
31
39
  for (const item of issueData.timelineItems?.nodes || []) {
32
- if (item?.source && item.source.state === 'OPEN') {
40
+ if (item?.source && wantedStates.has(item.source.state)) {
33
41
  // Check if PR actually closes this issue (has "fixes #N", "closes #N", or "resolves #N")
34
42
  const prBody = item.source.body || '';
35
43
  const prTitle = item.source.title || '';
@@ -58,9 +66,12 @@ export async function extractLinkedPullRequestsForIssue(issueData, issueNum, log
58
66
  * @param {string} owner - Repository owner
59
67
  * @param {string} repo - Repository name
60
68
  * @param {Array<number>} issueNumbers - Array of issue numbers to check
69
+ * @param {Object} [options]
70
+ * @param {Array<string>} [options.includeStates=['OPEN']] - PR states to report in `linkedPRs`
71
+ * (issue #2160). `openPRCount` always counts only OPEN pull requests.
61
72
  * @returns {Promise<Object>} Object mapping issue numbers to their linked PRs
62
73
  */
63
- export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers) {
74
+ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers, { includeStates = ['OPEN'] } = {}) {
64
75
  try {
65
76
  if (!issueNumbers || issueNumbers.length === 0) {
66
77
  return {};
@@ -138,12 +149,14 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
138
149
  // Issue #1094: Only count PRs that explicitly fix/close/resolve this issue
139
150
  // This prevents false positives from PRs that only mention issues without solving them
140
151
  // Issue #1760: Draft PRs are still active solution drafts and must block duplicate work
141
- const linkedPRs = await extractLinkedPullRequestsForIssue(issueData, issueNum);
152
+ const linkedPRs = await extractLinkedPullRequestsForIssue(issueData, issueNum, log, { includeStates });
142
153
 
143
154
  results[issueNum] = {
144
155
  title: issueData.title,
145
156
  state: issueData.state,
146
- openPRCount: linkedPRs.length,
157
+ // Issue #2160: linkedPRs may now include merged/closed PRs for reporting, so the
158
+ // gate count has to be derived from the open ones only.
159
+ openPRCount: linkedPRs.filter(pr => pr.state === 'OPEN').length,
147
160
  linkedPRs: linkedPRs,
148
161
  };
149
162
  } else {
@@ -165,18 +178,25 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
165
178
 
166
179
  for (const issueNum of batch) {
167
180
  try {
168
- const cmd = `gh api repos/${owner}/${repo}/issues/${issueNum}/timeline --paginate --jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null and .source.issue.state == "open")] | length'`;
181
+ // Issue #2160: return the PRs themselves, not just a count, so the end-of-run summary
182
+ // can name a merged solution draft even when GraphQL was unavailable.
183
+ const cmd = `gh api repos/${owner}/${repo}/issues/${issueNum}/timeline --paginate --jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null) | {number: .source.issue.number, title: .source.issue.title, body: .source.issue.body, state: (if .source.issue.pull_request.merged_at then "MERGED" else (.source.issue.state | ascii_upcase) end), isDraft: (.source.issue.draft // false), url: .source.issue.html_url}]'`;
169
184
 
170
185
  // #1756: route REST fallback through execGhWithRetry for transient 5xx + rate-limit
171
186
  const { stdout } = await execGhWithRetry(cmd, {
172
187
  execOptions: { encoding: 'utf8', env: process.env },
173
188
  label: `gh api timeline (issue #${issueNum})`,
174
189
  });
175
- const openPrCount = parseInt(stdout.trim()) || 0;
190
+ const wantedStates = new Set(includeStates);
191
+ const crossReferenced = JSON.parse(stdout.trim() || '[]');
192
+ const linkedPRs = crossReferenced
193
+ .filter(pr => wantedStates.has(pr.state))
194
+ .filter(pr => prClosesIssue(pr.body || '', issueNum) || prClosesIssue(pr.title || '', issueNum))
195
+ .map(({ number, title, state, isDraft, url }) => ({ number, title, state, isDraft: Boolean(isDraft), url }));
176
196
 
177
197
  results[issueNum] = {
178
- openPRCount: openPrCount,
179
- linkedPRs: [], // REST API doesn't give us PR details easily
198
+ openPRCount: linkedPRs.filter(pr => pr.state === 'OPEN').length,
199
+ linkedPRs,
180
200
  };
181
201
  } catch (restError) {
182
202
  results[issueNum] = {
@@ -579,7 +579,9 @@ ${logContent}
579
579
  if (useLargeFileMode) {
580
580
  await log(` 📁 Log file too large for inline comment (${Math.round(logStats.size / 1024 / 1024)}MB), using gh-upload-log`);
581
581
  } else {
582
- await log(` ⚠️ Log comment too long (${logComment.length} chars), GitHub limit is ${githubLimits.commentMaxSize} chars`);
582
+ // Issue #2160: this is the expected route for a long log, not a problem the upload
583
+ // below handles it. Reporting it as a warning made every normal run look degraded.
584
+ await log(` ℹ️ Log comment too long (${logComment.length} chars, GitHub limit is ${githubLimits.commentMaxSize} chars), using gh-upload-log`);
583
585
  }
584
586
  await log(' 📎 Uploading log using gh-upload-log...');
585
587
  try {
package/src/hive.mjs CHANGED
@@ -39,6 +39,8 @@ import { attachChildExitHandlers } from './child-exit.lib.mjs';
39
39
  import { SUBSCRIPTION_BLOCKED_MARKER } from './subscription-error.lib.mjs'; // Issue #2161
40
40
  import { isDirectExecution, withTimeout } from './hive.bootstrap.lib.mjs';
41
41
  import { createShutdownManager } from './hive.shutdown.lib.mjs';
42
+ // Issue #2160: keep dequeuing safe when the host disk fills up mid-run.
43
+ import { EXIT_CODE_INSUFFICIENT_DISK_SPACE, ensureDiskSpaceForWorker, extractSolverWorkspacePaths } from './disk-guard.lib.mjs';
42
44
  const isRunningDirectly = isDirectExecution(process.argv[1], import.meta.url);
43
45
  if (isRunningDirectly) {
44
46
  console.log('🐝 Hive Mind - AI-powered issue solver');
@@ -603,6 +605,7 @@ if (isRunningDirectly) {
603
605
  this.processing = new Set();
604
606
  this.completed = new Set();
605
607
  this.failed = new Set();
608
+ this.deferrals = new Map(); // Issue #2160: issueUrl -> environment deferral count
606
609
  this.workers = [];
607
610
  this.isRunning = true;
608
611
  }
@@ -633,6 +636,18 @@ if (isRunningDirectly) {
633
636
  this.processing.delete(issueUrl);
634
637
  this.failed.add(issueUrl);
635
638
  }
639
+ // Issue #2160: put an issue back at the head of the queue after an *environment* block (a
640
+ // full host disk). It is neither completed nor failed — the task was never attempted.
641
+ // Returns how many times this issue has been deferred so the caller can stop looping.
642
+ requeue(issueUrl) {
643
+ this.processing.delete(issueUrl);
644
+ const deferrals = (this.deferrals.get(issueUrl) || 0) + 1;
645
+ this.deferrals.set(issueUrl, deferrals);
646
+ if (!this.completed.has(issueUrl) && !this.queue.includes(issueUrl)) {
647
+ this.queue.unshift(issueUrl);
648
+ }
649
+ return deferrals;
650
+ }
636
651
  // Get queue statistics
637
652
  getStats() {
638
653
  return {
@@ -654,6 +669,16 @@ if (isRunningDirectly) {
654
669
  // controlled SIGTERM to each (they run in their own detached process group, so the
655
670
  // terminal's SIGINT never reaches them); a *second* interrupt force-kills the groups.
656
671
  const activeSolveChildren = new Set();
672
+ // Issue #2160: workspaces owned by in-flight workers, learned from the solver's own output.
673
+ // The disk guard must never reclaim these — they hold work in progress.
674
+ const workerWorkspaces = new Map(); // workerId -> Set<workspace path>
675
+ const getProtectedWorkspacePaths = () => new Set(Array.from(workerWorkspaces.values()).flatMap(paths => Array.from(paths)));
676
+ // How long a worker waits for in-flight work to release disk space before deferring its task,
677
+ // and how many deferrals of one task are tolerated before hive stops: with nothing else
678
+ // running, no amount of waiting will free space.
679
+ const DISK_SPACE_WAIT_MS = 10 * 60 * 1000;
680
+ const MAX_DISK_SPACE_DEFERRALS = 3;
681
+ let diskSpaceHalt = null;
657
682
  // Issue #2161: an account/subscription block is hive-wide, not per-issue. The
658
683
  // credentials every worker shares have been refused, so each remaining issue
659
684
  // would spin up a full solve run only to die the same way — burning clones,
@@ -690,8 +715,35 @@ if (isRunningDirectly) {
690
715
  await log(` 📊 Queue: ${stats.queued} waiting, ${stats.processing} processing, ${stats.completed} completed, ${stats.failed} failed`);
691
716
  continue;
692
717
  }
718
+ // Issue #2160: re-check free disk space before every task. hive used to check it once at
719
+ // startup, so a run whose kept workspaces filled the disk kept spawning solvers that died
720
+ // in their own pre-flight check — and each of those was counted as a *task* failure
721
+ // (`❌ 4 task(s) failed (completed: 6)`). Reclaim idle workspaces, wait for in-flight ones,
722
+ // and defer the task rather than burn it.
723
+ if (!argv.dryRun) {
724
+ const requiredDiskSpaceMB = argv.minDiskSpace || 10240;
725
+ const otherWorkInFlight = issueQueue.getStats().processing > 1;
726
+ const diskGuard = await ensureDiskSpaceForWorker({
727
+ requiredMB: requiredDiskSpaceMB,
728
+ protectedPaths: getProtectedWorkspacePaths(),
729
+ maxWaitMs: otherWorkInFlight ? DISK_SPACE_WAIT_MS : 0,
730
+ log,
731
+ });
732
+ if (!diskGuard.ok) {
733
+ const deferrals = issueQueue.requeue(issueUrl);
734
+ await log(` ⏸️ Worker ${workerId} deferred ${issueUrl}: ${diskGuard.freeMB}MB free, ${requiredDiskSpaceMB}MB required (deferral ${deferrals}/${MAX_DISK_SPACE_DEFERRALS}, not a task failure)`, { level: 'warning' });
735
+ if (deferrals >= MAX_DISK_SPACE_DEFERRALS && issueQueue.getStats().processing === 0) {
736
+ diskSpaceHalt = `Insufficient disk space: ${diskGuard.freeMB}MB free, ${requiredDiskSpaceMB}MB required`;
737
+ await log(' 🛑 Stopping: no in-flight work can release disk space. Free space on this host (or enable --auto-cleanup) and rerun.', { level: 'error' });
738
+ issueQueue.stop();
739
+ }
740
+ continue;
741
+ }
742
+ }
693
743
  // Track if this issue failed
694
744
  let issueFailed = false;
745
+ // Issue #2160: an environment block (full disk) reported by solve itself — requeue, don't fail.
746
+ let environmentDeferral = false;
695
747
  // Issue #1823: Track a graceful shutdown stop so it is neither failed nor completed.
696
748
  let gracefulStop = false;
697
749
  // Process the issue multiple times if needed
@@ -769,12 +821,17 @@ if (isRunningDirectly) {
769
821
  });
770
822
  // Issue #1823: register the in-flight child for optional force-kill on a 2nd signal
771
823
  activeSolveChildren.add(child);
824
+ // Issue #2160: start collecting the workspaces this worker owns so the disk guard
825
+ // (running in the other workers) never reclaims a directory that is still in use.
826
+ const ownedWorkspaces = new Set();
827
+ workerWorkspaces.set(workerId, ownedWorkspaces);
772
828
  log(` 🧒 Spawned ${solveCommand} worker-${workerId} (pid ${child.pid}, detached process group)`, { verbose: true }).catch(() => {});
773
829
  // Handle stdout data - stream output in real-time
774
830
  child.stdout.on('data', data => {
775
831
  const lines = data.toString().split('\n');
776
832
  for (const line of lines) {
777
833
  if (line.trim()) {
834
+ for (const workspacePath of extractSolverWorkspacePaths(line)) ownedWorkspaces.add(workspacePath);
778
835
  // Issue #2161: solve prints SUBSCRIPTION_BLOCKED_MARKER on a terminal
779
836
  // account block. Seen here, it stops the whole hive (see noteSubscriptionBlock).
780
837
  if (line.includes(SUBSCRIPTION_BLOCKED_MARKER)) noteSubscriptionBlock(workerId, line);
@@ -819,6 +876,8 @@ if (isRunningDirectly) {
819
876
  onLogError: (logError, operation) => reportError(logError, { context: 'worker_child_exit_log', workerId, operation }),
820
877
  onExit: result => {
821
878
  activeSolveChildren.delete(child); // Issue #1823: no longer in-flight
879
+ // Issue #2160: the worker released its workspaces — they become reclaimable.
880
+ workerWorkspaces.delete(workerId);
822
881
  exitCode = result.exitCode;
823
882
  resolve();
824
883
  },
@@ -834,6 +893,12 @@ if (isRunningDirectly) {
834
893
  await log(` 🛑 Worker ${workerId} stopped gracefully during shutdown on ${issueUrl} (exit ${exitCode}, ${duration}s)`);
835
894
  gracefulStop = true;
836
895
  break; // stop processing more PRs for this issue
896
+ } else if (exitCode === EXIT_CODE_INSUFFICIENT_DISK_SPACE) {
897
+ // Issue #2160: solve refused to start because this host is out of disk space. The
898
+ // issue was never attempted, so it must not be counted as a task failure.
899
+ await log(` ⏸️ Worker ${workerId} could not start ${issueUrl}: the host is out of disk space (exit ${exitCode}, ${duration}s) — requeued, not a task failure`, { level: 'warning' });
900
+ environmentDeferral = true;
901
+ break;
837
902
  } else if (subscriptionBlock) {
838
903
  // Issue #2161: the run did not fail because of this issue — the account
839
904
  // lost access mid-flight. Report the real reason and stop; solve has
@@ -867,7 +932,16 @@ if (isRunningDirectly) {
867
932
  // Only mark as completed if it didn't fail and wasn't gracefully stopped mid-shutdown.
868
933
  // Issue #1823: a graceful stop is neither a success nor a failure — leave it in
869
934
  // "processing" so it is not miscounted as completed (which would also trigger cleanup).
870
- if (!issueFailed && !gracefulStop) {
935
+ if (environmentDeferral) {
936
+ // Issue #2160: back to the queue (neither completed nor failed). Stop the run when
937
+ // nothing else is in flight, since no other worker can release disk space.
938
+ const deferrals = issueQueue.requeue(issueUrl);
939
+ if (deferrals >= MAX_DISK_SPACE_DEFERRALS && issueQueue.getStats().processing === 0) {
940
+ diskSpaceHalt = 'Insufficient disk space reported by the solver pre-flight check';
941
+ await log(' 🛑 Stopping: no in-flight work can release disk space. Free space on this host (or enable --auto-cleanup) and rerun.', { level: 'error' });
942
+ issueQueue.stop();
943
+ }
944
+ } else if (!issueFailed && !gracefulStop) {
871
945
  issueQueue.markCompleted(issueUrl);
872
946
  }
873
947
  // Show queue stats
@@ -1285,7 +1359,9 @@ if (isRunningDirectly) {
1285
1359
  // Perform cleanup if enabled and there were successful completions
1286
1360
  const finalStats = issueQueue.getStats();
1287
1361
  if (finalStats.completed > 0) {
1288
- await cleanupTempDirectories();
1362
+ // Issue #2160: argv must be forwarded — cleanupTempDirectories returns immediately
1363
+ // without it, so this branch used to be a silent no-op even with --auto-cleanup.
1364
+ await cleanupTempDirectories(argv);
1289
1365
  }
1290
1366
  await log('\n👋 Hive Mind monitoring stopped');
1291
1367
  await log(` 📁 Full log file: ${absoluteLogPath}`);
@@ -1331,16 +1407,20 @@ if (isRunningDirectly) {
1331
1407
  verbose: true,
1332
1408
  });
1333
1409
  } else {
1334
- const systemCheck = await checkSystem(
1335
- {
1336
- minDiskSpaceMB: argv.minDiskSpace || 10240,
1337
- minMemoryMB: 256,
1338
- exitOnFailure: true,
1339
- },
1340
- { log }
1341
- );
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 });
1342
1422
  if (!systemCheck.success) {
1343
- await safeExit(1, 'Error occurred');
1423
+ await safeExit(1, 'System resource check failed');
1344
1424
  }
1345
1425
  // Validate the selected AI tool connection before starting monitoring with the same model that will be used
1346
1426
  const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
@@ -1365,6 +1445,12 @@ if (isRunningDirectly) {
1365
1445
  }
1366
1446
  const finalStats = issueQueue.getStats(); // Issue #1718: surface worker failures via exit code
1367
1447
  if (finalStats.failed > 0) await safeExit(1, `${finalStats.failed} task(s) failed (completed: ${finalStats.completed})`);
1448
+ // Issue #2160: report an exhausted host disk as the environment problem it is, with its own
1449
+ // exit code, instead of letting it be counted as "N task(s) failed". Genuine task failures are
1450
+ // reported first above, because they say more about the run than the environment condition.
1451
+ if (diskSpaceHalt) {
1452
+ await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `${diskSpaceHalt} — ${finalStats.completed} task(s) completed, ${finalStats.queued} left queued (no task failures)`);
1453
+ }
1368
1454
  } catch (fatalError) {
1369
1455
  // Handle fatal errors during initialization or execution
1370
1456
  console.error('\n❌ Fatal error occurred during hive initialization or execution');
package/src/lib.mjs CHANGED
@@ -950,9 +950,16 @@ export const displayFormattedError = async options => {
950
950
  };
951
951
 
952
952
  /**
953
- * Clean up temporary directories
953
+ * Clean up temporary directories.
954
+ *
955
+ * Issue #2160: this used to run `sudo rm -rf /tmp/* /var/tmp/*`, which also wipes the workspaces,
956
+ * lock directories and logs of any *concurrent* hive/solve process on the same host. The entries
957
+ * are now enumerated first and anything a live process is sitting in (or that the caller protects)
958
+ * is left alone.
959
+ *
954
960
  * @param {Object} argv - Command line arguments
955
961
  * @param {boolean} [argv.autoCleanup] - Whether auto-cleanup is enabled
962
+ * @param {Iterable<string>} [argv.protectedTempPaths] - Paths this run still needs
956
963
  * @returns {Promise<void>}
957
964
  */
958
965
  export const cleanupTempDirectories = async argv => {
@@ -962,13 +969,26 @@ export const cleanupTempDirectories = async argv => {
962
969
 
963
970
  // Dynamic import for command-stream
964
971
  const { $ } = await use('command-stream');
972
+ const { listCleanableTempEntries } = await import('./disk-guard.lib.mjs');
973
+ const path = await use('path');
965
974
 
966
975
  try {
967
976
  await log('\n🧹 Auto-cleanup enabled, removing temporary directories...');
968
- await log(' ⚠️ Executing: sudo rm -rf /tmp/* /var/tmp/*', { verbose: true });
977
+ const protectedPaths = new Set(argv.protectedTempPaths || []);
978
+ const currentLogFile = getLogFile();
979
+ if (currentLogFile) protectedPaths.add(path.resolve(currentLogFile));
980
+ const { remove, keep } = await listCleanableTempEntries({ protectedPaths });
981
+ for (const kept of keep) {
982
+ await log(` 🔒 Keeping ${kept.path} (${kept.reason === 'process_cwd' ? 'a live process is using it' : 'needed by this run'})`, { verbose: true });
983
+ }
984
+ if (remove.length === 0) {
985
+ await log(' ✅ Nothing to clean: every temporary entry is still in use');
986
+ return;
987
+ }
988
+ await log(` ⚠️ Executing: sudo rm -rf on ${remove.length} temporary entr${remove.length === 1 ? 'y' : 'ies'} (${keep.length} kept)`, { verbose: true });
969
989
 
970
990
  // Execute cleanup command using command-stream
971
- const cleanupCommand = $`sudo rm -rf /tmp/* /var/tmp/*`;
991
+ const cleanupCommand = $`sudo rm -rf ${remove}`;
972
992
 
973
993
  let exitCode = 0;
974
994
  for await (const chunk of cleanupCommand.stream()) {
@@ -1,8 +1,15 @@
1
1
  /**
2
2
  * Solution Drafts Listing Module
3
3
  * Displays completed issues with their linked pull requests
4
+ *
5
+ * Issue #2160: this listing used to ask only for OPEN pull requests, so every issue whose draft
6
+ * `--auto-merge` had already merged was reported as "(no PR found)" — a false negative in the
7
+ * summary a human reads to judge the run. Merged and closed drafts are now listed with their state.
4
8
  */
5
9
 
10
+ /** Pull request states worth reporting at the end of a run, in the order they are most useful. */
11
+ const REPORTED_PULL_REQUEST_STATES = ['OPEN', 'MERGED', 'CLOSED'];
12
+
6
13
  /**
7
14
  * Lists all completed issues with their solution drafts (PRs)
8
15
  * @param {Object} issueQueue - The issue queue containing completed issues
@@ -10,10 +17,12 @@
10
17
  * @param {Function} batchCheckPullRequestsForIssues - Function to batch check PRs for issues
11
18
  */
12
19
  export async function listSolutionDrafts(issueQueue, log, batchCheckPullRequestsForIssues) {
13
- if (!issueQueue.completed || issueQueue.completed.length === 0) return;
20
+ // `completed` is a Set in hive.mjs, but callers/tests may pass an array.
21
+ const completedUrls = issueQueue?.completed ? Array.from(issueQueue.completed) : [];
22
+ if (completedUrls.length === 0) return;
14
23
  await log('\n📋 Issues with solution drafts:');
15
24
  const byRepo = {};
16
- for (const url of issueQueue.completed) {
25
+ for (const url of completedUrls) {
17
26
  const m = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
18
27
  if (m) (byRepo[`${m[1]}/${m[2]}`] ||= { owner: m[1], repo: m[2], iss: [] }).iss.push({ n: +m[3], url });
19
28
  }
@@ -21,12 +30,16 @@ export async function listSolutionDrafts(issueQueue, log, batchCheckPullRequests
21
30
  const prs = await batchCheckPullRequestsForIssues(
22
31
  r.owner,
23
32
  r.repo,
24
- r.iss.map(i => i.n)
33
+ r.iss.map(i => i.n),
34
+ { includeStates: REPORTED_PULL_REQUEST_STATES }
25
35
  );
26
36
  for (const i of r.iss)
27
37
  if (prs[i.n]?.linkedPRs?.length) {
28
38
  await log(` - ${i.url}`);
29
- for (const p of prs[i.n].linkedPRs) await log(` → PR #${p.number}: ${p.url}`);
39
+ for (const p of prs[i.n].linkedPRs) {
40
+ const state = p.state && p.state !== 'OPEN' ? ` (${p.state.toLowerCase()})` : '';
41
+ await log(` → PR #${p.number}${state}: ${p.url}`);
42
+ }
30
43
  } else await log(` - ${i.url} (no PR found)`);
31
44
  }
32
45
  }
@@ -755,6 +755,7 @@ en
755
755
  - When you execute commands and the output becomes large, save the logs to files for easier review.
756
756
  - When running commands, avoid setting a timeout yourself. Let them run as long as needed. The default timeout of 2 minutes is usually enough, and once commands finish, review the logs in the file.
757
757
  - When running sudo commands, especially package installations like apt-get, yum, or npm install, run them in the background to avoid timeout issues and permission errors when the process needs to be killed. Use the run_in_background parameter or append & to the command.
758
+ - When you need to wait for something (CI checks, a background job, a deploy), do not run a long foreground `sleep`: agent harnesses block it ("Blocked: sleep 240 followed by: ..."). Poll instead with a short until-loop (e.g. `until <check>; do sleep 10; done`) or re-check the condition on your next step.
758
759
  """
759
760
  purpose
760
761
  subagent " - When the task is large and requires processing many files or folders, use `general-purpose` sub-agents to delegate work. Each separate file or folder can be delegated to a sub-agent for more efficient processing."
@@ -755,6 +755,7 @@ hi
755
755
  - जब आप कमांड चलाते हैं और आउटपुट बड़ा हो जाता है, तो आसान समीक्षा के लिए लॉग को फ़ाइलों में सहेजें।
756
756
  - जब आप कमांड चलाते हैं, तो स्वयं टाइमआउट सेट न करें। उन्हें जितना आवश्यक हो उतना चलने दें। डिफ़ॉल्ट 2 मिनट का टाइमआउट आमतौर पर पर्याप्त होता है, और कमांड समाप्त होने के बाद फ़ाइल में लॉग की समीक्षा करें।
757
757
  - जब आप sudo कमांड चलाते हैं, विशेष रूप से apt-get, yum, या npm install जैसी पैकेज स्थापना, तो टाइमआउट और प्रोसेस को मारने पर अनुमति त्रुटियों से बचने के लिए उन्हें पृष्ठभूमि में चलाएँ। run_in_background पैरामीटर का उपयोग करें या कमांड के अंत में & जोड़ें।
758
+ - जब आपको किसी चीज़ की प्रतीक्षा करनी हो (CI जाँच, पृष्ठभूमि कार्य, डिप्लॉय), तो लंबा `sleep` अग्रभूमि में न चलाएँ: एजेंट हार्नेस उसे रोक देते हैं ("Blocked: sleep 240 followed by: ...")। इसके बजाय छोटे अंतराल वाले until लूप से पोलिंग करें (उदाहरण: `until <जाँच>; do sleep 10; done`) या अगले चरण में स्थिति फिर से जाँचें।
758
759
  """
759
760
  purpose
760
761
  subagent " - जब कार्य बड़ा हो और कई फ़ाइलों या फ़ोल्डरों के प्रसंस्करण की आवश्यकता हो, तो कार्य सौंपने के लिए `general-purpose` sub-agents का उपयोग करें। प्रत्येक अलग फ़ाइल या फ़ोल्डर को अधिक कुशल प्रसंस्करण के लिए एक sub-agent को सौंपा जा सकता है।"
@@ -755,6 +755,7 @@ ru
755
755
  - Когда выполняешь команды и вывод становится большим, сохраняй логи в файлы для удобного просмотра.
756
756
  - Когда запускаешь команды, не задавай таймаут самостоятельно. Дай им работать столько, сколько нужно. Стандартного таймаута в 2 минуты обычно достаточно, и после завершения команды просмотри логи в файле.
757
757
  - Когда запускаешь команды sudo, особенно установку пакетов вроде apt-get, yum или npm install, запускай их в фоне, чтобы избежать таймаутов и ошибок прав доступа при необходимости остановить процесс. Используй параметр run_in_background или добавляй & в конце команды.
758
+ - Когда нужно чего-то дождаться (проверок CI, фоновой задачи, деплоя), не запускай длинный `sleep` в основном потоке: агентные окружения блокируют его ("Blocked: sleep 240 followed by: ..."). Вместо этого опрашивай состояние коротким циклом until (например, `until <проверка>; do sleep 10; done`) или проверяй условие заново на следующем шаге.
758
759
  """
759
760
  purpose
760
761
  subagent " - Когда задача большая и требует обработки многих файлов или папок, используй `general-purpose` суб-агентов, чтобы делегировать работу. Каждый отдельный файл или папка может быть делегирован суб-агенту для более эффективной обработки."
@@ -755,6 +755,7 @@ zh
755
755
  - 当你执行的命令输出变得很大时,将日志保存到文件以便复查。
756
756
  - 当你运行命令时,不要自行设置超时。让它们按需运行。默认 2 分钟超时通常足够,命令完成后再查看文件中的日志。
757
757
  - 当你运行 sudo 命令(尤其是 apt-get、yum 或 npm install 等包安装)时,请在后台运行以避免超时和需要终止进程时的权限错误。使用 run_in_background 参数或在命令末尾追加 &。
758
+ - 当你需要等待某件事(CI 检查、后台任务、部署)时,不要在前台运行长时间的 `sleep`:智能体运行环境会拦截它("Blocked: sleep 240 followed by: ...")。请改用短间隔的 until 轮询循环(例如 `until <检查>; do sleep 10; done`),或在下一步重新检查条件。
758
759
  """
759
760
  purpose
760
761
  subagent " - 当任务很大且需要处理许多文件或文件夹时,使用 `general-purpose` 子代理来委派工作。每个单独的文件或文件夹都可以委派给一个子代理以更高效地处理。"
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Session log renaming (Issue #2160)
5
+ *
6
+ * When an AI tool reports its session id, the run log is renamed to `<sessionId>.log` so that
7
+ * the log file can be correlated with the tool session. The logic used to live inline in
8
+ * src/claude.lib.mjs and depended on `getLogFile`/`setLogFile` being forwarded through every
9
+ * caller. Restart/watch iterations (src/solve.restart-shared.lib.mjs) either omitted those
10
+ * parameters or passed no-op stubs, which produced this on every restart iteration:
11
+ *
12
+ * ⚠️ Could not rename log file: getLogFile is not a function
13
+ *
14
+ * Extracting the logic makes the failure mode explicit (a named reason instead of a TypeError)
15
+ * and testable.
16
+ *
17
+ * @see https://github.com/link-assistant/hive-mind/issues/2160
18
+ */
19
+
20
+ import { promises as fs } from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ /**
24
+ * Rename the current log file to `<sessionId>.log`.
25
+ *
26
+ * Never throws: every failure is returned as `{ ok: false, reason }` so callers can log it.
27
+ *
28
+ * @param {Object} params
29
+ * @param {string} params.sessionId - Session id reported by the AI tool
30
+ * @param {Function} params.getLogFile - Accessor returning the current log file path
31
+ * @param {Function} params.setLogFile - Accessor updating the current log file path
32
+ * @param {Function} [params.log] - Async logger
33
+ * @param {Object} [params.fileSystem] - Injectable fs.promises replacement (tests)
34
+ * @returns {Promise<{ok: boolean, reason?: string, error?: Error, sessionLogFile?: string}>}
35
+ */
36
+ export const renameLogToSessionId = async ({ sessionId, getLogFile, setLogFile, log, fileSystem = fs }) => {
37
+ if (!sessionId) return { ok: false, reason: 'missing_session_id' };
38
+ if (typeof getLogFile !== 'function' || typeof setLogFile !== 'function') {
39
+ // Issue #2160: a caller that forgot to forward the accessors. Report it as a real defect
40
+ // instead of surfacing "getLogFile is not a function" as a mysterious warning.
41
+ if (log) await log('⚠️ Could not rename log file: log file accessors were not provided by the caller', { verbose: true });
42
+ return { ok: false, reason: 'missing_log_file_accessors' };
43
+ }
44
+
45
+ const currentLogFile = getLogFile();
46
+ if (!currentLogFile) {
47
+ if (log) await log('⚠️ Could not rename log file: no current log file is configured', { verbose: true });
48
+ return { ok: false, reason: 'no_current_log_file' };
49
+ }
50
+
51
+ const sessionLogFile = path.join(path.dirname(currentLogFile), `${sessionId}.log`);
52
+ if (sessionLogFile === currentLogFile) return { ok: true, reason: 'already_named', sessionLogFile };
53
+
54
+ try {
55
+ await fileSystem.rename(currentLogFile, sessionLogFile);
56
+ setLogFile(sessionLogFile);
57
+ if (log) await log(`📁 Log renamed to: ${sessionLogFile}`);
58
+ return { ok: true, sessionLogFile };
59
+ } catch (error) {
60
+ if (log) await log(`⚠️ Could not rename log file: ${error.message}`, { verbose: true });
61
+ return { ok: false, reason: 'rename_failed', error, sessionLogFile };
62
+ }
63
+ };
64
+
65
+ export default { renameLogToSessionId };
package/src/solve.mjs CHANGED
@@ -223,6 +223,15 @@ const skipToolConnectionCheck = prepareOnly || argv.skipToolConnectionCheck || a
223
223
  const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
224
224
  await cascadePlaywrightMcpDisable(argv, log);
225
225
  if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
226
+ // Issue #2160: an exhausted host disk is an environment condition, not a defect in the issue.
227
+ // Exit with EX_TEMPFAIL (75) so an orchestrator can requeue the task, and skip the pre-exit
228
+ // notifier: posting "🚨 Solution Draft Failed — Reason: System checks failed" on the target
229
+ // repository's issue told its maintainers nothing they could act on.
230
+ if (argv.systemCheckFailure?.check === 'disk-space') {
231
+ const { EXIT_CODE_INSUFFICIENT_DISK_SPACE } = await import('./disk-guard.lib.mjs');
232
+ const { availableMB, requiredMB } = argv.systemCheckFailure;
233
+ await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space on this host (${availableMB}MB available, ${requiredMB}MB required) — the issue itself was not attempted`, { skipPreExit: true });
234
+ }
226
235
  await safeExit(1, 'System checks failed');
227
236
  }
228
237
  // Playwright MCP preflight is local/free and stays independent from paid tool connection checks.
@@ -271,6 +280,9 @@ const { isPublic: isRepoPublic } = await detectRepositoryVisibility(owner, repo)
271
280
  if (argv.autoCleanup === undefined) {
272
281
  // For public repos: keep temp directories (default false) For private repos: clean up temp directories (default true)
273
282
  argv.autoCleanup = !isRepoPublic;
283
+ // Issue #2160: remember that this was a default, not a flag, so the "keeping directory"
284
+ // message at the end of the session can say why the workspace is being kept.
285
+ argv.autoCleanupSource = 'repository-visibility-default';
274
286
  if (argv.verbose) {
275
287
  await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isRepoPublic ? 'public' : 'private'})`, {
276
288
  verbose: true,
@@ -1338,6 +1338,10 @@ export const cleanupTempDirectory = async (tempDir, argv, limitReached) => {
1338
1338
  } else if (limitReached) {
1339
1339
  await log(`\n📁 Keeping directory for future resume: ${tempDir}`);
1340
1340
  } else if (!argv.autoCleanup) {
1341
- await log(`\n📁 Keeping directory (--no-auto-cleanup): ${tempDir}`);
1341
+ // Issue #2160: `--no-auto-cleanup` is only one of the two ways to get here. On a public
1342
+ // repository auto-cleanup defaults to off, and reporting a flag that was never passed made
1343
+ // the run log misleading — the disk kept filling with no hint of why.
1344
+ const reason = argv.autoCleanupSource === 'repository-visibility-default' ? 'auto-cleanup is off by default for public repositories' : '--no-auto-cleanup';
1345
+ await log(`\n📁 Keeping directory (${reason}): ${tempDir}`);
1342
1346
  }
1343
1347
  };
@@ -30,7 +30,10 @@ const fs = (await use('fs')).promises;
30
30
 
31
31
  // Import shared library functions
32
32
  const lib = await import('./lib.mjs');
33
- const { log, formatAligned, extractToolErrorCore } = lib;
33
+ // Issue #2160: the real log-file accessors must be forwarded to every tool executor. Passing
34
+ // no-op stubs (or omitting them entirely) broke session-log renaming in restart/watch iterations
35
+ // ("⚠️ Could not rename log file: getLogFile is not a function").
36
+ const { log, formatAligned, extractToolErrorCore, getLogFile, setLogFile } = lib;
34
37
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
35
38
  const { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } = await import('./ai-tool-scratch.lib.mjs');
36
39
  const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
@@ -240,8 +243,8 @@ export const executeToolIteration = async params => {
240
243
  log,
241
244
  formatAligned,
242
245
  getResourceSnapshot,
243
- setLogFile: () => {},
244
- getLogFile: () => '',
246
+ setLogFile,
247
+ getLogFile,
245
248
  $,
246
249
  });
247
250
  } else if (argv.tool === 'opencode') {
@@ -280,6 +283,8 @@ export const executeToolIteration = async params => {
280
283
  log,
281
284
  formatAligned,
282
285
  getResourceSnapshot,
286
+ setLogFile,
287
+ getLogFile,
283
288
  opencodePath,
284
289
  $,
285
290
  });
@@ -318,8 +323,8 @@ export const executeToolIteration = async params => {
318
323
  repo,
319
324
  argv,
320
325
  log,
321
- setLogFile: () => {},
322
- getLogFile: () => '',
326
+ setLogFile,
327
+ getLogFile,
323
328
  formatAligned,
324
329
  getResourceSnapshot,
325
330
  codexPath,
@@ -362,6 +367,8 @@ export const executeToolIteration = async params => {
362
367
  log,
363
368
  formatAligned,
364
369
  getResourceSnapshot,
370
+ setLogFile,
371
+ getLogFile,
365
372
  agentPath,
366
373
  $,
367
374
  });
@@ -400,8 +407,8 @@ export const executeToolIteration = async params => {
400
407
  repo,
401
408
  argv,
402
409
  log,
403
- setLogFile: () => {},
404
- getLogFile: () => '',
410
+ setLogFile,
411
+ getLogFile,
405
412
  formatAligned,
406
413
  getResourceSnapshot,
407
414
  geminiPath,
@@ -442,8 +449,8 @@ export const executeToolIteration = async params => {
442
449
  repo,
443
450
  argv,
444
451
  log,
445
- setLogFile: () => {},
446
- getLogFile: () => '',
452
+ setLogFile,
453
+ getLogFile,
447
454
  formatAligned,
448
455
  getResourceSnapshot,
449
456
  qwenPath,
@@ -488,6 +495,8 @@ export const executeToolIteration = async params => {
488
495
  log,
489
496
  formatAligned,
490
497
  getResourceSnapshot,
498
+ setLogFile,
499
+ getLogFile,
491
500
  claudePath,
492
501
  $,
493
502
  });
@@ -485,12 +485,15 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
485
485
  const verifyResult = await $({ cwd: tempDir })`git ls-files ${fileName} 2>&1`;
486
486
  const fileStillExists = verifyResult.code === 0 && verifyResult.stdout && verifyResult.stdout.trim();
487
487
  if (fileStillExists) {
488
- await log(` ⚠️ WARNING: ${fileName} still exists after cleanup attempting direct removal...`);
489
- // Check if the file existed before the initial commit (parent)
488
+ // Issue #2160: the pre-existence check must come FIRST. A file that legitimately predates
489
+ // the session is not a cleanup failure, and warning about it produced a false positive
490
+ // ("⚠️ WARNING: .gitkeep still exists after cleanup" immediately followed by
491
+ // "ℹ️ .gitkeep existed before this session — keeping pre-existing file").
490
492
  const parentCommit = `${claudeCommitHash}~1`;
491
493
  const parentFileExists = await $({ cwd: tempDir })`git cat-file -e ${parentCommit}:${fileName} 2>&1`;
492
494
  if (parentFileExists.code !== 0) {
493
- // File didn't exist before the session — force remove it
495
+ // File didn't exist before the session — this is a real leftover, force remove it
496
+ await log(` ⚠️ WARNING: ${fileName} still exists after cleanup — attempting direct removal...`);
494
497
  await $({ cwd: tempDir })`git rm -f ${fileName} 2>&1`;
495
498
  const fallbackCommit = await $({ cwd: tempDir })`git commit -m "Remove leftover ${fileName} (post-cleanup fallback, Issue #1436)" 2>&1`;
496
499
  if (fallbackCommit.code === 0) {
@@ -53,12 +53,6 @@ const { parseResetTime: parseResetTimeToDate } = usageLimitLib;
53
53
 
54
54
  const { validateClaudeConnection } = claudeLib;
55
55
 
56
- // Wrapper function for disk space check using imported module
57
- const checkDiskSpace = async (minSpaceMB = 10240) => {
58
- const result = await memoryCheck.checkDiskSpace(minSpaceMB, { log });
59
- return result.success;
60
- };
61
-
62
56
  // Wrapper function for memory check using imported module
63
57
  const checkMemory = async (minMemoryMB = 256) => {
64
58
  const result = await memoryCheck.checkMemory(minMemoryMB, { log });
@@ -217,9 +211,13 @@ export const validateContinueOnlyOnFeedback = async (argv, isPrUrl, isIssueUrl)
217
211
  // Note: skipToolConnection only skips the connection check, not model validation
218
212
  // Model validation should be done separately before calling this function
219
213
  export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnection = false, model = 'sonnet', argv = {}) => {
220
- // Check disk space before proceeding
221
- const hasEnoughSpace = await checkDiskSpace(minDiskSpace);
222
- if (!hasEnoughSpace) {
214
+ // Check disk space before proceeding.
215
+ // Issue #2160: record *which* check failed on argv. A full disk says nothing about the issue
216
+ // being solved, so the caller exits with a retry-later code and skips the "Solution Draft
217
+ // Failed" comment instead of blaming the task (hive counted 4 such exits as task failures).
218
+ const diskSpace = await memoryCheck.checkDiskSpace(minDiskSpace, { log });
219
+ if (!diskSpace.success) {
220
+ argv.systemCheckFailure = { check: 'disk-space', availableMB: diskSpace.availableMB, requiredMB: minDiskSpace };
223
221
  return false;
224
222
  }
225
223