@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/src/buildUserMention.lib.mjs +30 -3
  4. package/src/claude.budget-stats.lib.mjs +4 -2
  5. package/src/claude.lib.mjs +31 -16
  6. package/src/claude.stream-events.lib.mjs +56 -2
  7. package/src/disk-guard.lib.mjs +256 -0
  8. package/src/github-url-parser.lib.mjs +26 -1
  9. package/src/github.batch.lib.mjs +31 -11
  10. package/src/github.lib.mjs +3 -1
  11. package/src/hive.mjs +97 -11
  12. package/src/lib.mjs +23 -3
  13. package/src/list-solution-drafts.lib.mjs +17 -4
  14. package/src/locales/en.lino +1 -0
  15. package/src/locales/hi.lino +1 -0
  16. package/src/locales/ru.lino +1 -0
  17. package/src/locales/zh.lino +1 -0
  18. package/src/session-log-rename.lib.mjs +65 -0
  19. package/src/session-monitor.lib.mjs +3 -2
  20. package/src/solve.mjs +12 -0
  21. package/src/solve.repository.lib.mjs +5 -1
  22. package/src/solve.restart-shared.lib.mjs +18 -9
  23. package/src/solve.results.lib.mjs +6 -3
  24. package/src/solve.validation.lib.mjs +7 -9
  25. package/src/telegram-accept-invitations.lib.mjs +5 -3
  26. package/src/telegram-bot.mjs +18 -9
  27. package/src/telegram-command-execution.lib.mjs +2 -1
  28. package/src/telegram-context-safety.lib.mjs +70 -0
  29. package/src/telegram-fix-command.lib.mjs +68 -4
  30. package/src/telegram-language-command.lib.mjs +4 -3
  31. package/src/telegram-log-command.lib.mjs +14 -12
  32. package/src/telegram-markdown-validator.lib.mjs +192 -0
  33. package/src/telegram-merge-command.lib.mjs +18 -16
  34. package/src/telegram-message-filters.lib.mjs +1 -1
  35. package/src/telegram-safe-reply.lib.mjs +290 -21
  36. package/src/telegram-solve-queue-command.lib.mjs +2 -1
  37. package/src/telegram-solve-queue.lib.mjs +16 -7
  38. package/src/telegram-start-stop-command.lib.mjs +38 -27
  39. package/src/telegram-subscribers.lib.mjs +6 -4
  40. package/src/telegram-terminal-watch-command.lib.mjs +8 -7
  41. package/src/telegram-tokens-command.lib.mjs +2 -1
  42. 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.13.0",
3
+ "version": "2.13.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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
- // Escape _ and * in display name to prevent "can't find end of entity" errors (issue #1460)
46
- const escapedMarkdownName = displayName.replace(/_/g, '\\_').replace(/\*/g, '\\*');
47
- return `[${escapedMarkdownName}](${link})`;
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⚠️ JSONL deduplication: skipped ${tokenUsage.duplicateEntriesSkipped} duplicate entries (upstream: anthropics/claude-code#6805)`, { verbose: true });
363
+ await log(`\nℹ️ JSONL deduplication: skipped ${tokenUsage.duplicateEntriesSkipped} duplicate entries so token totals stay correct (known upstream behaviour: anthropics/claude-code#87303)`, { verbose: true });
362
364
  }
