@link-assistant/hive-mind 2.11.2 → 2.11.4
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/agent-commander.lib.mjs +33 -12
- package/src/agent-token-usage.lib.mjs +5 -9
- package/src/agent.lib.mjs +110 -152
- package/src/ai-tool-scratch.lib.mjs +143 -0
- package/src/anthropic-cost-accumulator.lib.mjs +36 -0
- package/src/auto-restart-budget.lib.mjs +107 -0
- package/src/auto-restart-exhaustion.lib.mjs +122 -0
- package/src/claude.lib.mjs +19 -15
- package/src/claude.runtime-switch.lib.mjs +4 -4
- package/src/codex.lib.mjs +46 -8
- package/src/credential-sanitization-core.lib.mjs +27 -3
- package/src/formal-ai-pricing.lib.mjs +110 -0
- package/src/gemini.lib.mjs +34 -43
- package/src/github-cost-info.lib.mjs +5 -0
- package/src/json-stream.lib.mjs +219 -0
- package/src/opencode.lib.mjs +56 -74
- package/src/pull-request-changes.lib.mjs +166 -0
- package/src/qwen.lib.mjs +33 -32
- package/src/reviewers-hive.mjs +2 -2
- package/src/solve.auto-merge.lib.mjs +68 -44
- package/src/solve.auto-pr.lib.mjs +11 -3
- package/src/solve.finalize.lib.mjs +15 -0
- package/src/solve.repository.lib.mjs +21 -4
- package/src/solve.restart-shared.lib.mjs +6 -2
- package/src/solve.results.lib.mjs +29 -16
- package/src/solve.watch.lib.mjs +47 -18
- package/src/use-m-bootstrap.lib.mjs +23 -4
- package/src/use-m-single-flight.lib.mjs +350 -0
- package/src/use-with-retry.lib.mjs +8 -2
- package/src/working-session-summary.lib.mjs +65 -0
- package/src/youtrack/youtrack-sync.mjs +2 -2
|
@@ -67,6 +67,10 @@ const { reportError } = sentryLib;
|
|
|
67
67
|
const prIssueLinking = await import('./pr-issue-linking.lib.mjs');
|
|
68
68
|
const { buildIssueReference, ensureIssueLinkInPullRequestBody } = prIssueLinking;
|
|
69
69
|
|
|
70
|
+
// Issue #2119: the one place that decides whether a pull request changed anything.
|
|
71
|
+
const { formatChangeSummary, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
|
|
72
|
+
const { buildNoChangesNotice, redactWorkspacePaths } = await import('./working-session-summary.lib.mjs');
|
|
73
|
+
|
|
70
74
|
/**
|
|
71
75
|
* Placeholder patterns used to detect auto-generated PR content that was not updated by the agent.
|
|
72
76
|
* These patterns match the initial WIP PR created by solve.auto-pr.lib.mjs.
|
|
@@ -158,7 +162,7 @@ export const ensurePullRequestIssueLink = async ({ prNumber, issueNumber, owner,
|
|
|
158
162
|
await writeSanitizedPublicationFile(tempBodyFile, linkResult.body);
|
|
159
163
|
|
|
160
164
|
try {
|
|
161
|
-
const updateResult = await command`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file
|
|
165
|
+
const updateResult = await command`gh pr edit ${prNumber} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
|
|
162
166
|
await fs.unlink(tempBodyFile).catch(() => {});
|
|
163
167
|
|
|
164
168
|
if (updateResult.code === 0) {
|
|
@@ -787,7 +791,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
787
791
|
if (prTitleHasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
|
|
788
792
|
const updatedTitle = await sanitizeForPublication(pr.title.replace(/^\[WIP\]\s*/, ''));
|
|
789
793
|
await log(` 📝 Removing [WIP] prefix from PR title...`);
|
|
790
|
-
const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title
|
|
794
|
+
const titleResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --title ${updatedTitle}`;
|
|
791
795
|
if (titleResult.code === 0) {
|
|
792
796
|
await log(` ✅ Updated PR title to: "${updatedTitle}"`);
|
|
793
797
|
} else {
|
|
@@ -801,14 +805,14 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
801
805
|
if (hasPlaceholder && !argv.autoRestartOnNonUpdatedPullRequestDescription) {
|
|
802
806
|
await log(` 📝 Updating PR description to remove placeholder text...`);
|
|
803
807
|
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
808
|
+
// Issue #2119: measure the net diff. The reproduction PRs published
|
|
809
|
+
// "1 file(s) modified, 1 line(s) added" for a pull request that
|
|
810
|
+
// changed nothing, because the stats were never checked for being
|
|
811
|
+
// empty.
|
|
812
|
+
const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber: pr.number, $ });
|
|
813
|
+
if (!changeStats.hasChanges) {
|
|
814
|
+
await log(` ⚠️ PR #${pr.number} has an empty diff - the description will say so instead of claiming changes`, { level: 'warning' });
|
|
815
|
+
}
|
|
812
816
|
|
|
813
817
|
// Get the issue title for context
|
|
814
818
|
const issueTitleResult = await $`gh issue view ${issueNumber} --repo ${owner}/${repo} --json title --jq .title 2>&1`;
|
|
@@ -822,9 +826,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
822
826
|
This pull request implements a solution for ${issueRef}: ${issueTitle}
|
|
823
827
|
|
|
824
828
|
### Changes
|
|
825
|
-
|
|
826
|
-
- ${additions} line(s) added
|
|
827
|
-
- ${deletions} line(s) removed
|
|
829
|
+
${formatChangeSummary(changeStats)}
|
|
828
830
|
|
|
829
831
|
### Issue Reference
|
|
830
832
|
Fixes ${issueRef}
|
|
@@ -836,7 +838,7 @@ Fixes ${issueRef}
|
|
|
836
838
|
await writeSanitizedPublicationFile(tempBodyFile, newDescription);
|
|
837
839
|
|
|
838
840
|
try {
|
|
839
|
-
const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file
|
|
841
|
+
const descResult = await $`gh pr edit ${pr.number} --repo ${owner}/${repo} --body-file ${tempBodyFile}`;
|
|
840
842
|
await fs.unlink(tempBodyFile).catch(() => {});
|
|
841
843
|
|
|
842
844
|
if (descResult.code === 0) {
|
|
@@ -1269,7 +1271,7 @@ export const buildWorkingSessionSummaryDetails = ({ publicPricingEstimate = null
|
|
|
1269
1271
|
return `${costInfo}${budgetStats}`.trim();
|
|
1270
1272
|
};
|
|
1271
1273
|
|
|
1272
|
-
export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumber, owner, repo, publicPricingEstimate = null, anthropicTotalCostUSD = null, pricingInfo = null, budgetStatsData = null }) => {
|
|
1274
|
+
export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumber, owner, repo, publicPricingEstimate = null, anthropicTotalCostUSD = null, pricingInfo = null, budgetStatsData = null, changeStats = null }) => {
|
|
1273
1275
|
if (!resultSummary || typeof resultSummary !== 'string') {
|
|
1274
1276
|
await log('⚠️ No working session summary available to attach', { verbose: true });
|
|
1275
1277
|
return false;
|
|
@@ -1290,10 +1292,16 @@ export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumb
|
|
|
1290
1292
|
pricingInfo,
|
|
1291
1293
|
budgetStatsData,
|
|
1292
1294
|
});
|
|
1295
|
+
// Issue #2119: publish what the session actually produced. The reported
|
|
1296
|
+
// summary said "The `pwd` command completed" and printed the solver's own
|
|
1297
|
+
// /tmp workspace, on a pull request that was still empty.
|
|
1298
|
+
const noChangesNotice = buildNoChangesNotice(changeStats);
|
|
1299
|
+
const summaryBody = redactWorkspacePaths(resultSummary);
|
|
1300
|
+
|
|
1293
1301
|
const comment = `${toolComments.WORKING_SESSION_SUMMARY_AUTOMATION_MARKER}
|
|
1294
1302
|
## ${toolComments.WORKING_SESSION_SUMMARY_MARKER}
|
|
1295
1303
|
|
|
1296
|
-
${
|
|
1304
|
+
${summaryBody}${noChangesNotice ? `\n\n${noChangesNotice}` : ''}${usageDetails ? `\n\n${usageDetails}` : ''}
|
|
1297
1305
|
|
|
1298
1306
|
---
|
|
1299
1307
|
*${toolComments.WORKING_SESSION_SUMMARY_AUTOMATED_FOOTER}*`;
|
|
@@ -1395,6 +1403,10 @@ export const maybeAttachWorkingSessionSummary = async ({ argv, resultSummary, wo
|
|
|
1395
1403
|
...sessionUsage,
|
|
1396
1404
|
})
|
|
1397
1405
|
: null);
|
|
1406
|
+
// Issue #2119: a summary posted on a pull request that changed nothing must
|
|
1407
|
+
// say so, instead of reading as a report of completed work.
|
|
1408
|
+
const changeStats = prNumber ? await getPullRequestChangeStats({ owner, repo, prNumber, $ }) : null;
|
|
1409
|
+
|
|
1398
1410
|
const ok = await attachSolutionSummary({
|
|
1399
1411
|
resultSummary,
|
|
1400
1412
|
prNumber,
|
|
@@ -1405,6 +1417,7 @@ export const maybeAttachWorkingSessionSummary = async ({ argv, resultSummary, wo
|
|
|
1405
1417
|
anthropicTotalCostUSD,
|
|
1406
1418
|
pricingInfo,
|
|
1407
1419
|
budgetStatsData: resolvedBudgetStatsData,
|
|
1420
|
+
changeStats,
|
|
1408
1421
|
});
|
|
1409
1422
|
return { attached: !!ok, reason: ok ? 'attached' : 'post_failed', budgetStatsData: resolvedBudgetStatsData };
|
|
1410
1423
|
};
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -46,7 +46,12 @@ const { checkGitHubTerminalState } = terminalStateLib;
|
|
|
46
46
|
|
|
47
47
|
// Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
|
|
48
48
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
49
|
-
|
|
49
|
+
// Issue #2119: one auto-restart budget shared with solve.auto-merge.lib.mjs, so
|
|
50
|
+
// a limit of 5 means 5 AI sessions in total rather than 5 per subsystem, and
|
|
51
|
+
// every label renders in the same `N/M` form.
|
|
52
|
+
const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
|
|
53
|
+
const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, getAutoRestartIterationsUsed, getRemainingAutoRestartIterations, hasExhaustedAutoRestartBudget } = autoRestartBudget;
|
|
54
|
+
const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
|
|
50
55
|
|
|
51
56
|
// Issue #1625: Central marker constants + tracked comment posting
|
|
52
57
|
const toolComments = await import('./tool-comments.lib.mjs');
|
|
@@ -78,7 +83,9 @@ export const watchForFeedback = async params => {
|
|
|
78
83
|
|
|
79
84
|
const watchInterval = argv.watchInterval || 60; // seconds
|
|
80
85
|
const isTemporaryWatch = argv.temporaryWatch || false;
|
|
81
|
-
|
|
86
|
+
// Issue #2119: claim the shared budget; the same limit is honoured by the
|
|
87
|
+
// auto-merge restart loop that solve.mjs runs afterwards.
|
|
88
|
+
const maxAutoRestartIterations = beginAutoRestartBudget({ maxIterations: argv.autoRestartMaxIterations });
|
|
82
89
|
|
|
83
90
|
// Track latest session data across all iterations for accurate pricing
|
|
84
91
|
// Issue #1056: Seed from the *initial* tool execution so the first auto-restart
|
|
@@ -105,7 +112,7 @@ export const watchForFeedback = async params => {
|
|
|
105
112
|
await log(formatAligned('', 'Monitoring PR:', `#${prNumber}`, 2));
|
|
106
113
|
await log(formatAligned('', 'Mode:', 'Auto-restart (NOT --watch mode)', 2));
|
|
107
114
|
await log(formatAligned('', 'Stop conditions:', 'All changes committed OR PR merged OR max iterations reached', 2));
|
|
108
|
-
await log(formatAligned('', 'Max iterations:',
|
|
115
|
+
await log(formatAligned('', 'Max iterations:', formatAutoRestartLimit(), 2));
|
|
109
116
|
await log(formatAligned('', 'Note:', 'No wait time between iterations in auto-restart mode', 2));
|
|
110
117
|
} else {
|
|
111
118
|
await log(formatAligned('👁️', 'WATCH MODE ACTIVATED', ''));
|
|
@@ -118,8 +125,12 @@ export const watchForFeedback = async params => {
|
|
|
118
125
|
await log('');
|
|
119
126
|
|
|
120
127
|
let iteration = 0;
|
|
121
|
-
|
|
128
|
+
// Issue #2119: mirrors the shared budget counter so every label in this loop
|
|
129
|
+
// reports the run-wide iteration number, not a per-subsystem one.
|
|
130
|
+
let autoRestartCount = getAutoRestartIterationsUsed();
|
|
122
131
|
let firstIterationInTemporaryMode = isTemporaryWatch;
|
|
132
|
+
// Issue #2119: set when the budget runs out, so the caller learns the run failed.
|
|
133
|
+
let budgetExhaustion = null;
|
|
123
134
|
|
|
124
135
|
while (true) {
|
|
125
136
|
iteration++;
|
|
@@ -211,13 +222,25 @@ export const watchForFeedback = async params => {
|
|
|
211
222
|
break;
|
|
212
223
|
}
|
|
213
224
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await
|
|
225
|
+
// Issue #2119: the shared budget is exhausted. Previously this logged a
|
|
226
|
+
// warning and broke out of the loop, leaving the very uncommitted changes
|
|
227
|
+
// that triggered every restart on a temporary clone that is then deleted.
|
|
228
|
+
// Now the run fails and the work is auto-committed first, so the result
|
|
229
|
+
// stays visible in the PR.
|
|
230
|
+
if (hasExhaustedAutoRestartBudget()) {
|
|
231
|
+
const changes = await getUncommittedChangesDetails(tempDir);
|
|
232
|
+
budgetExhaustion = await failOnAutoRestartBudgetExhausted({
|
|
233
|
+
owner,
|
|
234
|
+
repo,
|
|
235
|
+
prNumber,
|
|
236
|
+
tempDir,
|
|
237
|
+
branchName: prBranch || branchName,
|
|
238
|
+
$,
|
|
239
|
+
log,
|
|
240
|
+
formatAligned,
|
|
241
|
+
blocker: changes.length > 0 ? `uncommitted changes remained: ${changes.join(', ')}` : 'uncommitted changes remained',
|
|
242
|
+
subsystem: 'auto-restart on uncommitted changes',
|
|
243
|
+
});
|
|
221
244
|
break;
|
|
222
245
|
}
|
|
223
246
|
}
|
|
@@ -274,17 +297,18 @@ export const watchForFeedback = async params => {
|
|
|
274
297
|
}
|
|
275
298
|
await log('');
|
|
276
299
|
|
|
277
|
-
//
|
|
278
|
-
|
|
300
|
+
// Issue #2119: claim one iteration from the run-wide budget shared with
|
|
301
|
+
// the auto-merge restart loop.
|
|
302
|
+
autoRestartCount = consumeAutoRestartIteration();
|
|
279
303
|
autoRestartIterationsRan = true; // Issue #1290: Mark that auto-restart iterations ran
|
|
280
304
|
lastIterationLogUploaded = false; // Reset log upload tracking for new iteration
|
|
281
|
-
const restartLabel =
|
|
305
|
+
const restartLabel = `Restart ${formatAutoRestartLabel(autoRestartCount)}`;
|
|
282
306
|
await log(formatAligned('🔄', `${restartLabel}:`, `Running ${argv.tool.toUpperCase()} to handle uncommitted changes...`));
|
|
283
307
|
|
|
284
308
|
// Post a comment to PR about auto-restart
|
|
285
309
|
if (prNumber) {
|
|
286
310
|
try {
|
|
287
|
-
const remainingIterations =
|
|
311
|
+
const remainingIterations = getRemainingAutoRestartIterations();
|
|
288
312
|
|
|
289
313
|
// Get uncommitted files list for the comment
|
|
290
314
|
let uncommittedFilesList = '';
|
|
@@ -292,7 +316,7 @@ export const watchForFeedback = async params => {
|
|
|
292
316
|
uncommittedFilesList = '\n\n**Uncommitted files:**\n```\n' + changes.join('\n') + '\n```';
|
|
293
317
|
}
|
|
294
318
|
|
|
295
|
-
const iterationLabel =
|
|
319
|
+
const iterationLabel = formatAutoRestartLabel(autoRestartCount);
|
|
296
320
|
const stopText = remainingIterations === null ? 'Auto-restart is configured with no iteration limit.' : `Auto-restart will stop after changes are committed or discarded, or after ${remainingIterations} more iteration${remainingIterations !== 1 ? 's' : ''}.`;
|
|
297
321
|
const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} ${iterationLabel}\n\nDetected uncommitted changes from previous run. Starting new session to review and commit or discard them.${uncommittedFilesList}\n\n---\n*${stopText} Please wait until working session will end and give your feedback.*`;
|
|
298
322
|
// Issue #1625: Track so this doesn't falsely count as AI-authored.
|
|
@@ -471,7 +495,7 @@ export const watchForFeedback = async params => {
|
|
|
471
495
|
const logFile = getLogFile();
|
|
472
496
|
if (logFile) {
|
|
473
497
|
// Use "Auto-restart X/Y Failure Log" format to distinguish from success logs
|
|
474
|
-
const iterationLabel =
|
|
498
|
+
const iterationLabel = formatAutoRestartLabel(autoRestartCount);
|
|
475
499
|
const customTitle = `⚠️ Auto-restart ${iterationLabel} Failure Log`;
|
|
476
500
|
const logUploadSuccess = await attachLogToGitHub({
|
|
477
501
|
logFile,
|
|
@@ -607,7 +631,7 @@ export const watchForFeedback = async params => {
|
|
|
607
631
|
const logFile = getLogFile();
|
|
608
632
|
if (logFile) {
|
|
609
633
|
// Use "Auto-restart X/Y Log" format as requested in issue #1107
|
|
610
|
-
const iterationLabel =
|
|
634
|
+
const iterationLabel = formatAutoRestartLabel(autoRestartCount);
|
|
611
635
|
const customTitle = `🔄 Auto-restart ${iterationLabel} Log`;
|
|
612
636
|
const logUploadSuccess = await attachLogToGitHub({
|
|
613
637
|
logFile,
|
|
@@ -733,6 +757,11 @@ export const watchForFeedback = async params => {
|
|
|
733
757
|
latestAnthropicCost,
|
|
734
758
|
autoRestartIterationsRan, // True if any auto-restart iterations actually ran
|
|
735
759
|
lastIterationLogUploaded, // True if the last iteration's logs were uploaded
|
|
760
|
+
// Issue #2119: false when the shared auto-restart budget ran out, so the run
|
|
761
|
+
// is reported as failed instead of silently exiting with work still pending.
|
|
762
|
+
success: !budgetExhaustion,
|
|
763
|
+
reason: budgetExhaustion?.reason || null,
|
|
764
|
+
autoRestartLimitReached: Boolean(budgetExhaustion),
|
|
736
765
|
};
|
|
737
766
|
};
|
|
738
767
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { wrapUseWithRetry } from './use-with-retry.lib.mjs';
|
|
4
|
+
import { wrapUseWithSingleFlight } from './use-m-single-flight.lib.mjs';
|
|
4
5
|
|
|
5
6
|
export const USE_M_BOOTSTRAP_URL = 'https://unpkg.com/use-m/use.js';
|
|
6
7
|
// Issue #2113: the fallback is only reached when unpkg cannot serve the `latest`
|
|
@@ -9,8 +10,13 @@ export const USE_M_BOOTSTRAP_URL = 'https://unpkg.com/use-m/use.js';
|
|
|
9
10
|
// dependency import to the least resilient loader available. 8.14.4 is the first
|
|
10
11
|
// release that both repairs corrupt aliases (8.14.3, use-m #66/#67) and removes
|
|
11
12
|
// them with a retry budget (8.14.4, use-m #68), so the degraded path now keeps
|
|
12
|
-
// upstream recovery instead of losing it.
|
|
13
|
-
|
|
13
|
+
// upstream recovery instead of losing it. 8.15.0 (use-m #70, the report filed
|
|
14
|
+
// from this issue) additionally serialises installs of one alias across
|
|
15
|
+
// processes with its own `.use-m/<alias>.lock` plus a post-install marker, so
|
|
16
|
+
// the pinned fallback now carries upstream prevention too — verified with the
|
|
17
|
+
// standalone reproduction: 8.14.4 fails 22/24 concurrent loads, 8.15.0 fails
|
|
18
|
+
// 0/24 (docs/case-studies/issue-2113/raw/experiment-upstream-use-m-8.15.0-fixed.log).
|
|
19
|
+
export const USE_M_BOOTSTRAP_FALLBACK_URL = 'https://unpkg.com/use-m@8.15.0/use.js';
|
|
14
20
|
|
|
15
21
|
const isMissingUseMBundle = code => /^Not found: \/use-m@[^/]+\/use\.js\s*$/.test(code.trim());
|
|
16
22
|
|
|
@@ -62,9 +68,22 @@ export const ensureUseM = async (options = {}) => {
|
|
|
62
68
|
// Only a few call sites used useWithRetry explicitly; wrapping here means
|
|
63
69
|
// every `await use(...)` in the codebase recovers by deleting the corrupt
|
|
64
70
|
// install directory and re-fetching.
|
|
65
|
-
|
|
71
|
+
//
|
|
72
|
+
// Issue #2113: retrying alone is not enough. use-m runs one
|
|
73
|
+
// `npm install -g <alias>@npm:<pkg>@<version>` per `use()` call with no
|
|
74
|
+
// in-flight dedup, and 38 modules under src/ load command-stream through
|
|
75
|
+
// use(), 31 of them with a top-level
|
|
76
|
+
// `await use('command-stream')`. Node evaluates sibling top-level-await
|
|
77
|
+
// subgraphs concurrently, so a cold container fires dozens of simultaneous
|
|
78
|
+
// global installs of the *same* alias directory; they delete and re-extract
|
|
79
|
+
// each other's trees, producing the ENOTEMPTY and half-extracted-package
|
|
80
|
+
// failures recorded in the issue. Every retry re-enters the same race, so
|
|
81
|
+
// the single-flight layer wraps the retry layer: identical loads collapse
|
|
82
|
+
// into one install, and installs of the same alias are serialised within
|
|
83
|
+
// and across processes.
|
|
84
|
+
globalThis.use = wrapUseWithSingleFlight(wrapUseWithRetry(rawUse));
|
|
66
85
|
} else {
|
|
67
|
-
globalThis.use = wrapUseWithRetry(globalThis.use);
|
|
86
|
+
globalThis.use = wrapUseWithSingleFlight(wrapUseWithRetry(globalThis.use));
|
|
68
87
|
}
|
|
69
88
|
return globalThis.use;
|
|
70
89
|
};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single-flight layer for `use-m` package loading (issue #2113).
|
|
5
|
+
*
|
|
6
|
+
* Root cause this file addresses
|
|
7
|
+
* -----------------------------
|
|
8
|
+
* `use-m` installs every package it resolves with a *global* npm install:
|
|
9
|
+
*
|
|
10
|
+
* npm install -g <pkg>-v-<version>@npm:<pkg>@<version>
|
|
11
|
+
*
|
|
12
|
+
* and it has no in-flight deduplication — every `use(specifier)` call runs the
|
|
13
|
+
* full `ensurePackageInstalled` → `installPackage` path. Hive Mind has 36
|
|
14
|
+
* modules under `src/` whose module body starts with a top-level
|
|
15
|
+
* `await use('command-stream')`, and Node evaluates sibling top-level-await
|
|
16
|
+
* subgraphs *concurrently*. On a cold container that means dozens of
|
|
17
|
+
* simultaneous `npm install -g command-stream-v-latest@npm:command-stream@latest`
|
|
18
|
+
* processes writing into the same global `node_modules` directory.
|
|
19
|
+
*
|
|
20
|
+
* npm has no cross-process locking for the global prefix, so those installs
|
|
21
|
+
* delete and re-extract each other's trees. The two symptoms recorded in the
|
|
22
|
+
* issue are exactly what that race produces (both reproduced in
|
|
23
|
+
* `experiments/issue-2113/reproduce-concurrent-install-race.mjs`):
|
|
24
|
+
*
|
|
25
|
+
* * `npm error ENOTEMPTY: directory not empty, rmdir
|
|
26
|
+
* '<...>/command-stream-v-latest/examples'` — one npm is removing the alias
|
|
27
|
+
* while another is extracting into it, so the directory it just emptied is
|
|
28
|
+
* repopulated before the `rmdir`;
|
|
29
|
+
* * a half-extracted tree that imports fine at the entry point but throws
|
|
30
|
+
* `ERR_MODULE_NOT_FOUND` for an arbitrary internal file
|
|
31
|
+
* (`shell-parser.mjs`, `terminal-capture.mjs`, `$.trace.mjs`).
|
|
32
|
+
*
|
|
33
|
+
* Retrying cannot fix this, because every retry re-enters the same race with
|
|
34
|
+
* the same 30-odd competitors — which is why use-m's own 3 install attempts and
|
|
35
|
+
* `useWithRetry`'s backoff both failed in the logs attached to the issue.
|
|
36
|
+
*
|
|
37
|
+
* The fix
|
|
38
|
+
* -------
|
|
39
|
+
* Make the install happen **once**:
|
|
40
|
+
*
|
|
41
|
+
* 1. in-process memoisation per specifier — the 36 concurrent
|
|
42
|
+
* `use('command-stream')` calls collapse into one load (this also removes
|
|
43
|
+
* 35 redundant `npm show command-stream version` network round-trips);
|
|
44
|
+
* 2. an in-process mutex per npm *alias* — different specifiers that map to
|
|
45
|
+
* the same alias (`yargs@17.7.2` and `yargs@17.7.2/helpers`) are
|
|
46
|
+
* serialised, because they install the same directory;
|
|
47
|
+
* 3. a cross-process advisory lock per alias — two Hive Mind processes
|
|
48
|
+
* started at the same time (worker + monitor, CI matrix jobs) share one
|
|
49
|
+
* global `node_modules`, so the lock has to outlive a single process.
|
|
50
|
+
*
|
|
51
|
+
* The lock is deliberately *advisory and self-healing*: it is an atomic
|
|
52
|
+
* `mkdir`, refreshed by a heartbeat, stolen when stale, and abandoned (with a
|
|
53
|
+
* diagnostic) after a timeout. A stuck lock therefore degrades to today's
|
|
54
|
+
* behaviour instead of hanging Hive Mind.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import os from 'node:os';
|
|
58
|
+
import path from 'node:path';
|
|
59
|
+
import { isBuiltin } from 'node:module';
|
|
60
|
+
import { USE_RETRY_WRAPPED } from './use-with-retry.lib.mjs';
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_HEARTBEAT_MS = 1000;
|
|
63
|
+
export const DEFAULT_STALE_MS = 15000;
|
|
64
|
+
export const DEFAULT_POLL_MS = 100;
|
|
65
|
+
export const DEFAULT_TIMEOUT_MS = 300000;
|
|
66
|
+
|
|
67
|
+
const USE_SINGLE_FLIGHT_WRAPPED = Symbol.for('hive-mind.use-m-single-flight.wrapped');
|
|
68
|
+
|
|
69
|
+
// Mirrors use-m's own parser (`parseModuleSpecifier`) so the alias computed
|
|
70
|
+
// here is byte-identical to the directory npm will create.
|
|
71
|
+
const SPECIFIER_PATTERN = /^(?<packageName>(@[^@/]+\/)?[^@/]+)?(?:@(?<version>[^/]*))?(?<modulePath>(?:\/[^@]+)*)?$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} specifier
|
|
75
|
+
* @returns {{ packageName: string, version: string, modulePath: string } | null}
|
|
76
|
+
* `null` for anything that use-m will not install from npm (builtins,
|
|
77
|
+
* relative/absolute paths, unparseable input).
|
|
78
|
+
*/
|
|
79
|
+
export const parseSpecifier = specifier => {
|
|
80
|
+
if (typeof specifier !== 'string' || specifier.trim() === '') return null;
|
|
81
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:')) return null;
|
|
82
|
+
const match = specifier.match(SPECIFIER_PATTERN);
|
|
83
|
+
const packageName = match?.groups?.packageName;
|
|
84
|
+
if (typeof packageName !== 'string' || packageName.trim() === '') return null;
|
|
85
|
+
const version = typeof match.groups.version === 'string' && match.groups.version.trim() !== '' ? match.groups.version : 'latest';
|
|
86
|
+
const modulePath = typeof match.groups.modulePath === 'string' ? match.groups.modulePath : '';
|
|
87
|
+
return { packageName, version, modulePath };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The global `node_modules` directory name use-m installs into, e.g.
|
|
92
|
+
* `use('command-stream')` → `command-stream-v-latest`.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} specifier
|
|
95
|
+
* @returns {string | null}
|
|
96
|
+
*/
|
|
97
|
+
export const aliasForSpecifier = specifier => {
|
|
98
|
+
const parsed = parseSpecifier(specifier);
|
|
99
|
+
if (!parsed) return null;
|
|
100
|
+
return `${parsed.packageName.replace('@', '').replace('/', '-')}-v-${parsed.version}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Does loading this specifier run `npm install -g`?
|
|
105
|
+
*
|
|
106
|
+
* `use('fs')`, `use('path')` and `use('os')` account for 57 of Hive Mind's 128
|
|
107
|
+
* `use()` call sites; use-m answers them from its built-in resolver without
|
|
108
|
+
* touching npm, so they must not pay for (or wait on) an install lock. They are
|
|
109
|
+
* still memoised — 26 identical `use('fs')` calls should resolve one promise.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} specifier
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export const installsFromNpm = specifier => {
|
|
115
|
+
const parsed = parseSpecifier(specifier);
|
|
116
|
+
if (!parsed) return false;
|
|
117
|
+
return !isBuiltin(`${parsed.packageName}${parsed.modulePath}`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const defaultLockRoot = () => process.env.HIVE_MIND_USE_M_LOCK_DIR || path.join(os.tmpdir(), 'hive-mind-use-m-locks');
|
|
121
|
+
|
|
122
|
+
const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
123
|
+
|
|
124
|
+
const defaultLog = message => {
|
|
125
|
+
if (process.env.HIVE_MIND_USE_M_DEBUG || process.argv.includes('--verbose')) {
|
|
126
|
+
console.error(`[use-m] ${message}`);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// `/` and `@` never survive alias generation, but a caller may lock on an
|
|
131
|
+
// arbitrary key in tests — keep the lock directory name filesystem-safe.
|
|
132
|
+
const lockDirectoryFor = (lockRoot, key) => path.join(lockRoot, `${key.replace(/[^\w.@-]+/g, '_')}.lock`);
|
|
133
|
+
|
|
134
|
+
const noopRelease = async () => {};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acquire a cross-process advisory lock for one npm alias.
|
|
138
|
+
*
|
|
139
|
+
* The lock is a directory: `mkdir` is atomic on every filesystem Hive Mind runs
|
|
140
|
+
* on (ext4, overlayfs, fuse-overlayfs in the DinD image, tmpfs, APFS), unlike
|
|
141
|
+
* `writeFile` with `flag: 'wx'` on network filesystems.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} key - alias name.
|
|
144
|
+
* @param {object} [options]
|
|
145
|
+
* @param {string} [options.lockRoot]
|
|
146
|
+
* @param {number} [options.heartbeatMs] - how often the owner refreshes mtime.
|
|
147
|
+
* @param {number} [options.staleMs] - age after which a lock may be stolen.
|
|
148
|
+
* @param {number} [options.pollMs] - wait between acquisition attempts.
|
|
149
|
+
* @param {number} [options.timeoutMs] - give up (and proceed unlocked) after this.
|
|
150
|
+
* @param {object} [options.fs] - injectable `node:fs/promises`.
|
|
151
|
+
* @param {(ms: number) => Promise<void>} [options.sleep]
|
|
152
|
+
* @param {() => number} [options.now]
|
|
153
|
+
* @param {(message: string) => void} [options.log]
|
|
154
|
+
* @returns {Promise<{ acquired: boolean, path: string, release: () => Promise<void> }>}
|
|
155
|
+
*/
|
|
156
|
+
export const acquireAliasLock = async (key, options = {}) => {
|
|
157
|
+
const fs = options.fs ?? (await import('node:fs/promises'));
|
|
158
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
159
|
+
const now = options.now ?? Date.now;
|
|
160
|
+
const log = options.log ?? defaultLog;
|
|
161
|
+
const lockRoot = options.lockRoot ?? defaultLockRoot();
|
|
162
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
163
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
164
|
+
const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
165
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
166
|
+
const lockPath = lockDirectoryFor(lockRoot, key);
|
|
167
|
+
const startedAt = now();
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await fs.mkdir(lockRoot, { recursive: true });
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// A lock root we cannot create means no cross-process protection; the
|
|
173
|
+
// in-process layers still dedupe, so continue instead of failing the load.
|
|
174
|
+
log(`lock root ${lockRoot} is unusable (${error?.message}); continuing without a cross-process lock`);
|
|
175
|
+
return { acquired: false, path: lockPath, release: noopRelease };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (;;) {
|
|
179
|
+
try {
|
|
180
|
+
await fs.mkdir(lockPath);
|
|
181
|
+
// Best-effort ownership breadcrumb: it makes a stuck lock diagnosable
|
|
182
|
+
// (`cat /tmp/hive-mind-use-m-locks/<alias>.lock/owner.json`) but nothing
|
|
183
|
+
// depends on it being readable.
|
|
184
|
+
await fs.writeFile(path.join(lockPath, 'owner.json'), `${JSON.stringify({ pid: process.pid, hostname: os.hostname(), key, startedAt: new Date(startedAt).toISOString() }, null, 2)}\n`).catch(() => {});
|
|
185
|
+
log(`acquired install lock for '${key}' at ${lockPath}`);
|
|
186
|
+
|
|
187
|
+
// Keep the mtime fresh so other processes do not mistake a slow install
|
|
188
|
+
// (a cold `npm install -g` can take a minute) for a crashed owner.
|
|
189
|
+
const heartbeat = setInterval(() => {
|
|
190
|
+
const stamp = new Date(now());
|
|
191
|
+
Promise.resolve(fs.utimes(lockPath, stamp, stamp)).catch(() => {});
|
|
192
|
+
}, heartbeatMs);
|
|
193
|
+
heartbeat.unref?.();
|
|
194
|
+
|
|
195
|
+
let released = false;
|
|
196
|
+
const release = async () => {
|
|
197
|
+
if (released) return;
|
|
198
|
+
released = true;
|
|
199
|
+
clearInterval(heartbeat);
|
|
200
|
+
try {
|
|
201
|
+
await fs.rm(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
log(`failed to release install lock ${lockPath}: ${error?.message}`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
return { acquired: true, path: lockPath, release };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error?.code !== 'EEXIST') {
|
|
209
|
+
log(`could not create install lock ${lockPath} (${error?.message}); continuing without a cross-process lock`);
|
|
210
|
+
return { acquired: false, path: lockPath, release: noopRelease };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const stats = await fs.stat(lockPath).catch(() => null);
|
|
215
|
+
if (!stats) continue; // owner released between mkdir and stat — retry immediately.
|
|
216
|
+
|
|
217
|
+
const age = now() - stats.mtimeMs;
|
|
218
|
+
if (age > staleMs) {
|
|
219
|
+
log(`stealing stale install lock ${lockPath} (idle for ${Math.round(age)}ms)`);
|
|
220
|
+
await fs.rm(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => {});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (now() - startedAt > timeoutMs) {
|
|
225
|
+
log(`timed out after ${timeoutMs}ms waiting for install lock ${lockPath}; proceeding without it`);
|
|
226
|
+
return { acquired: false, path: lockPath, release: noopRelease };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await sleep(pollMs);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Serialise `fn` against every other caller holding the same alias, in this
|
|
235
|
+
* process and across processes.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} key
|
|
238
|
+
* @param {() => Promise<T>} fn
|
|
239
|
+
* @param {object} [options] - forwarded to {@link acquireAliasLock}.
|
|
240
|
+
* @returns {Promise<T>}
|
|
241
|
+
* @template T
|
|
242
|
+
*/
|
|
243
|
+
export const withAliasLock = async (key, fn, options = {}) => {
|
|
244
|
+
if (options.disabled) return fn();
|
|
245
|
+
const lock = await acquireAliasLock(key, options);
|
|
246
|
+
try {
|
|
247
|
+
return await fn();
|
|
248
|
+
} finally {
|
|
249
|
+
await lock.release();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const createState = () => ({ inflight: new Map(), chains: new Map() });
|
|
254
|
+
|
|
255
|
+
let sharedState = createState();
|
|
256
|
+
|
|
257
|
+
/** Drop memoised loads and alias chains (tests only). */
|
|
258
|
+
export const resetSingleFlightState = () => {
|
|
259
|
+
sharedState = createState();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const runOnAliasChain = (state, alias, fn) => {
|
|
263
|
+
const previous = state.chains.get(alias) ?? Promise.resolve();
|
|
264
|
+
// `.then(fn, fn)` so a failed predecessor does not strand the queue.
|
|
265
|
+
const result = previous.then(fn, fn);
|
|
266
|
+
const tail = result.then(
|
|
267
|
+
() => {},
|
|
268
|
+
() => {}
|
|
269
|
+
);
|
|
270
|
+
state.chains.set(alias, tail);
|
|
271
|
+
tail.then(() => {
|
|
272
|
+
if (state.chains.get(alias) === tail) state.chains.delete(alias);
|
|
273
|
+
});
|
|
274
|
+
return result;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wrap a `use` function so concurrent loads of the same package collapse into a
|
|
279
|
+
* single npm install.
|
|
280
|
+
*
|
|
281
|
+
* Composition order matters: single-flight must sit **outside**
|
|
282
|
+
* `wrapUseWithRetry`, so that the retry/repair logic (which deletes and
|
|
283
|
+
* reinstalls the alias directory) also runs under the lock. The wrapper carries
|
|
284
|
+
* both wrapper symbols, which keeps `ensureUseM()` idempotent — re-wrapping an
|
|
285
|
+
* already-protected `globalThis.use` returns it unchanged instead of nesting
|
|
286
|
+
* retries inside locks inside retries.
|
|
287
|
+
*
|
|
288
|
+
* @param {Function} use
|
|
289
|
+
* @param {object} [options]
|
|
290
|
+
* @param {boolean} [options.disabled] - skip the cross-process lock only.
|
|
291
|
+
* @param {object} [options.state] - injectable memo/chain state (tests).
|
|
292
|
+
* @returns {Function}
|
|
293
|
+
*/
|
|
294
|
+
export const wrapUseWithSingleFlight = (use, options = {}) => {
|
|
295
|
+
if (typeof use !== 'function' || use[USE_SINGLE_FLIGHT_WRAPPED]) return use;
|
|
296
|
+
const log = options.log ?? defaultLog;
|
|
297
|
+
const disabled = options.disabled ?? Boolean(process.env.HIVE_MIND_USE_M_NO_LOCK);
|
|
298
|
+
|
|
299
|
+
const wrapped = (specifier, ...args) => {
|
|
300
|
+
const state = options.state ?? sharedState;
|
|
301
|
+
const alias = aliasForSpecifier(specifier);
|
|
302
|
+
// Relative imports resolve against the *caller's* directory, so neither
|
|
303
|
+
// memoising nor serialising them is safe — pass them straight through.
|
|
304
|
+
if (!alias) return use(specifier, ...args);
|
|
305
|
+
|
|
306
|
+
// Issue #2113: both failing runs were started with `--verbose` and the log
|
|
307
|
+
// showed only the final crash. Tracing every load (specifier, alias,
|
|
308
|
+
// duration) is what makes the next incident diagnosable from the log alone.
|
|
309
|
+
const call = async () => {
|
|
310
|
+
const startedAt = Date.now();
|
|
311
|
+
log(`use('${specifier}') loading (alias ${alias})`);
|
|
312
|
+
try {
|
|
313
|
+
const module = await use(specifier, ...args);
|
|
314
|
+
log(`use('${specifier}') loaded in ${Date.now() - startedAt}ms`);
|
|
315
|
+
return module;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
log(`use('${specifier}') failed after ${Date.now() - startedAt}ms: ${error?.message}`);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
// Only npm-backed specifiers need the alias mutex and the file lock; a
|
|
322
|
+
// built-in has no install step to protect.
|
|
323
|
+
const start = installsFromNpm(specifier) ? () => runOnAliasChain(state, alias, () => withAliasLock(alias, call, { ...options, disabled, log })) : call;
|
|
324
|
+
|
|
325
|
+
// Extra arguments select a different resolver/context, so results are not
|
|
326
|
+
// interchangeable; those calls skip the memo but still take the lock.
|
|
327
|
+
if (args.length > 0) return start();
|
|
328
|
+
|
|
329
|
+
const pending = state.inflight.get(specifier);
|
|
330
|
+
if (pending) {
|
|
331
|
+
log(`use('${specifier}') joined an in-flight load (alias ${alias})`);
|
|
332
|
+
return pending;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const promise = start();
|
|
336
|
+
state.inflight.set(specifier, promise);
|
|
337
|
+
// Successful loads stay memoised for the process lifetime (Node caches the
|
|
338
|
+
// module anyway); failures are evicted so a later call can retry.
|
|
339
|
+
promise.catch(() => {
|
|
340
|
+
if (state.inflight.get(specifier) === promise) state.inflight.delete(specifier);
|
|
341
|
+
});
|
|
342
|
+
return promise;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
Object.defineProperty(wrapped, USE_SINGLE_FLIGHT_WRAPPED, { value: true });
|
|
346
|
+
// Claim the retry symbol too: `wrapUseWithRetry` is always applied first
|
|
347
|
+
// (see ensureUseM), so an outer re-wrap would invert the intended order.
|
|
348
|
+
Object.defineProperty(wrapped, USE_RETRY_WRAPPED, { value: true });
|
|
349
|
+
return wrapped;
|
|
350
|
+
};
|