@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.
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2119: does the pull request actually contain any changes?
5
+ *
6
+ * Nobody asked that question before, and two separate false positives followed
7
+ * from it. In the reproduction runs the AI tool produced nothing, so the branch
8
+ * ended up with the solver's own scaffolding commit and a revert of it - a net
9
+ * diff of zero files:
10
+ *
11
+ * https://github.com/konard/test-hello-world-019fb330-00e1-73b9-955e-f357a1600d5b/pull/2
12
+ * https://github.com/konard/test-hello-world-019fb330-fa49-7c9d-a664-b7ea33bb698a/pull/2
13
+ *
14
+ * Yet the published pull request bodies claimed
15
+ *
16
+ * ### Changes
17
+ * - 1 file(s) modified
18
+ * - 1 line(s) added
19
+ *
20
+ * (the stats were measured while the scaffolding commit was still in the diff
21
+ * and never revisited), and the Kotlin run went on to post "✅ Ready to merge -
22
+ * No pending changes" for a pull request that changed nothing at all.
23
+ *
24
+ * The third reproduction run failed before the AI committed anything, so its
25
+ * pull request kept the scaffolding file itself:
26
+ *
27
+ * https://github.com/konard/test-hello-world-019fb331-c107-78c7-8ff6-9f127a3c593c/pull/2
28
+ * .gitkeep | 1 +
29
+ *
30
+ * That is the same "nothing was implemented" state wearing a file count, so the
31
+ * solver's own placeholder is excluded from the counts here rather than being
32
+ * reported as the AI's work.
33
+ *
34
+ * This module is the single place that answers the question, so both the
35
+ * description writer and the mergeability watcher agree.
36
+ */
37
+
38
+ import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
39
+
40
+ /**
41
+ * The solver's own scaffolding files, recognised by the content it writes into
42
+ * them (`src/solve.auto-pr.lib.mjs`). A pull request whose whole diff is one of
43
+ * these contains no solution: the placeholder exists only to give an empty
44
+ * branch something to open a pull request from, and is reverted once the AI
45
+ * commits real work.
46
+ *
47
+ * Matching on content, not on the file name, keeps a repository's own
48
+ * `.gitkeep` or `CLAUDE.md` edits counted as the real changes they are.
49
+ */
50
+ const PLACEHOLDER_CONTENT_PATTERNS = new Map([
51
+ ['.gitkeep', [/^\+#\s*\.gitkeep file auto-generated at .+ for PR creation at branch /m]],
52
+ ['CLAUDE.md', [/^\+Issue to solve: \S+/m, /^\+Your prepared branch: \S+/m]],
53
+ ]);
54
+
55
+ /** Split a unified diff into one section per file. */
56
+ const splitDiffByFile = diff => {
57
+ const sections = [];
58
+ for (const line of diff.split('\n')) {
59
+ if (line.startsWith('diff --git ')) {
60
+ const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
61
+ sections.push({ path: match ? match[2] : '', body: '' });
62
+ continue;
63
+ }
64
+ if (sections.length > 0) sections[sections.length - 1].body += `${line}\n`;
65
+ }
66
+ return sections;
67
+ };
68
+
69
+ /** True when this file section is nothing but the solver's own placeholder. */
70
+ const isPlaceholderSection = section => {
71
+ const patterns = PLACEHOLDER_CONTENT_PATTERNS.get(section.path);
72
+ return Boolean(patterns) && patterns.every(pattern => pattern.test(section.body));
73
+ };
74
+
75
+ /**
76
+ * Measure the net diff of a pull request.
77
+ *
78
+ * The counts come from the unified diff rather than from the PR's `additions` /
79
+ * `deletions` fields because those are per-commit sums: a commit and its revert
80
+ * report 1 addition and 1 deletion while the net diff is empty.
81
+ *
82
+ * @param {Object} params
83
+ * @param {string} params.owner
84
+ * @param {string} params.repo
85
+ * @param {number} params.prNumber
86
+ * @param {Function} params.$ command-stream tagged-template executor
87
+ * @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, measured: boolean}>}
88
+ * The counts cover the AI's own work: the solver's placeholder file is
89
+ * excluded and reported through `placeholderOnly` instead. `measured` is
90
+ * false when the diff could not be fetched, in which case callers must not
91
+ * treat the pull request as empty.
92
+ */
93
+ export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $ }) => {
94
+ let diffOutput = '';
95
+ let measured = false;
96
+ try {
97
+ const result = await ghWithRateLimitRetry(() => $`gh pr diff ${prNumber} --repo ${owner}/${repo}`, { label: `pr diff ${owner}/${repo}#${prNumber}` });
98
+ if (result.code === 0) {
99
+ diffOutput = result.stdout.toString();
100
+ measured = true;
101
+ }
102
+ } catch {
103
+ // Leave measured false: an unreachable API must not read as "no changes".
104
+ }
105
+
106
+ const sections = splitDiffByFile(diffOutput);
107
+ const placeholderSections = sections.filter(isPlaceholderSection);
108
+ const realSections = sections.filter(section => !isPlaceholderSection(section));
109
+
110
+ const countMatches = (pattern, text) => (text.match(pattern) || []).length;
111
+ const filesChanged = realSections.length;
112
+ const additions = realSections.reduce((total, section) => total + countMatches(/^\+[^+]/gm, section.body), 0);
113
+ const deletions = realSections.reduce((total, section) => total + countMatches(/^-[^-]/gm, section.body), 0);
114
+
115
+ return {
116
+ hasChanges: filesChanged > 0,
117
+ filesChanged,
118
+ additions,
119
+ deletions,
120
+ placeholderOnly: filesChanged === 0 && placeholderSections.length > 0,
121
+ measured,
122
+ };
123
+ };
124
+
125
+ /**
126
+ * Render the "### Changes" section of a generated pull request description.
127
+ *
128
+ * When the diff is empty this says so instead of inventing a file count, so a
129
+ * reviewer reading the description learns the same thing the diff would tell
130
+ * them.
131
+ *
132
+ * @param {{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, measured: boolean}} stats
133
+ * @returns {string}
134
+ */
135
+ export const formatChangeSummary = stats => {
136
+ if (!stats.measured) {
137
+ return '- The diff could not be read, so the change summary is unavailable';
138
+ }
139
+ if (!stats.hasChanges) {
140
+ if (stats.placeholderOnly) {
141
+ return '- No files were changed by this pull request yet (it contains only the placeholder file the solver commits to open a pull request)';
142
+ }
143
+ return '- No files were changed by this pull request yet';
144
+ }
145
+ return [`- ${stats.filesChanged} file(s) modified`, `- ${stats.additions} line(s) added`, `- ${stats.deletions} line(s) removed`].join('\n');
146
+ };
147
+
148
+ /**
149
+ * The blocker to report when a pull request is otherwise mergeable but empty.
150
+ *
151
+ * Merging it would close the issue without changing anything, so this is
152
+ * treated as a reason to restart the AI rather than as success. The shared
153
+ * auto-restart budget bounds the retries and fails the run visibly once it is
154
+ * exhausted.
155
+ */
156
+ export const EMPTY_PULL_REQUEST_BLOCKER = 'The pull request contains no changes (its net diff is empty), so there is nothing to merge';
157
+
158
+ /**
159
+ * The same blocker, naming the placeholder when that is all the diff contains.
160
+ *
161
+ * @param {{placeholderOnly?: boolean}|null} stats
162
+ * @returns {string}
163
+ */
164
+ export const buildEmptyPullRequestBlocker = (stats = null) => (stats?.placeholderOnly ? 'The pull request contains only the placeholder file the solver commits to open a pull request, so there is nothing to merge' : EMPTY_PULL_REQUEST_BLOCKER);
165
+
166
+ export default { getPullRequestChangeStats, formatChangeSummary, EMPTY_PULL_REQUEST_BLOCKER, buildEmptyPullRequestBlocker };
package/src/qwen.lib.mjs CHANGED
@@ -18,12 +18,15 @@ import { reportError } from './sentry.lib.mjs';
18
18
  import { timeouts, retryLimits } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