363
365
  if (tokenUsage.peakContextUsage > 0) {
364
366
  await log(`📊 Peak restored-context input: ${formatNumber(tokenUsage.peakContextUsage)} tokens`, { verbose: true });
@@ -35,6 +35,7 @@ import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
35
35
  import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.lib.mjs'; // Issue #1834 (PR #1835 feedback)
36
36
  import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
37
37
  import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
38
+ import { renameLogToSessionId } from './session-log-rename.lib.mjs'; // Issue #2160
38
39
  import { showResumeCommand } from './claude.resume-output.lib.mjs';
39
40
  import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
40
41
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
@@ -393,6 +394,11 @@ export const executeClaudeCommand = async params => {
393
394
  let resultSummary = null;
394
395
  let resultModelUsage = null;
395
396
  let lastToolResultError = null;
397
+ // Issue #2160: an in-session tool failure the AI handles itself (a blocked command, its own
398
+ // Bash timeout, a bare non-zero exit status). Kept apart from lastToolResultError so it is not
399
+ // reported as the session error, but still available as the last-resort detail for a
400
+ // truncated stream that has nothing better to point at (issue #2023).
401
+ let lastBenignToolResultError = null;
396
402
  // Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
397
403
  const subAgentCalls = [];
398
404
  // Issue #1590: Map tool_use_id -> subAgentCalls index for accumulating per-call usage from parent_tool_use_id events
@@ -633,16 +639,16 @@ export const executeClaudeCommand = async params => {
633
639
  if (!sessionId && data.session_id) {
634
640
  sessionId = data.session_id;
635
641
  await log(`📌 Session ID: ${sessionId}`);
636
- let sessionLogFile;
637
- try {
638
- const currentLogFile = getLogFile();
639
- sessionLogFile = path.join(path.dirname(currentLogFile), `${sessionId}.log`);
640
- await fs.rename(currentLogFile, sessionLogFile);
641
- setLogFile(sessionLogFile);
642
- await log(`📁 Log renamed to: ${sessionLogFile}`);
643
- } catch (renameError) {
644
- reportError(renameError, { context: 'rename_session_log', sessionId, sessionLogFile, operation: 'rename_log_file' });
645
- await log(`⚠️ Could not rename log file: ${renameError.message}`, { verbose: true });
642
+ // Issue #2160: shared implementation, so restart/watch iterations rename their
643
+ // logs too and a caller that forgets the accessors gets a named reason.
644
+ const renameResult = await renameLogToSessionId({ sessionId, getLogFile, setLogFile, log });
645
+ if (!renameResult.ok && renameResult.error) {
646
+ reportError(renameResult.error, {
647
+ context: 'rename_session_log',
648
+ sessionId,
649
+ sessionLogFile: renameResult.sessionLogFile,
650
+ operation: 'rename_log_file',
651
+ });
646
652
  }
647
653
  }
648
654
  const eventFacts = collectClaudeStreamEventFacts(data);
@@ -654,9 +660,16 @@ export const executeClaudeCommand = async params => {
654
660
  await log('📝 Captured fallback summary from Claude compaction context', { verbose: true });
655
661
  }
656
662
  if (eventFacts.toolResultError) {
657
- lastToolResultError = eventFacts.toolResultError;
658
- lastMessage = eventFacts.toolResultError;
659
- await log(`⚠️ Tool result error detected: ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
663
+ // Issue #2160: an in-session tool failure the AI handles itself is not a warning,
664
+ // and it must not replace the last assistant message — that message is what a
665
+ // truncated-stream failure is reported "after".
666
+ if (eventFacts.toolResultErrorIsBenign) {
667
+ lastBenignToolResultError = eventFacts.toolResultError;
668
+ await log(`ℹ️ In-session tool result (${eventFacts.toolResultErrorCategory}): ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
669
+ } else {
670
+ lastToolResultError = eventFacts.toolResultError;
671
+ await log(`⚠️ Tool result error detected: ${eventFacts.toolResultError.substring(0, 200)}`, { verbose: true });
672
+ }
660
673
  }
661
674
  // Issue #1708: signal busy/idle to the bidirectional handler so
662
675
  // queue-comments-to-input mode can hold frames until the AI is
@@ -903,9 +916,11 @@ export const executeClaudeCommand = async params => {
903
916
  toolUseCount += eventFacts.toolUseCountDelta;
904
917
  if (eventFacts.lastText) lastMessage = eventFacts.lastText;
905
918
  if (!resultSummary && eventFacts.compactionSummary) resultSummary = eventFacts.compactionSummary;
906
- if (eventFacts.toolResultError) {
919
+ // Issue #2160: same classification as the streaming path above.
920
+ if (eventFacts.toolResultError && eventFacts.toolResultErrorIsBenign) {
921
+ lastBenignToolResultError = eventFacts.toolResultError;
922
+ } else if (eventFacts.toolResultError) {
907
923
  lastToolResultError = eventFacts.toolResultError;
908
- lastMessage = eventFacts.toolResultError;
909
924
  }
910
925
  if (data?.type === 'result') {
911
926
  resultEventReceived = true;
@@ -1006,7 +1021,7 @@ export const executeClaudeCommand = async params => {
1006
1021
  }
1007
1022
  if (shouldFailClaudeStreamWithoutResult({ commandFailed, streamingInput, resultEventReceived })) {
1008
1023
  commandFailed = true;
1009
- lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage });
1024
+ lastMessage = buildMissingClaudeResultMessage({ lastToolResultError, lastMessage, lastBenignToolResultError });
1010
1025
  await log(`\n\n❌ Command failed: ${lastMessage}`, { level: 'error' });
1011
1026
  }
1012
1027
  const retryableLastError = classifyRetryableError(lastMessage);
@@ -19,12 +19,46 @@ const normalizeToolResultError = value => {
19
19
  }
20
20
  };
21
21
 
22
+ /**
23
+ * Issue #2160: not every `tool_result` marked `is_error` says something about the session.
24
+ * Most of them are the AI's own command failing inside the session — the AI sees the result and
25
+ * carries on. Run 4c1dedd8 logged 26 "⚠️ Tool result error detected" lines, all of them of this
26
+ * kind (11 harness-blocked `sleep`s, 9 × `Exit code 143` Bash timeouts, 4 × `Exit code 1`,
27
+ * 2 × `Exit code 127`), and each one also overwrote the
28
+ * last assistant message, so a truncated stream could be reported as having failed "after:
29
+ * Blocked: sleep 240 …" instead of after what the AI actually said.
30
+ *
31
+ * Categories:
32
+ * - `harness_blocked` the AI tool's own harness refused the command (e.g. foreground sleep)
33
+ * - `command_timeout` the AI's command hit its Bash timeout (SIGTERM ⇒ exit code 143)
34
+ * - `command_exit_code` a bare non-zero exit status with no further detail
35
+ * Anything else is left unclassified and keeps being treated as a real error signal.
36
+ *
37
+ * @param {string|null} toolResultError - normalized tool_result error text
38
+ * @returns {{benign: boolean, category: string|null}}
39
+ */
40
+ export const classifyToolResultError = toolResultError => {
41
+ if (typeof toolResultError !== 'string' || !toolResultError.trim()) return { benign: false, category: null };
42
+ const text = toolResultError.trim();
43
+
44
+ if (/^Blocked:/i.test(text)) return { benign: true, category: 'harness_blocked' };
45
+ if (/Command timed out after/i.test(text)) return { benign: true, category: 'command_timeout' };
46
+ // A bare "Exit code 143" is the SIGTERM the AI tool sends when its own Bash timeout fires.
47
+ if (/^Exit code 143\.?$/i.test(text)) return { benign: true, category: 'command_timeout' };
48
+ if (/^Exit code \d+\.?$/i.test(text)) return { benign: true, category: 'command_exit_code' };
49
+
50
+ return { benign: false, category: null };
51
+ };
52
+
22
53
  export const collectClaudeStreamEventFacts = data => {
23
54
  const facts = {
24
55
  messageCountDelta: 0,
25
56
  toolUseCountDelta: 0,
26
57
  lastText: null,
27
58
  toolResultError: null,
59
+ // Issue #2160: set when toolResultError is an in-session, self-handled tool failure.
60
+ toolResultErrorIsBenign: false,
61
+ toolResultErrorCategory: null,
28
62
  compactionSummary: null,
29
63
  };
30
64
  if (!data || typeof data !== 'object') return facts;
@@ -47,6 +81,12 @@ export const collectClaudeStreamEventFacts = data => {
47
81
  facts.toolResultError = data.tool_use_result.trim();
48
82
  }
49
83
 
84
+ if (facts.toolResultError) {
85
+ const classification = classifyToolResultError(facts.toolResultError);
86
+ facts.toolResultErrorIsBenign = classification.benign;
87
+ facts.toolResultErrorCategory = classification.category;
88
+ }
89
+
50
90
  return facts;
51
91
  };
52
92
 
@@ -54,8 +94,22 @@ export const shouldFailClaudeStreamWithoutResult = ({ commandFailed, streamingIn
54
94
  return !commandFailed && !streamingInput && !resultEventReceived;
55
95
  };
56
96
 
57
- export const buildMissingClaudeResultMessage = ({ lastToolResultError, lastMessage }) => {
58
- const detail = lastToolResultError || lastMessage;
97
+ /**
98
+ * Describe a stream that ended without a terminal result event (issue #2023).
99
+ *
100
+ * Detail preference, in order (issue #2160): a real tool error explains the truncation best; the
101
+ * last thing the AI said is next; a benign in-session tool result (a blocked command, a Bash
102
+ * timeout) is only used when there is nothing else, so it stays out of the message whenever the
103
+ * assistant actually said something.
104
+ *
105
+ * @param {Object} params
106
+ * @param {string|null} [params.lastToolResultError] - last non-benign tool_result error
107
+ * @param {string|null} [params.lastMessage] - last assistant text
108
+ * @param {string|null} [params.lastBenignToolResultError] - last self-handled tool_result error
109
+ * @returns {string}
110
+ */
111
+ export const buildMissingClaudeResultMessage = ({ lastToolResultError, lastMessage, lastBenignToolResultError = null }) => {
112
+ const detail = lastToolResultError || lastMessage || lastBenignToolResultError;
59
113
  if (!detail) return 'Claude stream ended without a terminal result event';
60
114
  return `Claude stream ended without a terminal result event after: ${String(detail).slice(0, 500)}`;
61
115
  };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Disk-space guard for solver workspaces (issue #2160).
3
+ *
4
+ * Reported symptom: `hive … --all-issues` finished with `❌ 4 task(s) failed (completed: 6)` even
5
+ * though nothing was wrong with those 4 issues. The run log shows why:
6
+ *
7
+ * - the target repository was public, so solve's auto-cleanup default resolved to OFF and every
8
+ * `/tmp/gh-issue-solver-*` workspace (~10 GB each) was kept;
9
+ * - hive checked free disk space exactly once, at startup (73.2 GB free), and kept dequeuing;
10
+ * - after 6 completed tasks only 9.8 GB were left, so each remaining task tripped solve's
11
+ * pre-flight check (`❌ Insufficient disk space: 10047MB available, 10240MB required`), exited
12
+ * after ~12s, posted a "Solution Draft Failed" comment and was counted as a *task* failure.
13
+ *
14
+ * An exhausted disk is an environment condition: the task is still perfectly solvable once space is
15
+ * available. This module lets the orchestrator (a) reclaim workspaces nobody is using any more,
16
+ * (b) wait for in-flight work to release space, and (c) report the condition as a deferral instead
17
+ * of a task failure.
18
+ *
19
+ * Everything that touches the outside world (df, readdir, rm, clock, sleep) is injectable so the
20
+ * behaviour can be tested without a full disk.
21
+ *
22
+ * @see https://github.com/link-assistant/hive-mind/issues/2160
23
+ */
24
+
25
+ import fsPromises from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { execFile } from 'node:child_process';
28
+ import { promisify } from 'node:util';
29
+
30
+ const execFileAsync = promisify(execFile);
31
+
32
+ /**
33
+ * Exit code a solver uses when it refuses to start because the host is out of disk space.
34
+ * 75 is EX_TEMPFAIL ("temporary failure, the user is invited to retry") from sysexits.h — the
35
+ * closest standard meaning to "nothing is wrong with the request, retry later".
36
+ */
37
+ export const EXIT_CODE_INSUFFICIENT_DISK_SPACE = 75;
38
+
39
+ /** Prefix of the temporary directories solve clones repositories into. */
40
+ export const SOLVER_WORKSPACE_PREFIX = 'gh-issue-solver-';
41
+
42
+ export const DEFAULT_TMP_ROOT = '/tmp';
43
+
44
+ /** A workspace whose contents changed this recently is never reclaimed. */
45
+ export const DEFAULT_MIN_IDLE_MS = 5 * 60 * 1000;
46
+
47
+ const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
48
+
49
+ const defaultRemove = async targetPath => fsPromises.rm(targetPath, { recursive: true, force: true });
50
+
51
+ /**
52
+ * Free space in MB on the filesystem holding `targetPath`, or null when it cannot be determined.
53
+ * `df -Pk` is POSIX-portable output (single line per filesystem, 1K blocks).
54
+ */
55
+ export const getFreeDiskSpaceMB = async (targetPath = DEFAULT_TMP_ROOT, { exec = execFileAsync } = {}) => {
56
+ try {
57
+ const { stdout } = await exec('df', ['-Pk', targetPath]);
58
+ const lines = String(stdout).trim().split('\n');
59
+ if (lines.length < 2) return null;
60
+ const columns = lines[lines.length - 1].trim().split(/\s+/);
61
+ const availableKB = Number.parseInt(columns[3], 10);
62
+ if (!Number.isFinite(availableKB)) return null;
63
+ return Math.floor(availableKB / 1024);
64
+ } catch {
65
+ return null;
66
+ }
67
+ };
68
+
69
+ /** Every `/tmp/gh-issue-solver-*` directory, oldest modification first. */
70
+ export const listSolverWorkspaces = async ({ tmpRoot = DEFAULT_TMP_ROOT, fileSystem = fsPromises } = {}) => {
71
+ let entries;
72
+ try {
73
+ entries = await fileSystem.readdir(tmpRoot);
74
+ } catch {
75
+ return [];
76
+ }
77
+ const workspaces = [];
78
+ for (const entry of entries) {
79
+ const name = typeof entry === 'string' ? entry : entry.name;
80
+ if (!name || !name.startsWith(SOLVER_WORKSPACE_PREFIX)) continue;
81
+ const workspacePath = path.join(tmpRoot, name);
82
+ let stats;
83
+ try {
84
+ stats = await fileSystem.stat(workspacePath);
85
+ } catch {
86
+ continue;
87
+ }
88
+ if (typeof stats.isDirectory === 'function' && !stats.isDirectory()) continue;
89
+ workspaces.push({ path: workspacePath, name, mtimeMs: Number(stats.mtimeMs) || 0 });
90
+ }
91
+ return workspaces.sort((a, b) => a.mtimeMs - b.mtimeMs);
92
+ };
93
+
94
+ /**
95
+ * Workspaces that a live process is currently sitting in. The AI tool runs with its workspace as
96
+ * cwd, so /proc/<pid>/cwd is an authoritative "do not touch" signal on Linux. When /proc cannot be
97
+ * read (macOS, restricted container) every workspace is reported as busy — refusing to guess is the
98
+ * only safe answer, since deleting a live workspace would destroy real work.
99
+ */
100
+ export const findBusySolverWorkspaces = async ({ workspaces = [], procRoot = '/proc', fileSystem = fsPromises } = {}) => {
101
+ if (!workspaces.length) return new Set();
102
+ let pids;
103
+ try {
104
+ pids = (await fileSystem.readdir(procRoot)).map(entry => (typeof entry === 'string' ? entry : entry.name)).filter(name => /^\d+$/.test(name));
105
+ } catch {
106
+ return new Set(workspaces.map(workspace => workspace.path));
107
+ }
108
+ const cwds = [];
109
+ for (const pid of pids) {
110
+ try {
111
+ cwds.push(String(await fileSystem.readlink(path.join(procRoot, pid, 'cwd'))));
112
+ } catch {
113
+ // The process exited, or its cwd is not readable by this user — nothing to protect here.
114
+ }
115
+ }
116
+ const busy = new Set();
117
+ for (const workspace of workspaces) {
118
+ if (cwds.some(cwd => cwd === workspace.path || cwd.startsWith(`${workspace.path}/`))) busy.add(workspace.path);
119
+ }
120
+ return busy;
121
+ };
122
+
123
+ /**
124
+ * Entries of the given temp roots that `--auto-cleanup` may delete.
125
+ *
126
+ * The old implementation ran `sudo rm -rf /tmp/* /var/tmp/*`, which also destroys the workspaces,
127
+ * lock directories and log files of any *concurrent* hive/solve run on the same host — the run
128
+ * doing the cleanup is rarely the only tenant of /tmp. This builds an explicit list instead and
129
+ * leaves alone anything a live process is sitting in, anything the caller marked as protected, and
130
+ * the run's own log file.
131
+ *
132
+ * @param {Object} [options]
133
+ * @param {Array<string>} [options.roots=['/tmp','/var/tmp']] - Directories to clean
134
+ * @param {Iterable<string>} [options.protectedPaths] - Paths that must survive
135
+ * @returns {Promise<{remove: Array<string>, keep: Array<{path: string, reason: string}>}>}
136
+ */
137
+ export const listCleanableTempEntries = async ({ roots = ['/tmp', '/var/tmp'], protectedPaths = new Set(), fileSystem = fsPromises, procRoot = '/proc' } = {}) => {
138
+ const protectedSet = new Set(Array.from(protectedPaths).filter(Boolean).map(String));
139
+ const remove = [];
140
+ const keep = [];
141
+ const candidates = [];
142
+ for (const root of roots) {
143
+ let entries;
144
+ try {
145
+ entries = await fileSystem.readdir(root);
146
+ } catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const name = typeof entry === 'string' ? entry : entry.name;
151
+ if (!name || name === '.' || name === '..') continue;
152
+ candidates.push({ path: path.join(root, name), name, mtimeMs: 0 });
153
+ }
154
+ }
155
+ const busy = await findBusySolverWorkspaces({ workspaces: candidates, procRoot, fileSystem });
156
+ for (const candidate of candidates) {
157
+ const isProtected = protectedSet.has(candidate.path) || Array.from(protectedSet).some(protectedPath => protectedPath.startsWith(`${candidate.path}/`));
158
+ if (isProtected) {
159
+ keep.push({ path: candidate.path, reason: 'protected' });
160
+ continue;
161
+ }
162
+ if (busy.has(candidate.path)) {
163
+ keep.push({ path: candidate.path, reason: 'process_cwd' });
164
+ continue;
165
+ }
166
+ remove.push(candidate.path);
167
+ }
168
+ return { remove, keep };
169
+ };
170
+
171
+ /** Workspace paths mentioned in a line of solver output, used to protect in-flight workspaces. */
172
+ export const extractSolverWorkspacePaths = (text, { tmpRoot = DEFAULT_TMP_ROOT } = {}) => {
173
+ if (!text) return [];
174
+ const pattern = new RegExp(`${tmpRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${SOLVER_WORKSPACE_PREFIX}[A-Za-z0-9_-]+`, 'g');
175
+ return Array.from(new Set(String(text).match(pattern) || []));
176
+ };
177
+
178
+ /**
179
+ * Remove idle solver workspaces, oldest first, until `requiredMB` is free.
180
+ * A workspace is skipped when it is in flight, is some process's cwd, or was modified recently.
181
+ */
182
+ export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, now = Date.now, log = async () => {}, fileSystem = fsPromises, procRoot = '/proc', getFreeMB = getFreeDiskSpaceMB, remove = defaultRemove } = {}) => {
183
+ const removed = [];
184
+ const skipped = [];
185
+ let freeMB = await getFreeMB(tmpRoot);
186
+ const workspaces = await listSolverWorkspaces({ tmpRoot, fileSystem });
187
+ if (!workspaces.length) return { removed, skipped, freeMB };
188
+ const busy = await findBusySolverWorkspaces({ workspaces, procRoot, fileSystem });
189
+ const currentTime = now();
190
+ for (const workspace of workspaces) {
191
+ if (freeMB !== null && freeMB >= requiredMB) break;
192
+ if (protectedPaths.has(workspace.path)) {
193
+ skipped.push({ path: workspace.path, reason: 'in_flight' });
194
+ continue;
195
+ }
196
+ if (busy.has(workspace.path)) {
197
+ skipped.push({ path: workspace.path, reason: 'process_cwd' });
198
+ continue;
199
+ }
200
+ if (currentTime - workspace.mtimeMs < minIdleMs) {
201
+ skipped.push({ path: workspace.path, reason: 'recently_modified' });
202
+ continue;
203
+ }
204
+ try {
205
+ await remove(workspace.path);
206
+ removed.push(workspace.path);
207
+ await log(` 🧹 Reclaimed idle solver workspace: ${workspace.path}`);
208
+ } catch (error) {
209
+ skipped.push({ path: workspace.path, reason: 'remove_failed', error });
210
+ await log(` ⚠️ Could not remove ${workspace.path}: ${error.message}`, { level: 'warning' });
211
+ continue;
212
+ }
213
+ freeMB = await getFreeMB(tmpRoot);
214
+ }
215
+ return { removed, skipped, freeMB };
216
+ };
217
+
218
+ /**
219
+ * Make sure `requiredMB` is free before a worker starts a task.
220
+ *
221
+ * Returns `{ ok: true }` when there is (or there now is) enough space, and
222
+ * `{ ok: false, reason: 'insufficient_disk_space' }` when the caller should defer the task instead
223
+ * of spawning a solver that would die in its pre-flight check.
224
+ *
225
+ * An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
226
+ * check, not a replacement for it.
227
+ */
228
+ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
229
+ const startedAt = now();
230
+ const reclaimed = [];
231
+ let freeMB = await getFreeMB(tmpRoot);
232
+ if (freeMB === null) {
233
+ await log(' 💾 Could not determine free disk space — continuing and letting the solver pre-flight check decide', { verbose: true });
234
+ return { ok: true, freeMB: null, reason: 'unknown_free_space', reclaimed, waitedMs: 0 };
235
+ }
236
+ if (freeMB >= requiredMB) {
237
+ await log(` 💾 Disk space before starting work: ${freeMB}MB free (${requiredMB}MB required)`, { verbose: true });
238
+ return { ok: true, freeMB, reason: 'sufficient', reclaimed, waitedMs: 0 };
239
+ }
240
+ await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
241
+ for (;;) {
242
+ const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
243
+ reclaimed.push(...result.removed);
244
+ if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
245
+ if (freeMB >= requiredMB) {
246
+ await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${result.removed.length} workspace(s)`);
247
+ return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
248
+ }
249
+ const elapsedMs = now() - startedAt;
250
+ if (elapsedMs + pollIntervalMs > maxWaitMs) {
251
+ return { ok: false, freeMB, reason: 'insufficient_disk_space', reclaimed, waitedMs: elapsedMs, skipped: result.skipped };
252
+ }
253
+ await log(` ⏳ Still ${freeMB}MB free of the ${requiredMB}MB required — waiting ${Math.round(pollIntervalMs / 1000)}s for in-flight work to release disk space`);
254
+ await sleep(pollIntervalMs);
255
+ }
256
+ };
@@ -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) {