@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 +6 -0
- package/package.json +1 -1
- package/src/claude.budget-stats.lib.mjs +4 -2
- package/src/claude.lib.mjs +31 -16
- package/src/claude.stream-events.lib.mjs +56 -2
- package/src/disk-guard.lib.mjs +256 -0
- package/src/github.batch.lib.mjs +31 -11
- package/src/github.lib.mjs +3 -1
- package/src/hive.mjs +97 -11
- package/src/lib.mjs +23 -3
- package/src/list-solution-drafts.lib.mjs +17 -4
- package/src/locales/en.lino +1 -0
- package/src/locales/hi.lino +1 -0
- package/src/locales/ru.lino +1 -0
- package/src/locales/zh.lino +1 -0
- package/src/session-log-rename.lib.mjs +65 -0
- package/src/solve.mjs +12 -0
- package/src/solve.repository.lib.mjs +5 -1
- package/src/solve.restart-shared.lib.mjs +18 -9
- package/src/solve.results.lib.mjs +6 -3
- package/src/solve.validation.lib.mjs +7 -9
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
|
@@ -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
|
|
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 });
|
package/src/claude.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
+
};
|
package/src/github.batch.lib.mjs
CHANGED
|
@@ -17,19 +17,27 @@ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from
|
|
|
17
17
|
export { prClosesIssue };
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* Extract
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
179
|
-
linkedPRs
|
|
198
|
+
openPRCount: linkedPRs.filter(pr => pr.state === 'OPEN').length,
|
|
199
|
+
linkedPRs,
|
|
180
200
|
};
|
|
181
201
|
} catch (restError) {
|
|
182
202
|
results[issueNum] = {
|
package/src/github.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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, '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
}
|
package/src/locales/en.lino
CHANGED
|
@@ -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."
|
package/src/locales/hi.lino
CHANGED
|
@@ -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 को सौंपा जा सकता है।"
|
package/src/locales/ru.lino
CHANGED
|
@@ -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` суб-агентов, чтобы делегировать работу. Каждый отдельный файл или папка может быть делегирован суб-агенту для более эффективной обработки."
|
package/src/locales/zh.lino
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
//
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|