20
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
21
- import { qwenModels, defaultModels } from './models/index.mjs';
21
+ import { qwenModels, defaultModels, isFormalAiModel } from './models/index.mjs';
22
22
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
23
+ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
23
24
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
24
25
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
25
26
  import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
27
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
26
28
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
29
+ import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
27
30
 
28
31
  export const mapModelToId = model => qwenModels[model] || model;
29
32
 
@@ -249,7 +252,7 @@ const applyQwenUsageToState = (state, event) => {
249
252
  applyQwenUsageObject(state, rawUsage, findFirstValue(event, QWEN_USAGE_PATHS.model));
250
253
  };
251
254
 
252
- const buildQwenPricingInfo = (state, mappedModel) => {
255
+ export const buildQwenPricingInfo = (state, mappedModel) => {
253
256
  const tokenUsage = cloneQwenTokenUsage(state?.tokenUsage);
254
257
  if (!tokenUsage || tokenUsage.stepCount === 0) {
255
258
  return {
@@ -264,6 +267,17 @@ const buildQwenPricingInfo = (state, mappedModel) => {
264
267
  tokenUsage.respondedModelId ||= tokenUsage.requestedModelId;
265
268
  const modelId = tokenUsage.respondedModelId || tokenUsage.requestedModelId;
266
269
 
270
+ // Issue #2119: `--model formal-ai` is served by the local Link.Assistant model
271
+ // server, so the session belongs to Link.Assistant at $0.00 - not to Qwen Code.
272
+ if (isFormalAiModel(mappedModel) || isFormalAiModel(modelId)) {
273
+ return {
274
+ pricingInfo: { ...buildFormalAiPricingInfo(modelId, tokenUsage), source: 'qwen-stream-json' },
275
+ publicPricingEstimate: 0,
276
+ tokenUsage,
277
+ resultModelUsage: buildQwenResultModelUsage(tokenUsage),
278
+ };
279
+ }
280
+
267
281
  return {
268
282
  pricingInfo: {
269
283
  provider: 'Qwen Code',
@@ -315,35 +329,19 @@ export const parseQwenStreamJsonOutput = (output, state = {}) => {
315
329
  const text = output?.toString?.() ?? String(output || '');
316
330
  nextState.plainText += text;
317
331
 
318
- const parseCandidate = value => {
319
- const trimmed = value.trim();
320
- if (!trimmed) return true;
321
-
322
- try {
323
- const parsed = JSON.parse(trimmed);
324
- if (Array.isArray(parsed)) {
325
- for (const item of parsed) addQwenEventToState(nextState, item);
326
- } else {
327
- addQwenEventToState(nextState, parsed);
328
- }
329
- return true;
330
- } catch {
331
- return false;
332
- }
333
- };
334
-
335
- const combined = `${nextState.buffer}${text}`;
336
- nextState.buffer = '';
337
-
338
- const lines = combined.split(/\r?\n/);
339
- for (let index = 0; index < lines.length; index++) {
340
- const line = lines[index];
341
- const isLastLine = index === lines.length - 1;
342
- if (!line.trim()) continue;
343
-
344
- const parsed = parseCandidate(line);
345
- if (!parsed && isLastLine) {
346
- nextState.buffer = line;
332
+ // Issue #2119: frame the stream by balanced JSON values instead of by lines.
333
+ // `formal-ai with qwen` emits pretty-printed, multi-line records, so every
334
+ // line failed to parse and every event - including the token usage - was
335
+ // dropped. Scanning for balanced values also covers records concatenated
336
+ // without a separator and records split across two process chunks.
337
+ const { records, rest } = takeJsonRecords(`${nextState.buffer}${text}`);
338
+ nextState.buffer = rest;
339
+
340
+ for (const record of records) {
341
+ if (Array.isArray(record)) {
342
+ for (const item of record) addQwenEventToState(nextState, item);
343
+ } else {
344
+ addQwenEventToState(nextState, record);
347
345
  }
348
346
  }
349
347
 
@@ -739,11 +737,14 @@ export const executeQwenCommand = async params => {
739
737
 
740
738
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
741
739
  await log('\n🔍 Checking for uncommitted changes...');
740
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
741
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
742
+ await ensureAiToolScratchIgnored(tempDir, log);
742
743
  try {
743
744
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
744
745
 
745
746
  if (gitStatusResult.code === 0) {
746
- const statusOutput = gitStatusResult.stdout.toString().trim();
747
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
747
748
 
748
749
  if (statusOutput) {
749
750
  await log('📝 Found uncommitted changes');
@@ -322,10 +322,10 @@ async function reviewer(reviewerId) {
322
322
  await log(` 🚀 Executing review.mjs for ${prUrl}...`);
323
323
 
324
324
  const startTime = Date.now();
325
- let reviewCommand = $`./review.mjs "${prUrl}" --model ${argv.model} --focus ${argv.focus}`;
325
+ let reviewCommand = $`./review.mjs ${prUrl} --model ${argv.model} --focus ${argv.focus}`;
326
326
 
327
327
  if (argv.autoApprove) {
328
- reviewCommand = $`./review.mjs "${prUrl}" --model ${argv.model} --focus ${argv.focus} --approve`;
328
+ reviewCommand = $`./review.mjs ${prUrl} --model ${argv.model} --focus ${argv.focus} --approve`;
329
329
  }
330
330
 
331
331
  // Stream output and capture result
@@ -67,7 +67,7 @@ const { buildCancelledCIReviewComment, getRetriggerableWorkflowRuns, shouldStopF
67
67
 
68
68
  // Issue #1625: Shared marker constants + posting/tracking helpers
69
69
  const toolComments = await import('./tool-comments.lib.mjs');
70
- const { READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
70
+ const { READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
71
71
 
72
72
  const externalReviewLimitLib = await import('./external-review-limit.lib.mjs');
73
73
  const { buildReadyForReviewComment } = externalReviewLimitLib;
@@ -81,8 +81,18 @@ const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = results
81
81
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
82
82
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
83
83
  const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
84
+ // Issue #2119: one auto-restart budget shared with solve.watch.lib.mjs. solve.mjs
85
+ // runs both loops in the same process, so before this a limit of 5 allowed 10 AI
86
+ // sessions and the two loops published incompatible progress labels
87
+ // ("Auto-restart triggered (iteration 1)" vs "Auto-restart 1/5 Log").
88
+ const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
89
+ const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, hasExhaustedAutoRestartBudget } = autoRestartBudget;
90
+ const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
84
91
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
85
92
 
93
+ // Issue #2119: an empty pull request must not be reported as ready to merge.
94
+ const { buildEmptyPullRequestBlocker, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
95
+
86
96
  // Issue #1895: explicitly close linked issues after merging a PR into a
87
97
  // non-default branch, where GitHub does not auto-close them.
88
98
  const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
@@ -101,7 +111,8 @@ export const watchUntilMergeable = async params => {
101
111
  const MIN_CI_CHECK_INTERVAL_SECONDS = 120;
102
112
  const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
103
113
  const isAutoMerge = argv.autoMerge || false;
104
- const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
114
+ // Issue #2119: join the shared budget instead of starting a second counter.
115
+ const maxAutoRestartIterations = beginAutoRestartBudget({ maxIterations: argv.autoRestartMaxIterations });
105
116
  const maxAutoResumeIterations = normalizeAutoIterationLimit(argv.autoResumeMaxIterations);
106
117
  // Issue #1503/#1573/#1612: repo-wide action gating is opt-in strict mode.
107
118
  // The config default may be bypassed when this module is reused directly, so normalize here.
@@ -112,7 +123,8 @@ export const watchUntilMergeable = async params => {
112
123
  let latestAnthropicCost = null;
113
124
 
114
125
  // Issue #1323: Track actual AI restarts separately from check cycle iterations
115
- let restartCount = 0;
126
+ // Issue #2119: the count now lives in the shared budget module, so restarts
127
+ // already spent by the watch loop earlier in this run are counted here too.
116
128
  let limitResumeCount = 0;
117
129
 
118
130
  // Issue #1371: In-memory dedup for "Ready to merge" comment (per-session, not all-time)
@@ -133,7 +145,7 @@ export const watchUntilMergeable = async params => {
133
145
  await log(formatAligned('', 'Mode:', isAutoMerge ? 'Auto-merge (will merge when ready)' : 'Auto-restart-until-mergeable (will NOT auto-merge)', 2));
134
146
  await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds (minimum: ${MIN_CI_CHECK_INTERVAL_SECONDS}s)`, 2));
135
147
  await log(formatAligned('', 'Initial cooldown:', `${INITIAL_COOLDOWN_SECONDS} seconds`, 2));
136
- await log(formatAligned('', 'Max restart iterations:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
148
+ await log(formatAligned('', 'Max restart iterations:', formatAutoRestartLimit(), 2));
137
149
  await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
138
150
  await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
139
151
  await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
@@ -277,9 +289,20 @@ export const watchUntilMergeable = async params => {
277
289
  }
278
290
  }
279
291
 
292
+ // Issue #2119: an empty pull request is not "ready to merge". The Kotlin
293
+ // reproduction run posted "✅ Ready to merge - No pending changes" for a
294
+ // pull request whose net diff was empty, so merging it would have closed
295
+ // the issue without implementing anything.
296
+ const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber, $ });
297
+ const isEmptyPullRequest = changeStats.measured && !changeStats.hasChanges;
298
+ const emptyPullRequestBlocker = buildEmptyPullRequestBlocker(changeStats);
299
+ if (isEmptyPullRequest) {
300
+ await log(formatAligned('⚠️', 'PR is empty:', changeStats.placeholderOnly ? 'only the solver placeholder file is in the diff - not treating it as mergeable' : 'net diff contains no files - not treating it as mergeable', 2), { level: 'warning' });
301
+ }
302
+
280
303
  // If PR is mergeable, no blockers, no new comments, no issue metadata
281
- // edits, and no uncommitted changes
282
- if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges) {
304
+ // edits, no uncommitted changes and it actually changes something
305
+ if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges && !isEmptyPullRequest) {
283
306
  // Issue #1503 (enhanced): Multi-mechanism consensus + repo-wide action check.
284
307
  // Before declaring PR mergeable, run multiple independent CI detection mechanisms
285
308
  // and require all to agree. This catches race conditions where CI starts between
@@ -434,6 +457,15 @@ export const watchUntilMergeable = async params => {
434
457
  feedbackLines.push('Please review and address the feedback from these comments.');
435
458
  }
436
459
 
460
+ // Issue #2119: Reason 1a: the pull request does not change anything yet.
461
+ if (isEmptyPullRequest) {
462
+ shouldRestart = true;
463
+ restartReason = restartReason ? `${restartReason}; ${emptyPullRequestBlocker}` : emptyPullRequestBlocker;
464
+ feedbackLines.push(`📭 ${emptyPullRequestBlocker}.`);
465
+ feedbackLines.push('');
466
+ feedbackLines.push('Implement the requested change and commit it to the pull request branch. Do not report the work as done while the diff is empty.');
467
+ }
468
+
437
469
  // Issue #2007: Reason 1b: Issue title/description edited by the user.
438
470
  if (hasIssueMetadataChanges) {
439
471
  shouldRestart = true;
@@ -691,38 +723,24 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
691
723
  }
692
724
 
693
725
  if (shouldRestart) {
694
- if (hasReachedAutoIterationLimit(restartCount, maxAutoRestartIterations)) {
695
- await log('');
696
- await log(formatAligned('⚠️', 'AUTO-RESTART LIMIT REACHED', `Stopping after ${restartCount} restart iteration${restartCount !== 1 ? 's' : ''}`));
697
- await log(formatAligned('', 'Configured limit:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
698
- await log(formatAligned('', 'Remaining blockers:', restartReason, 2));
699
- await log('');
700
-
701
- try {
702
- const limitComment = `## ⚠️ Auto-restart limit reached
703
-
704
- Hive Mind stopped auto-restart-until-mergeable after ${restartCount} restart iteration${restartCount !== 1 ? 's' : ''}.
705
-
706
- **Configured limit:** ${formatAutoIterationLimit(maxAutoRestartIterations)}
707
- **Remaining reason:** ${restartReason}
708
-
709
- No further AI sessions will be started automatically for this run. Please review the remaining blockers manually or rerun with a higher \`--auto-restart-max-iterations\` value.
710
-
711
- ---
712
- *Auto-restart-until-mergeable stopped by the safety limit.*`;
713
- await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: limitComment });
714
- } catch (commentError) {
715
- reportError(commentError, {
716
- context: 'post_auto_restart_limit_comment',
717
- owner,
718
- repo,
719
- prNumber,
720
- operation: 'comment_on_pr',
721
- });
722
- await log(formatAligned('', '⚠️ Could not post auto-restart limit comment to PR', '', 2));
723
- }
724
-
725
- return { success: false, reason: 'auto_restart_limit_reached', latestSessionId, latestAnthropicCost };
726
+ // Issue #2119: the run-wide budget is exhausted (it may already have been
727
+ // spent by the watch loop). Fail and auto-commit through the same shared
728
+ // exhaustion path the uncommitted-changes loop uses, so the outcome and
729
+ // the published comment are identical no matter which loop hit the limit.
730
+ if (hasExhaustedAutoRestartBudget()) {
731
+ const exhaustion = await failOnAutoRestartBudgetExhausted({
732
+ owner,
733
+ repo,
734
+ prNumber,
735
+ tempDir,
736
+ branchName: prBranch || branchName,
737
+ $,
738
+ log,
739
+ formatAligned,
740
+ blocker: restartReason,
741
+ subsystem: 'auto-restart-until-mergeable',
742
+ });
743
+ return { success: false, reason: exhaustion.reason, latestSessionId, latestAnthropicCost };
726
744
  }
727
745
 
728
746
  // Add standard instructions for auto-restart-until-mergeable mode using shared utility
@@ -759,17 +777,21 @@ No further AI sessions will be started automatically for this run. Please review
759
777
  }
760
778
 
761
779
  // Issue #1323: Increment restart count only when a tool execution is about to start.
762
- restartCount++;
780
+ // Issue #2119: claim it from the run-wide shared budget.
781
+ const restartCount = consumeAutoRestartIteration();
763
782
 
764
783
  await log(formatAligned('🔄', 'RESTART TRIGGERED:', restartReason));
765
- await log(formatAligned('', 'Restart iteration:', maxAutoRestartIterations === 0 ? `${restartCount}` : `${restartCount}/${maxAutoRestartIterations}`, 2));
784
+ await log(formatAligned('', 'Restart iteration:', formatAutoRestartLabel(restartCount), 2));
766
785
  await log('');
767
786
 
768
787
  // Post a comment to PR about the restart after preflight succeeds, so every
769
788
  // posted restart notification corresponds to an actual tool session.
770
789
  try {
771
- const limitText = maxAutoRestartIterations === 0 ? 'No automatic restart limit is configured.' : `This run will stop after ${maxAutoRestartIterations} restart iteration${maxAutoRestartIterations !== 1 ? 's' : ''}.`;
772
- const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} triggered (iteration ${restartCount})\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. ${limitText}*`;
790
+ const limitText = maxAutoRestartIterations === 0 ? 'No automatic restart limit is configured.' : `This run will stop after ${maxAutoRestartIterations} restart iteration${maxAutoRestartIterations !== 1 ? 's' : ''} in total.`;
791
+ // Issue #2119: the same `N/M` heading the uncommitted-changes loop posts.
792
+ // "triggered (iteration N)" hid the limit and made one auto-restart
793
+ // system look like two.
794
+ const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} ${formatAutoRestartLabel(restartCount)}\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. ${limitText}*`;
773
795
  // Issue #1625: Track so this doesn't falsely count as an AI-authored comment
774
796
  await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
775
797
  await log(formatAligned('', '💬 Posted auto-restart notification to PR', '', 2));
@@ -1093,8 +1115,10 @@ No further AI sessions will be started automatically for this run. Please review
1093
1115
  try {
1094
1116
  const logFile = getLogFile();
1095
1117
  if (logFile) {
1096
- // Issue #1323: Use restartCount (actual AI executions) instead of iteration (check cycles)
1097
- const customTitle = `🔄 Auto-restart-until-mergeable Log (iteration ${restartCount})`;
1118
+ // Issue #1323: Use the restart count (actual AI executions) instead of iteration (check cycles)
1119
+ // Issue #2119: `N/M` like every other auto-restart label, so the
1120
+ // limit is visible in the log title too.
1121
+ const customTitle = `🔄 ${AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER} ${formatAutoRestartLabel()}`;
1098
1122
  await attachLogToGitHub({
1099
1123
  logFile,
1100
1124
  targetType: 'pr',
@@ -1126,8 +1126,14 @@ ${prBody}`,
1126
1126
  // Link the issue to the PR in GitHub's Development section using GraphQL API
1127
1127
  await log(formatAligned('🔗', 'Linking:', `Issue #${issueNumber} to PR #${localPrNumber}...`));
1128
1128
  try {
1129
+ // Issue #2119: the double quotes below are GraphQL string syntax,
1130
+ // not shell quoting. command-stream escapes interpolated values, so
1131
+ // the queries are assembled in JS and passed as one argument.
1132
+ const repositorySelector = `repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(repo)})`;
1133
+
1129
1134
  // First, get the node IDs for both the issue and the PR
1130
- const issueNodeResult = await $`gh api graphql -f query='query { repository(owner: "${owner}", name: "${repo}") { issue(number: ${issueNumber}) { id } } }' --jq .data.repository.issue.id`;
1135
+ const issueNodeQuery = `query { ${repositorySelector} { issue(number: ${issueNumber}) { id } } }`;
1136
+ const issueNodeResult = await $`gh api graphql -f query=${issueNodeQuery} --jq .data.repository.issue.id`;
1131
1137
 
1132
1138
  if (issueNodeResult.code !== 0) {
1133
1139
  throw new Error(`Failed to get issue node ID: ${issueNodeResult.stderr}`);
@@ -1136,7 +1142,8 @@ ${prBody}`,
1136
1142
  const issueNodeId = issueNodeResult.stdout.toString().trim();
1137
1143
  await log(` Issue node ID: ${issueNodeId}`, { verbose: true });
1138
1144
 
1139
- const prNodeResult = await $`gh api graphql -f query='query { repository(owner: "${owner}", name: "${repo}") { pullRequest(number: ${localPrNumber}) { id } } }' --jq .data.repository.pullRequest.id`;
1145
+ const prNodeQuery = `query { ${repositorySelector} { pullRequest(number: ${localPrNumber}) { id } } }`;
1146
+ const prNodeResult = await $`gh api graphql -f query=${prNodeQuery} --jq .data.repository.pullRequest.id`;
1140
1147
 
1141
1148
  if (prNodeResult.code !== 0) {
1142
1149
  throw new Error(`Failed to get PR node ID: ${prNodeResult.stderr}`);
@@ -1152,7 +1159,8 @@ ${prBody}`,
1152
1159
  // 2. For cross-repo (fork) PRs, we need "Fixes owner/repo#N"
1153
1160
 
1154
1161
  // Let's verify the link was created
1155
- const linkCheckResult = await $`gh api graphql -f query='query { repository(owner: "${owner}", name: "${repo}") { pullRequest(number: ${localPrNumber}) { closingIssuesReferences(first: 10) { nodes { number } } } } }' --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number'`;
1162
+ const linkCheckQuery = `query { ${repositorySelector} { pullRequest(number: ${localPrNumber}) { closingIssuesReferences(first: 10) { nodes { number } } } } }`;
1163
+ const linkCheckResult = await $`gh api graphql -f query=${linkCheckQuery} --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number'`;
1156
1164
 
1157
1165
  if (linkCheckResult.code === 0) {
1158
1166
  const linkedIssues = parseClosingIssueNumbers(linkCheckResult.stdout);
@@ -1,3 +1,9 @@
1
+ // Issue #2119: "after 5 we must actually stop (fail + auto-commit on fail
2
+ // recovery). So the result will be actually visible." Both auto-restart loops
3
+ // record their exhaustion in this shared module, so the run exits non-zero
4
+ // instead of reporting success with the blocker still unresolved.
5
+ import { getAutoRestartLimitFailure, hasAutoRestartLimitFailure } from './auto-restart-exhaustion.lib.mjs';
6
+
1
7
  export async function finalizeSolveProcess({ tempDir, argv, limitReached, path, getLogFile, log, closeSentry, logActiveHandles, cleanupTempDirectory, safeExit }) {
2
8
  const runFinalizationStep = async (label, step) => {
3
9
  try {
@@ -31,6 +37,15 @@ export async function finalizeSolveProcess({ tempDir, argv, limitReached, path,
31
37
  // drainHandles() inside safeExit() will unref/close these before process.exit().
32
38
  await runFinalizationStep('active handle diagnostics', () => logActiveHandles(msg => log(msg)));
33
39
 
40
+ // Issue #2119: an exhausted auto-restart budget is a failure, not a completed run.
41
+ if (hasAutoRestartLimitFailure()) {
42
+ const failure = getAutoRestartLimitFailure();
43
+ await log(`\n❌ Auto-restart limit reached after ${failure.iterationsUsed} iteration${failure.iterationsUsed !== 1 ? 's' : ''} - the blocker was never resolved.`, { level: 'error' });
44
+ await log(failure.committed ? ' Uncommitted work was auto-committed before exit, so the partial result is visible.' : ' No uncommitted work was left to preserve.', { level: 'error' });
45
+ await safeExit(1, 'Auto-restart limit reached');
46
+ return;
47
+ }
48
+
34
49
  // Issue #1431: safeExit() unrefs handles so the event loop exits naturally, then calls process.exit(0)
35
50
  await safeExit(0, 'Process completed');
36
51
  }
@@ -29,6 +29,7 @@ const { log, formatAligned } = lib;
29
29
 
30
30
  // Import exit handler
31
31
  import { safeExit } from './exit-handler.lib.mjs';
32
+ import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
32
33
  import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
33
34
  import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
34
35
  import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
@@ -59,7 +60,11 @@ export const checkExistingForkOfRoot = async rootRepo => {
59
60
  const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get user (fork check)' });
60
61
  if (userResult.code !== 0) return null;
61
62
  const currentUser = userResult.stdout.toString().trim();
62
- const forksResult = await lib.ghCmdRetry(() => $`gh api repos/${rootRepo}/forks --paginate --jq '.[] | select(.owner.login == "${currentUser}") | .full_name'`, { label: `check forks of ${rootRepo}` });
63
+ // Issue #2119: build the jq expression in JS. Its double quotes belong to jq,
64
+ // not to the shell, and command-stream quotes interpolated values itself - so
65
+ // interpolating inside the quotes would leak shell quotes into the comparison.
66
+ const forkFilter = `.[] | select(.owner.login == ${JSON.stringify(currentUser)}) | .full_name`;
67
+ const forksResult = await lib.ghCmdRetry(() => $`gh api repos/${rootRepo}/forks --paginate --jq ${forkFilter}`, { label: `check forks of ${rootRepo}` });
63
68
  if (forksResult.code !== 0) return null;
64
69
 
65
70
  const forks = forksResult.stdout
@@ -324,9 +329,13 @@ export const tryInitializeEmptyRepository = async (owner, repo) => {
324
329
  const base64Content = Buffer.from(readmeContent).toString('base64');
325
330
 
326
331
  // Try to create README.md using GitHub API
332
+ // Issue #2119: `--field content="${base64Content}"` would leak literal quotes
333
+ // into the field value as soon as command-stream decides the value needs
334
+ // quoting, so the whole `key=value` token is built in JS instead.
335
+ const contentField = `content=${base64Content}`;
327
336
  const createResult = await $`gh api repos/${owner}/${repo}/contents/README.md --method PUT --silent \
328
- --field message="Initialize repository with README" \
329
- --field content="${base64Content}" 2>&1`;
337
+ --field message=${'Initialize repository with README'} \
338
+ --field ${contentField} 2>&1`;
330
339
 
331
340
  if (createResult.code === 0) {
332
341
  await log(`${formatAligned('✅', 'Success:', 'README.md created successfully')}`);
@@ -1062,6 +1071,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1062
1071
  if (cloneResult.code === 0 && repoIsValid) {
1063
1072
  await log(`${formatAligned('✅', 'Cloned to:', tempDir)}`);
1064
1073
 
1074
+ // Issue #2119: AI tools drop scratch state (`.formal-ai/`, `.playwright-mcp/`)
1075
+ // into the workspace. Exclude it here, once, so every later `git status` and
1076
+ // `git add -A` agrees instead of reading it as the AI's uncommitted work.
1077
+ await ensureAiToolScratchIgnored(tempDir, log);
1078
+
1065
1079
  // Verify and fix remote configuration
1066
1080
  const remoteCheckResult = await $({ cwd: tempDir })`git remote -v 2>&1`;
1067
1081
  if (!remoteCheckResult.stdout || !remoteCheckResult.stdout.toString().includes('origin')) {
@@ -1207,7 +1221,10 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
1207
1221
  // Strategy 1: Query the upstream repo's forks to find this user's fork
1208
1222
  if (owner) {
1209
1223
  await log(`${formatAligned('🔍', 'Discovering fork name:', `Searching ${owner}/${repo}/forks for ${prForkOwner}'s fork...`)}`);
1210
- const forksResult = await $`gh api repos/${owner}/${repo}/forks --paginate --jq '.[] | select(.owner.login == "${prForkOwner}") | .name'`;
1224
+ // Issue #2119: the double quotes here are jq syntax, so the expression is
1225
+ // built in JS and interpolated as one already-escaped argument.
1226
+ const forkNameFilter = `.[] | select(.owner.login == ${JSON.stringify(prForkOwner)}) | .name`;
1227
+ const forksResult = await $`gh api repos/${owner}/${repo}/forks --paginate --jq ${forkNameFilter}`;
1211
1228
  if (forksResult.code === 0 && forksResult.stdout) {
1212
1229
  const forkName = forksResult.stdout.toString().trim().split('\n')[0]; // Take first match
1213
1230
  if (forkName) {
@@ -32,6 +32,7 @@ const fs = (await use('fs')).promises;
32
32
  const lib = await import('./lib.mjs');
33
33
  const { log, formatAligned, extractToolErrorCore } = lib;
34
34
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
35
+ const { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } = await import('./ai-tool-scratch.lib.mjs');
35
36
  const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
36
37
  // Issue #2123: shared draft/ready transitions for working sessions.
37
38
  const { ensurePullRequestIsDraft } = await import('./pr-draft-state.lib.mjs');
@@ -128,11 +129,14 @@ export const cleanupPlaywrightMcpFolder = async (tempDir, argv = {}) => {
128
129
  export const checkForUncommittedChanges = async (tempDir, argv = {}) => {
129
130
  // First, clean up .playwright-mcp/ folder to prevent false positives (Issue #1124)
130
131
  await cleanupPlaywrightMcpFolder(tempDir, argv);
132
+ // Issue #2119: the same false positive, generalized - `.formal-ai/` and any
133
+ // other AI tool scratch directory must not read as the AI's uncommitted work.
134
+ await ensureAiToolScratchIgnored(tempDir, log);
131
135
 
132
136
  try {
133
137
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
134
138
  if (gitStatusResult.code === 0) {
135
- const statusOutput = gitStatusResult.stdout.toString().trim();
139
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
136
140
  return statusOutput.length > 0;
137
141
  }
138
142
  } catch (error) {
@@ -156,7 +160,7 @@ export const getUncommittedChangesDetails = async tempDir => {
156
160
  try {
157
161
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
158
162
  if (gitStatusResult.code === 0) {
159
- const statusOutput = gitStatusResult.stdout.toString().trim();
163
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
160
164
  if (statusOutput) {
161
165
  changes.push(...statusOutput.split('\n'));
162
166
  }