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