@link-assistant/hive-mind 2.15.0 → 2.15.1

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.
@@ -504,6 +504,19 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
504
504
  // then no CI is required and we should not block indefinitely.
505
505
  // Otherwise (e.g. mergeStateStatus === 'BLOCKED'), treat as pending race condition.
506
506
  const earlyMergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
507
+ // Issue #2182: a draft pull request reports mergeable=false now, which would
508
+ // otherwise fall into the "checks have not started yet" race-condition branch
509
+ // below and hide the real reason behind a ci_pending blocker forever. The
510
+ // `no_checks` branch owns several early returns, so the draft blocker has to
511
+ // be emitted here to reach the caller through every one of them.
512
+ if (earlyMergeStatus.isDraft) {
513
+ blockers.push({
514
+ type: 'draft',
515
+ message: earlyMergeStatus.reason || 'PR is a draft',
516
+ details: [],
517
+ });
518
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false, noWorkflowRunsForCommit };
519
+ }
507
520
  if (earlyMergeStatus.mergeable) {
508
521
  // Issue #1363: Before concluding "no CI configured", verify the repo actually
509
522
  // has no active GitHub Actions workflows. If workflows exist but no checks have
@@ -972,8 +985,14 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
972
985
  }
973
986
 
974
987
  if (!mergeStatus.mergeable) {
988
+ // Issue #2182: a draft pull request gets its own blocker type. GitHub keeps
989
+ // reporting mergeable=MERGEABLE/CLEAN for drafts, so before this the loop
990
+ // saw no blocker at all, declared "PR IS MERGEABLE!" and then failed the
991
+ // actual merge with "Pull Request is still a draft" on every check for
992
+ // 4d 12h. The dedicated type also lets the caller self-heal (mark ready)
993
+ // instead of burning an AI restart iteration on it.
975
994
  blockers.push({
976
- type: 'not_mergeable',
995
+ type: mergeStatus.isDraft ? 'draft' : 'not_mergeable',
977
996
  message: mergeStatus.reason || 'PR is not mergeable',
978
997
  details: [],
979
998
  });
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+
4
+ /**
5
+ * Pre-flight checks that run before the --auto-merge / --auto-restart-until-mergeable
6
+ * watch loop is entered: mode detection, base-branch guard, fork detection and
7
+ * merge-permission verification.
8
+ *
9
+ * Extracted from solve.auto-merge.lib.mjs (issue #1593) to keep that file under the
10
+ * 1350-line advisory threshold while the issue #2182 guard rails were added. Same
11
+ * pattern as solve.auto-merge-attempt.lib.mjs (issue #2144).
12
+ *
13
+ * @see https://github.com/link-assistant/hive-mind/issues/1593
14
+ */
15
+
16
+ if (typeof globalThis.use === 'undefined') {
17
+ await ensureUseM();
18
+ }
19
+ const use = globalThis.use;
20
+
21
+ const { $: __rawDollar$ } = await use('command-stream');
22
+ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
23
+ const $ = wrapDollarWithGhRetry(__rawDollar$);
24
+
25
+ const lib = await import('./lib.mjs');
26
+ const { log, formatAligned } = lib;
27
+
28
+ const { checkMergePermissions } = await import('./github-merge.lib.mjs');
29
+ const { checkForExistingComment } = await import('./solve.auto-merge-helpers.lib.mjs');
30
+ const { READY_TO_MERGE_MARKER, postTrackedComment } = await import('./tool-comments.lib.mjs');
31
+ const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
32
+
33
+ /**
34
+ * Issue #1323: notify the maintainer on the pull request that auto-merge was
35
+ * requested but cannot be performed, without posting the same comment twice.
36
+ */
37
+ const postManualMergeNotice = async ({ owner, repo, prNumber, reason, verbose, footer }) => {
38
+ try {
39
+ const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
40
+ if (await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, verbose)) {
41
+ await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
42
+ return;
43
+ }
44
+ const commentBody = `${readyToMergeSignature}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because ${reason}\n\nPlease merge manually.\n\n---\n*${footer}*`;
45
+ // Issue #1625: Track so this doesn't falsely count as AI-authored.
46
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
47
+ await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
48
+ } catch {
49
+ // Don't fail if comment posting fails
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Run every check that can stop auto-merge before the watch loop starts.
55
+ *
56
+ * @param {Object} params - the same params object `startAutoRestartUntilMergeable` receives
57
+ * @returns {Promise<{stop: boolean, result?: (Object|null)}>} `stop: true` means the
58
+ * caller must return `result` immediately instead of entering the watch loop.
59
+ */
60
+ export const runAutoMergePreflight = async params => {
61
+ const { argv, owner, repo, prNumber } = params;
62
+ const isAutoMerge = argv.autoMerge || false;
63
+ const isAutoRestartUntilMergeable = argv.autoRestartUntilMergeable || false;
64
+
65
+ if (!isAutoMerge && !isAutoRestartUntilMergeable) {
66
+ return { stop: true, result: null }; // Neither mode enabled
67
+ }
68
+
69
+ if (!prNumber) {
70
+ await log('');
71
+ await log(formatAligned('⚠️', 'Auto-restart-until-mergeable:', 'Requires a pull request'));
72
+ await log(formatAligned('', 'Note:', 'This mode only works with existing PRs', 2));
73
+ return { stop: true, result: null };
74
+ }
75
+
76
+ await ensurePullRequestBaseBranch({
77
+ owner,
78
+ repo,
79
+ prNumber,
80
+ argv,
81
+ log,
82
+ formatAligned,
83
+ $,
84
+ onMismatch: isAutoMerge ? 'throw' : 'restore',
85
+ operation: isAutoMerge ? 'auto-merge' : 'auto-restart-until-mergeable',
86
+ });
87
+
88
+ // Issue #1226: Check if running in fork mode — auto-merge cannot work without write access
89
+ if (argv.fork && isAutoMerge) {
90
+ await log('');
91
+ await log(formatAligned('⚠️', 'Auto-merge:', 'Cannot auto-merge fork PRs'));
92
+ await log(formatAligned('', 'Reason:', 'Fork contributors do not have write access to merge PRs to upstream repositories', 2));
93
+ await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
94
+ await log('');
95
+ await postManualMergeNotice({ owner, repo, prNumber, verbose: argv.verbose, reason: 'this PR was created from a fork (no write access to the target repository).', footer: 'hive-mind with --auto-merge flag (fork mode)' });
96
+ return { stop: true, result: { success: false, reason: 'fork_no_write_access' } };
97
+ }
98
+
99
+ // Issue #1226: Verify merge permissions before entering the auto-merge/restart loop
100
+ if (isAutoMerge && owner && repo) {
101
+ const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
102
+ if (!canMerge) {
103
+ await log('');
104
+ await log(formatAligned('⚠️', 'Auto-merge:', 'Insufficient permissions to merge'));
105
+ await log(formatAligned('', 'Permission level:', permission || 'unknown', 2));
106
+ await log(formatAligned('', 'Required:', 'push, maintain, or admin access', 2));
107
+ await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
108
+ await log('');
109
+ await postManualMergeNotice({ owner, repo, prNumber, verbose: argv.verbose, reason: `the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).`, footer: 'hive-mind with --auto-merge flag' });
110
+ return { stop: true, result: { success: false, reason: 'insufficient_permissions' } };
111
+ }
112
+ }
113
+
114
+ // --auto-merge implies --auto-restart-until-mergeable
115
+ if (isAutoMerge) {
116
+ argv.autoRestartUntilMergeable = true;
117
+ }
118
+
119
+ return { stop: false };
120
+ };
121
+
122
+ export default { runAutoMergePreflight };
@@ -30,7 +30,13 @@ const sentryLib = await import('./sentry.lib.mjs');
30
30
  const { reportError } = sentryLib;
31
31
  // Import GitHub merge functions
32
32
  const githubMergeLib = await import('./github-merge.lib.mjs');
33
- const { checkMergePermissions, mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
33
+ const { mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
34
+ // Issue #2182: guard rails for this loop (wall-clock ceiling, draft self-heal,
35
+ // classified merge failures). See solve.auto-merge-guards.lib.mjs.
36
+ const autoMergeGuards = await import('./solve.auto-merge-guards.lib.mjs');
37
+ const { DRAFT_RECHECK_DELAY_MS, evaluateWatchTimeout, resolveDraftBlocker, resolveMergeFailure } = autoMergeGuards;
38
+ // Re-exported so callers and tests keep a single entry point for the watch loop.
39
+ export const { DEFAULT_WATCH_TIMEOUT_HOURS, normalizeWatchTimeoutHours } = autoMergeGuards;
34
40
  // Import GitHub functions for log attachment
35
41
  const githubLib = await import('./github.lib.mjs');
36
42
  const { sanitizeLogContent, attachLogToGitHub } = githubLib;
@@ -88,7 +94,6 @@ const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIte
88
94
  const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
89
95
  const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, hasExhaustedAutoRestartBudget } = autoRestartBudget;
90
96
  const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
91
- const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
92
97
  // Issue #2119: an empty pull request must not be reported as ready to merge.
93
98
  const { buildEmptyPullRequestBlocker, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
94
99
  // Issue #1895: explicitly close linked issues after merging a PR into a
@@ -128,6 +133,16 @@ export const watchUntilMergeable = async params => {
128
133
  let lastKnownHeadSha = null;
129
134
  // Issue #1567: Initial cooldown to let CI register and solution logs post
130
135
  const INITIAL_COOLDOWN_SECONDS = MIN_CI_CHECK_INTERVAL_SECONDS;
136
+ // Issue #2182: this loop used to be `while (true)` with no wall-clock ceiling
137
+ // at all, so a permanently failing merge kept a single task "processing" for
138
+ // 4d 12h 13m. The timeout is the last-resort backstop below the specific
139
+ // fixes (draft detection, merge-failure classification).
140
+ const watchTimeoutHours = normalizeWatchTimeoutHours(argv.autoRestartUntilMergeableTimeoutHours ?? argv['auto-restart-until-mergeable-timeout-hours']);
141
+ const watchStartedAt = Date.now();
142
+ // Issue #2182: consecutive failed `gh pr merge` attempts, and how many times
143
+ // we silently restored "ready for review" on a pull request left as a draft.
144
+ // Mutable on purpose: the guard helpers update it in place.
145
+ const guardState = { consecutiveMergeFailures: 0, draftSelfHealCount: 0 };
131
146
 
132
147
  await log('');
133
148
  await log(formatAligned('🔄', 'AUTO-RESTART-UNTIL-MERGEABLE MODE ACTIVE', ''));
@@ -137,6 +152,7 @@ export const watchUntilMergeable = async params => {
137
152
  await log(formatAligned('', 'Initial cooldown:', `${INITIAL_COOLDOWN_SECONDS} seconds`, 2));
138
153
  await log(formatAligned('', 'Max restart iterations:', formatAutoRestartLimit(), 2));
139
154
  await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
155
+ await log(formatAligned('', 'Watch timeout:', watchTimeoutHours > 0 ? `${watchTimeoutHours} hour(s)` : 'unlimited', 2));
140
156
  await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
141
157
  await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
142
158
  await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, issue title/description edits, CI failures, merge conflicts', 2));
@@ -163,6 +179,15 @@ export const watchUntilMergeable = async params => {
163
179
  while (true) {
164
180
  iteration++;
165
181
  const currentTime = new Date();
182
+ // Issue #2182: hard wall-clock ceiling for the whole monitoring loop.
183
+ const watchTimeout = evaluateWatchTimeout({ watchTimeoutHours, watchStartedAt, now: Date.now(), checksCompleted: iteration - 1 });
184
+ if (watchTimeout) {
185
+ await log('');
186
+ await log(formatAligned('⏱️', 'WATCH TIMEOUT REACHED:', watchTimeout.message, 2), { level: 'error' });
187
+ await log('');
188
+ await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'watch_timeout', mode: 'auto-restart-until-mergeable', message: watchTimeout.message, details: watchTimeout.details, verbose: argv.verbose, log });
189
+ return { success: false, reason: 'watch_timeout', latestSessionId, latestAnthropicCost };
190
+ }
166
191
  const terminalState = await checkGitHubTerminalState({
167
192
  owner,
168
193
  repo,
@@ -217,6 +242,8 @@ export const watchUntilMergeable = async params => {
217
242
  }
218
243
  lastKnownHeadSha = currentHeadSha;
219
244
  consecutiveNoRunsChecks = 0;
245
+ // Issue #2182: a new commit is a genuinely new merge attempt.
246
+ guardState.consecutiveMergeFailures = 0;
220
247
  // Issue #1503: Also reset the readyToMergeCommentPosted flag when SHA changes,
221
248
  // so a new "Ready to merge" comment can be posted for the new commit's CI results.
222
249
  readyToMergeCommentPosted = false;
@@ -237,6 +264,23 @@ export const watchUntilMergeable = async params => {
237
264
  await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'terminal_github_entity_error', mode: 'auto-restart-until-mergeable', message: terminalGitHubBlocker.message, details: terminalGitHubBlocker.details, verbose: argv.verbose, log });
238
265
  return { success: false, reason: 'terminal_github_entity_error', latestSessionId, latestAnthropicCost };
239
266
  }
267
+ // Issue #2182: a pull request that is still a draft while no AI session is
268
+ // running is a leftover from a restart iteration (executeToolIteration
269
+ // drafts the PR on entry). GitHub answers mergeable=MERGEABLE/CLEAN for it,
270
+ // so nothing else in this loop notices — the merge then fails with
271
+ // "Pull Request is still a draft" on every single check. Restore
272
+ // "ready for review" here instead of burning an AI restart iteration.
273
+ if (blockers.find(b => b.type === 'draft')) {
274
+ const decision = await resolveDraftBlocker({ owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose: argv.verbose, state: guardState });
275
+ if (decision.action === 'stop') {
276
+ return { success: false, reason: decision.reason, latestSessionId, latestAnthropicCost };
277
+ }
278
+ if (decision.action === 'retry') {
279
+ lastCheckTime = currentTime;
280
+ await interruptibleSleep(DRAFT_RECHECK_DELAY_MS);
281
+ continue;
282
+ }
283
+ }
240
284
  // Issue #1503/#1918: Reset counter when CI checks exist (safety valve only for
241
285
  // consecutive "no runs"). Issue #1918: do NOT reset while getMergeBlockers is still
242
286
  // waiting for PR-triggered workflow runs to register (noWorkflowRunsForCommit). A
@@ -384,7 +428,21 @@ export const watchUntilMergeable = async params => {
384
428
  }
385
429
  return { success: true, reason: 'auto-merged', latestSessionId, latestAnthropicCost };
386
430
  } else {
387
- await log(formatAligned('⚠️', 'Auto-merge failed:', mergeResult.error || 'Unknown error', 2));
431
+ // Issue #2182: an unclassified merge failure used to be logged as
432
+ // "Will continue monitoring..." and retried every 120 seconds
433
+ // forever (5384 identical failures in the reported run). Classify
434
+ // it: self-heal what we can, stop on terminal causes, and cap the
435
+ // number of consecutive failures for everything else.
436
+ const decision = await resolveMergeFailure({ error: mergeResult.error, owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose: argv.verbose, state: guardState });
437
+ if (decision.action === 'stop') {
438
+ return { success: false, reason: decision.reason, error: mergeResult.error, latestSessionId, latestAnthropicCost };
439
+ }
440
+ if (decision.action === 'retry') {
441
+ lastCheckTime = currentTime;
442
+ await interruptibleSleep(DRAFT_RECHECK_DELAY_MS);
443
+ continue;
444
+ }
445
+
388
446
  await log(formatAligned('', 'Will continue monitoring...', '', 2));
389
447
  }
390
448
  } else {
@@ -1250,89 +1308,17 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
1250
1308
  const autoMergeAttempt = await import('./solve.auto-merge-attempt.lib.mjs');
1251
1309
  export const { attemptAutoMerge, reportAutoMergeBlockedByIssue } = autoMergeAttempt;
1252
1310
  /**
1253
- * Start auto-restart-until-mergeable mode
1311
+ * Start auto-restart-until-mergeable mode.
1312
+ *
1313
+ * The pre-flight checks (mode detection, base-branch guard, fork detection,
1314
+ * merge permissions) live in solve.auto-merge-preflight.lib.mjs so this file
1315
+ * stays under the 1350-line advisory threshold (issue #1593).
1254
1316
  */
1317
+ const { runAutoMergePreflight } = await import('./solve.auto-merge-preflight.lib.mjs');
1255
1318
  export const startAutoRestartUntilMergeable = async params => {
1256
- const { argv, owner, repo, prNumber } = params;
1257
- // Determine the mode
1258
- const isAutoMerge = argv.autoMerge || false;
1259
- const isAutoRestartUntilMergeable = argv.autoRestartUntilMergeable || false;
1260
- if (!isAutoMerge && !isAutoRestartUntilMergeable) {
1261
- return null; // Neither mode enabled
1262
- }
1263
- if (!prNumber) {
1264
- await log('');
1265
- await log(formatAligned('⚠️', 'Auto-restart-until-mergeable:', 'Requires a pull request'));
1266
- await log(formatAligned('', 'Note:', 'This mode only works with existing PRs', 2));
1267
- return null;
1268
- }
1269
- await ensurePullRequestBaseBranch({
1270
- owner,
1271
- repo,
1272
- prNumber,
1273
- argv,
1274
- log,
1275
- formatAligned,
1276
- $,
1277
- onMismatch: isAutoMerge ? 'throw' : 'restore',
1278
- operation: isAutoMerge ? 'auto-merge' : 'auto-restart-until-mergeable',
1279
- });
1280
- // Issue #1226: Check if running in fork mode — auto-merge cannot work without write access
1281
- if (argv.fork && isAutoMerge) {
1282
- await log('');
1283
- await log(formatAligned('⚠️', 'Auto-merge:', 'Cannot auto-merge fork PRs'));
1284
- await log(formatAligned('', 'Reason:', 'Fork contributors do not have write access to merge PRs to upstream repositories', 2));
1285
- await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
1286
- await log('');
1287
-
1288
- // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
1289
- try {
1290
- const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
1291
- const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
1292
- if (!hasExistingComment) {
1293
- const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because this PR was created from a fork (no write access to the target repository).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag (fork mode)*`;
1294
- // Issue #1625: Track so this doesn't falsely count as AI-authored.
1295
- await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
1296
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
1297
- } else {
1298
- await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
1299
- }
1300
- } catch {
1301
- // Don't fail if comment posting fails
1302
- }
1303
- return { success: false, reason: 'fork_no_write_access' };
1304
- }
1305
- // Issue #1226: Verify merge permissions before entering the auto-merge/restart loop
1306
- if (isAutoMerge && owner && repo) {
1307
- const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
1308
- if (!canMerge) {
1309
- await log('');
1310
- await log(formatAligned('⚠️', 'Auto-merge:', 'Insufficient permissions to merge'));
1311
- await log(formatAligned('', 'Permission level:', permission || 'unknown', 2));
1312
- await log(formatAligned('', 'Required:', 'push, maintain, or admin access', 2));
1313
- await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
1314
- await log('');
1315
- // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
1316
- try {
1317
- const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
1318
- const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
1319
- if (!hasExistingComment) {
1320
- const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag*`;
1321
- // Issue #1625: Track so this doesn't falsely count as AI-authored.
1322
- await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
1323
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
1324
- } else {
1325
- await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
1326
- }
1327
- } catch {
1328
- // Don't fail if comment posting fails
1329
- }
1330
- return { success: false, reason: 'insufficient_permissions' };
1331
- }
1332
- }
1333
- // If --auto-merge implies --auto-restart-until-mergeable
1334
- if (isAutoMerge) {
1335
- argv.autoRestartUntilMergeable = true;
1319
+ const preflight = await runAutoMergePreflight(params);
1320
+ if (preflight.stop) {
1321
+ return preflight.result ?? null;
1336
1322
  }
1337
1323
  // Start the watch loop
1338
1324
  return await watchUntilMergeable(params);
@@ -230,6 +230,14 @@ export const SOLVE_OPTION_DEFINITIONS = {
230
230
  description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
231
231
  default: 5,
232
232
  },
233
+ // Issue #2182: the auto-restart-until-mergeable watch loop had no wall-clock
234
+ // ceiling, so a pull request that could never merge kept a task "processing"
235
+ // for 4d 12h 13m.
236
+ 'auto-restart-until-mergeable-timeout-hours': {
237
+ type: 'number',
238
+ description: 'Maximum wall-clock hours the --auto-restart-until-mergeable/--auto-merge monitoring loop may run before stopping and reporting (default: 24, 0 = unlimited)',
239
+ default: 24,
240
+ },
233
241
  'resume-on-auto-restart': {
234
242
  type: 'boolean',
235
243
  description: '[EXPERIMENTAL] Resume the previous Claude session on uncommitted-change auto-restart and send only a minimal restart prompt. Disabled by default.',
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Interrupt wrapper factory for CTRL+C handling in solve sessions.
3
3
  *
4
- * On SIGINT, auto-commits uncommitted changes and uploads session logs if --attach-logs is enabled.
4
+ * On SIGINT, auto-commits uncommitted changes, restores the pull request draft state and
5
+ * uploads session logs if --attach-logs is enabled.
5
6
  */
6
7
 
7
8
  /**
@@ -59,6 +60,22 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
59
60
  }
60
61
  }
61
62
 
63
+ // Issue #2182: CTRL+C ends the working session, so the pull request must go back to
64
+ // "ready for review" exactly like a normal session end. This runs before the log upload
65
+ // on purpose: it is two fast gh calls, while attaching a multi-MB log can be cut off by
66
+ // the isolation backend's SIGKILL (#2052). A pull request left in draft can never be
67
+ // merged by --auto-merge, so restoring it is the more important of the two.
68
+ try {
69
+ const { restorePullRequestsLeftInDraft } = await import('./pr-draft-state.lib.mjs');
70
+ await trace('draft-restore: start');
71
+ await restorePullRequestsLeftInDraft({ $, log, reason: 'session interrupted (CTRL+C)' });
72
+ await trace('draft-restore: done');
73
+ } catch (restoreError) {
74
+ await log(`⚠️ Could not restore pull request draft state on interrupt: ${restoreError.message}`, {
75
+ level: 'warning',
76
+ });
77
+ }
78
+
62
79
  // Upload logs if --attach-logs is enabled and we have a PR
63
80
  if (shouldAttachLogs && ctx.prNumber && ctx.owner && ctx.repo) {
64
81
  await log('📎 Uploading interrupted session logs to Pull Request...');
package/src/solve.mjs CHANGED
@@ -1185,6 +1185,17 @@ try {
1185
1185
  logsAttached = true;
1186
1186
  }
1187
1187
  }
1188
+ // Issue #2182: the AI working session is over at this point — everything below is
1189
+ // monitoring and merging, not working. The pull request must therefore be back in
1190
+ // "ready for review" BEFORE the auto-merge watch loop starts, because that loop can
1191
+ // run for days and endWorkSession() (further down) is unreachable until it returns.
1192
+ // In the reported run the PR was left in draft by an auto-restart iteration and
1193
+ // `gh pr merge` answered "Pull Request is still a draft" 2692 times over 4d 12h.
1194
+ if (prNumber) {
1195
+ const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
1196
+ await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'AI working session finished', reportError });
1197
+ }
1198
+
1188
1199
  // Start auto-restart-until-mergeable mode if enabled This runs after the normal watch mode completes (if any) --auto-merge implies --auto-restart-until-mergeable
1189
1200
  if (argv.autoMerge || argv.autoRestartUntilMergeable) {
1190
1201
  const autoMergeResult = await startAutoRestartUntilMergeable({
@@ -1220,6 +1231,14 @@ try {
1220
1231
  await endWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, logsAttached });
1221
1232
  } catch (error) {
1222
1233
  await finalizeDevelopmentLog(); // Preserve failed/interrupted sessions too.
1234
+ // Issue #2182: a failed session is still a finished session. Restore every pull request
1235
+ // this process put into draft, otherwise the failure leaves it permanently unmergeable.
1236
+ try {
1237
+ const { restorePullRequestsLeftInDraft } = await import('./pr-draft-state.lib.mjs');
1238
+ await restorePullRequestsLeftInDraft({ $, log, formatAligned, reason: 'working session failed', reportError });
1239
+ } catch (restoreError) {
1240
+ await log(`Warning: Could not restore pull request draft state: ${restoreError.message}`, { level: 'warning' });
1241
+ }
1223
1242
  // Don't report authentication errors to Sentry as they are user configuration issues
1224
1243
  if (!error.isAuthError) {
1225
1244
  reportError(error, {