@link-assistant/hive-mind 2.13.0 → 2.13.2
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 +12 -0
- package/package.json +1 -1
- package/src/buildUserMention.lib.mjs +30 -3
- 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-url-parser.lib.mjs +26 -1
- 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/session-monitor.lib.mjs +3 -2
- 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/src/telegram-accept-invitations.lib.mjs +5 -3
- package/src/telegram-bot.mjs +18 -9
- package/src/telegram-command-execution.lib.mjs +2 -1
- package/src/telegram-context-safety.lib.mjs +70 -0
- package/src/telegram-fix-command.lib.mjs +68 -4
- package/src/telegram-language-command.lib.mjs +4 -3
- package/src/telegram-log-command.lib.mjs +14 -12
- package/src/telegram-markdown-validator.lib.mjs +192 -0
- package/src/telegram-merge-command.lib.mjs +18 -16
- package/src/telegram-message-filters.lib.mjs +1 -1
- package/src/telegram-safe-reply.lib.mjs +290 -21
- package/src/telegram-solve-queue-command.lib.mjs +2 -1
- package/src/telegram-solve-queue.lib.mjs +16 -7
- package/src/telegram-start-stop-command.lib.mjs +38 -27
- package/src/telegram-subscribers.lib.mjs +6 -4
- package/src/telegram-terminal-watch-command.lib.mjs +8 -7
- package/src/telegram-tokens-command.lib.mjs +2 -1
- package/src/telegram-top-command.lib.mjs +8 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.13.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6b0435a: Make every Telegram message the bot sends visible. A single unpaired `_` in a repository name (`save_visiogetbb`) made `parse_mode: 'Markdown'` messages fail with `400: can't parse entities`, and because the plain-text fallback was installed on `bot.telegram` — while telegraf hands each handler a _different_ `Telegram` instance — the fallback never ran, so `/stop` cancelled the task but reported nothing. All sends now go through one funnel that validates the text against a port of TDLib's `parse_markdown()` before the call, logs every attempt/success/rejection, chunks at 4096 chars, and retries as plain text on any `400`; the funnel is re-installed on every per-update context, covers document captions, and a new `telegram-safety/no-unsafe-telegram-send` ESLint rule makes a raw `parse_mode` send a build error. Also: `/stop` and queue cards echo only the URL actually interpreted (no `#issuecomment-…` anchor), mentions no longer render a literal `\_`, and `/fix` rejects unsupported options (`--ci-de`) up front instead of silently spawning a wrong run.
|
|
8
|
+
|
|
9
|
+
## 2.13.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 2.13.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a display name safe to use as the label of a legacy-Markdown entity
|
|
3
|
+
* (`[label](url)`).
|
|
4
|
+
*
|
|
5
|
+
* Inside an entity TDLib copies bytes verbatim, so nothing needs escaping — but
|
|
6
|
+
* a literal `]` would terminate the entity early and turn the rest of the
|
|
7
|
+
* message into garbage. Those two delimiters are therefore dropped; `_` and `*`
|
|
8
|
+
* are deliberately left alone (issue #2166).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} label - Raw display name.
|
|
11
|
+
* @returns {string} Label safe to embed between `[` and `]`.
|
|
12
|
+
*/
|
|
13
|
+
export function escapeMarkdownEntityLabel(label) {
|
|
14
|
+
if (!label || typeof label !== 'string') return label;
|
|
15
|
+
return label.replace(/[[\]]/g, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
/**
|
|
2
19
|
* Build a Telegram user mention link in various parse modes.
|
|
3
20
|
*
|
|
@@ -42,9 +59,19 @@ export function buildUserMention({ user, id: idParam, username: usernameParam, f
|
|
|
42
59
|
switch (parseMode) {
|
|
43
60
|
case 'Markdown': {
|
|
44
61
|
// Legacy Markdown: [text](url)
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
//
|
|
63
|
+
// Issue #2166: do NOT backslash-escape `_` / `*` here. TDLib's
|
|
64
|
+
// `parse_markdown()` only unescapes `\_ \* \` \[` at the *top level*; once it
|
|
65
|
+
// is inside an entity it copies bytes verbatim until the closing `]`:
|
|
66
|
+
//
|
|
67
|
+
// while (i < size && text[i] != end_character) { … text[result_size++] = text[i++]; }
|
|
68
|
+
//
|
|
69
|
+
// So `[@my\_user](…)` renders the backslashes literally — that is the
|
|
70
|
+
// unpolished `\_` the issue reports. The label is already inside the entity,
|
|
71
|
+
// which is what actually prevents the "can't find end of entity" error from
|
|
72
|
+
// issue #1460; only the delimiters themselves are dangerous.
|
|
73
|
+
const labelName = escapeMarkdownEntityLabel(displayName);
|
|
74
|
+
return `[${labelName}](${link})`;
|
|
48
75
|
}
|
|
49
76
|
case 'MarkdownV2': {
|
|
50
77
|
// MarkdownV2 requires escaping special characters
|
|
@@ -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
|
+
};
|
|
@@ -5,7 +5,8 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
5
5
|
* @param {string} url - The GitHub URL to parse
|
|
6
6
|
* @returns {Object} Parsed URL information including:
|
|
7
7
|
* - valid: boolean indicating if the URL is valid
|
|
8
|
-
* - normalized: the normalized URL (https://github.com/...)
|
|
8
|
+
* - normalized: the normalized URL (https://github.com/...), query/fragment kept
|
|
9
|
+
* - canonical: the URL the bot actually interprets (no query string, no #fragment)
|
|
9
10
|
* - type: 'user', 'repo', 'issue', 'pull', 'gist', 'actions', etc.
|
|
10
11
|
* - owner: repository owner/organization
|
|
11
12
|
* - repo: repository name (if applicable)
|
|
@@ -96,6 +97,13 @@ export function parseGitHubUrl(url) {
|
|
|
96
97
|
const result = {
|
|
97
98
|
valid: true,
|
|
98
99
|
normalized: normalizedUrl,
|
|
100
|
+
// Issue #2166: `normalized` keeps whatever query string or fragment the user
|
|
101
|
+
// pasted (`…/pull/18#issuecomment-5370631063`), but nothing downstream reads
|
|
102
|
+
// it — the bot resolves the target from owner/repo/number alone. Echoing the
|
|
103
|
+
// fragment back therefore claims an interpretation that never happened, so
|
|
104
|
+
// `canonical` is the URL the bot actually acted on and is what gets shown,
|
|
105
|
+
// queued and matched against.
|
|
106
|
+
canonical: `https://github.com${urlObj.pathname.replace(/\/+$/, '')}`,
|
|
99
107
|
hostname: 'github.com',
|
|
100
108
|
protocol: 'https',
|
|
101
109
|
path: urlObj.pathname,
|
|
@@ -242,6 +250,23 @@ export function normalizeGitHubUrl(url) {
|
|
|
242
250
|
return parsed.valid ? parsed.normalized : null;
|
|
243
251
|
}
|
|
244
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Reduce a GitHub URL to the part the tooling actually interprets: no query
|
|
255
|
+
* string, no `#issuecomment-…` fragment, no trailing slash.
|
|
256
|
+
*
|
|
257
|
+
* Used wherever a URL is echoed back to a user, stored as a queue key, or
|
|
258
|
+
* compared against another URL, so that a link copied from a comment and the
|
|
259
|
+
* same link copied from the address bar name one and the same task (issue #2166).
|
|
260
|
+
*
|
|
261
|
+
* @param {string} url
|
|
262
|
+
* @returns {string} The canonical URL, or the trimmed input when it cannot be parsed.
|
|
263
|
+
*/
|
|
264
|
+
export function canonicalizeGitHubUrl(url) {
|
|
265
|
+
if (!url || typeof url !== 'string') return url;
|
|
266
|
+
const parsed = parseGitHubUrl(url);
|
|
267
|
+
return parsed.valid && parsed.canonical ? parsed.canonical : url.trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
245
270
|
/** Build the canonical web URL for a pull request already identified by GitHub. */
|
|
246
271
|
export function buildGitHubPullRequestUrl({ owner, repo, number } = {}) {
|
|
247
272
|
if (!owner || !repo || !Number.isInteger(Number(number)) || Number(number) <= 0) {
|