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