@link-assistant/hive-mind 2.14.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.
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Classification helpers for pull request mergeability and merge failures.
5
+ *
6
+ * Issue #2182: `/solve ... --auto-merge --auto-restart-until-mergeable` kept a
7
+ * single task "processing" for 4d 12h 13m. The pull request had been converted
8
+ * to draft by an auto-restart iteration and never converted back, so:
9
+ *
10
+ * 1. `checkPRMergeable()` asked GitHub only for `mergeable,mergeStateStatus`.
11
+ * A draft pull request with no other blockers answers
12
+ * `MERGEABLE` / `CLEAN` — the `case 'DRAFT'` branch that was supposed to
13
+ * catch this was dead code, because it is only reachable when
14
+ * `mergeable !== 'MERGEABLE'`. The watch loop therefore declared
15
+ * "PR IS MERGEABLE!" on every check.
16
+ * 2. `gh pr merge` then failed with
17
+ * `GraphQL: Pull Request is still a draft (mergePullRequest)`.
18
+ * 3. The failure was logged as "Will continue monitoring..." and retried
19
+ * every 120 seconds, forever (5384 identical failures in the reported run).
20
+ *
21
+ * These two pure functions are the single place where "is this pull request
22
+ * actually mergeable?" and "is this merge failure worth retrying?" are decided,
23
+ * so every merge call site can share the same answer.
24
+ *
25
+ * @see https://github.com/link-assistant/hive-mind/issues/2182
26
+ * @see docs/case-studies/issue-2182/README.md for the full timeline and evidence
27
+ */
28
+
29
+ /**
30
+ * Merge failure categories.
31
+ *
32
+ * `recoverable` means hive-mind itself can fix the cause and retry (currently
33
+ * only the draft state). `terminal` means retrying the exact same merge cannot
34
+ * succeed without a human or a new AI session, so a watch loop must stop
35
+ * instead of hammering the API.
36
+ */
37
+ export const MERGE_ERROR_CATEGORIES = {
38
+ DRAFT: 'draft',
39
+ CONFLICT: 'conflict',
40
+ BLOCKED: 'blocked',
41
+ CLOSED: 'closed',
42
+ PERMISSION: 'permission',
43
+ NOT_MERGEABLE: 'not_mergeable',
44
+ UNKNOWN: 'unknown',
45
+ };
46
+
47
+ /**
48
+ * How many consecutive failed merge attempts a watch loop may make before it
49
+ * gives up and reports the stop. Issue #2182: there was no such ceiling.
50
+ */
51
+ export const MAX_CONSECUTIVE_MERGE_FAILURES = 3;
52
+
53
+ const MERGE_ERROR_PATTERNS = [
54
+ // "GraphQL: Pull Request is still a draft (mergePullRequest)"
55
+ { category: MERGE_ERROR_CATEGORIES.DRAFT, terminal: false, recoverable: true, pattern: /still a draft|is a draft|draft state|convert(ed)? to draft/i, resolution: 'Mark the pull request as ready for review (gh pr ready <number>) before merging.' },
56
+ { category: MERGE_ERROR_CATEGORIES.CLOSED, terminal: true, recoverable: false, pattern: /pull request is closed|already merged|has already been merged|not open/i, resolution: 'The pull request is no longer open — nothing left to merge.' },
57
+ { category: MERGE_ERROR_CATEGORIES.PERMISSION, terminal: true, recoverable: false, pattern: /resource not accessible|must have (admin|write|push)|permission|403|not authorized|forbidden/i, resolution: 'Grant the token merge permission on the repository, or merge manually.' },
58
+ { category: MERGE_ERROR_CATEGORIES.BLOCKED, terminal: true, recoverable: false, pattern: /required status check|approving review|review is required|protected branch|branch protection|changes must be made through a pull request|merge queue/i, resolution: 'Satisfy the branch protection requirements (reviews / required checks) or merge manually.' },
59
+ { category: MERGE_ERROR_CATEGORIES.CONFLICT, terminal: false, recoverable: false, pattern: /merge conflict|not mergeable due to conflicts|conflicts? with the base branch/i, resolution: 'Resolve the merge conflicts with the base branch, then retry.' },
60
+ { category: MERGE_ERROR_CATEGORIES.NOT_MERGEABLE, terminal: false, recoverable: false, pattern: /pull request is not mergeable|is not mergeable|base branch was modified/i, resolution: 'Wait for GitHub to recompute mergeability, or update the branch from the base branch.' },
61
+ ];
62
+
63
+ /**
64
+ * Classify a `gh pr merge` failure message.
65
+ *
66
+ * @param {string|null|undefined} errorMessage raw stderr/message from `gh pr merge`
67
+ * @returns {{category: string, terminal: boolean, recoverable: boolean, resolution: string|null}}
68
+ */
69
+ export const classifyMergeError = errorMessage => {
70
+ const text = typeof errorMessage === 'string' ? errorMessage : '';
71
+ for (const entry of MERGE_ERROR_PATTERNS) {
72
+ if (entry.pattern.test(text)) {
73
+ return { category: entry.category, terminal: entry.terminal, recoverable: entry.recoverable, resolution: entry.resolution };
74
+ }
75
+ }
76
+ return { category: MERGE_ERROR_CATEGORIES.UNKNOWN, terminal: false, recoverable: false, resolution: null };
77
+ };
78
+
79
+ /**
80
+ * Decide whether a pull request payload describes a mergeable pull request.
81
+ *
82
+ * Expects the parsed output of
83
+ * `gh pr view <n> --json isDraft,mergeable,mergeStateStatus`.
84
+ *
85
+ * Issue #2182: `isDraft` is checked FIRST and independently of
86
+ * `mergeStateStatus`, because GitHub reports `CLEAN`/`MERGEABLE` for a draft
87
+ * pull request that has no other blockers, while `gh pr merge` still refuses it.
88
+ *
89
+ * @param {{isDraft?: boolean, mergeable?: string|null, mergeStateStatus?: string|null}} pr
90
+ * @returns {{mergeable: boolean, isDraft: boolean, mergeableState: string|null, mergeStateStatus: string|null, reason: string|null}}
91
+ */
92
+ export const evaluatePullRequestMergeability = (pr = {}) => {
93
+ const isDraft = pr.isDraft === true;
94
+ const mergeableState = pr.mergeable ?? null;
95
+ const mergeStateStatus = pr.mergeStateStatus ?? null;
96
+
97
+ if (isDraft) {
98
+ return { mergeable: false, isDraft: true, mergeableState, mergeStateStatus, reason: 'PR is a draft' };
99
+ }
100
+
101
+ if (mergeableState === 'MERGEABLE') {
102
+ return { mergeable: true, isDraft: false, mergeableState, mergeStateStatus, reason: null };
103
+ }
104
+
105
+ let reason;
106
+ switch (mergeStateStatus) {
107
+ case 'BLOCKED':
108
+ reason = 'PR is blocked (possibly by branch protection rules)';
109
+ break;
110
+ case 'BEHIND':
111
+ reason = 'PR branch is behind the base branch';
112
+ break;
113
+ case 'DIRTY':
114
+ reason = 'PR has merge conflicts';
115
+ break;
116
+ case 'UNSTABLE':
117
+ reason = 'PR has failing required status checks';
118
+ break;
119
+ case 'DRAFT':
120
+ reason = 'PR is a draft';
121
+ break;
122
+ default:
123
+ reason = `Merge state: ${mergeStateStatus || 'unknown'}`;
124
+ }
125
+
126
+ return { mergeable: false, isDraft: false, mergeableState, mergeStateStatus, reason };
127
+ };
128
+
129
+ export default {
130
+ MERGE_ERROR_CATEGORIES,
131
+ MAX_CONSECUTIVE_MERGE_FAILURES,
132
+ classifyMergeError,
133
+ evaluatePullRequestMergeability,
134
+ };
@@ -227,6 +227,7 @@ const KNOWN_OPTION_NAMES = [
227
227
  'allow-to-push-to-contributors-pull-requests-as-maintainer',
228
228
  'prefix-fork-name-with-owner-name',
229
229
  'auto-restart-max-iterations',
230
+ 'auto-restart-until-mergeable-timeout-hours',
230
231
  'auto-resume-max-iterations',
231
232
  'auto-continue-only-on-new-comments',
232
233
  'auto-restart-on-limit-reset',
@@ -11,7 +11,17 @@
11
11
  * keep-working, auto-ensure, PR-placeholder restart) kept the PR marked as
12
12
  * "ready for review" while the AI was actively working on it.
13
13
  *
14
+ * Issue #2182: the draft transition was code-driven and unconditional while the
15
+ * matching ready transition was delegated to the AI tool (the prompt asks it to run
16
+ * `gh pr ready <n>`) and to `endWorkSession()`, which only runs in continue mode and
17
+ * only after the auto-merge watch loop returns. When the AI simply did not run the
18
+ * command, the pull request stayed a draft forever. This module therefore *tracks*
19
+ * every draft it hands out, so the matching ready transition can be guaranteed by
20
+ * code — including on the interrupt and fatal-error exit paths.
21
+ *
14
22
  * @see https://github.com/link-assistant/hive-mind/issues/2123
23
+ * @see https://github.com/link-assistant/hive-mind/issues/2182
24
+ * @see docs/case-studies/issue-2182/README.md for the full timeline and evidence
15
25
  */
16
26
 
17
27
  // rate-limit marker (#1726): callers pass in a `$` already wrapped by wrapDollarWithGhRetry.
@@ -19,6 +29,75 @@ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-l
19
29
 
20
30
  const noopLog = async () => {};
21
31
 
32
+ /**
33
+ * Issue #2182: pull requests this process put into draft for a working session that
34
+ * has not been converted back to "ready for review" yet.
35
+ *
36
+ * Keyed by `owner/repo#number`, module-level on purpose: like working-session.lib.mjs
37
+ * this is a per-process singleton, and the safety nets that drain it (interrupt
38
+ * handler, fatal-error handler) have no access to the call site that drafted the PR.
39
+ *
40
+ * @type {Map<string, {owner: string, repo: string, prNumber: (number|string), reason: (string|null), since: string}>}
41
+ */
42
+ const outstandingWorkingSessionDrafts = new Map();
43
+
44
+ const draftKey = (owner, repo, prNumber) => `${owner}/${repo}#${prNumber}`;
45
+
46
+ /**
47
+ * Record that a working session of this process is holding `prNumber` in draft.
48
+ * Also called when the pull request already was a draft: what matters for the
49
+ * invariant is that a session is now responsible for converting it back.
50
+ */
51
+ const trackWorkingSessionDraft = ({ owner, repo, prNumber, reason }) => {
52
+ outstandingWorkingSessionDrafts.set(draftKey(owner, repo, prNumber), { owner, repo, prNumber, reason: reason || null, since: new Date().toISOString() });
53
+ };
54
+
55
+ /** Record that `prNumber` is no longer held in draft by this process. */
56
+ const untrackWorkingSessionDraft = ({ owner, repo, prNumber }) => {
57
+ outstandingWorkingSessionDrafts.delete(draftKey(owner, repo, prNumber));
58
+ };
59
+
60
+ /**
61
+ * Pull requests currently held in draft by this process on behalf of a working session.
62
+ * @returns {Array<{owner: string, repo: string, prNumber: (number|string), reason: (string|null), since: string}>}
63
+ */
64
+ export const getOutstandingWorkingSessionDrafts = () => Array.from(outstandingWorkingSessionDrafts.values());
65
+
66
+ /** Forget every tracked draft (used by tests and by a clean process restart). */
67
+ export const resetWorkingSessionDrafts = () => {
68
+ outstandingWorkingSessionDrafts.clear();
69
+ };
70
+
71
+ /**
72
+ * Issue #2182 safety net: convert back to "ready for review" every pull request this
73
+ * process left in draft for a working session that is now over.
74
+ *
75
+ * Called from the interrupt handler and the fatal-error handler, so an aborted session
76
+ * cannot leave a pull request permanently unmergeable. A no-op when nothing is
77
+ * outstanding, so it is safe to call on every exit path.
78
+ *
79
+ * @param {Object} options
80
+ * @param {Function} options.$ - command-stream style tagged template executor
81
+ * @param {Function} [options.log]
82
+ * @param {Function} [options.formatAligned]
83
+ * @param {string} [options.reason]
84
+ * @param {Function} [options.reportError]
85
+ * @returns {Promise<Array<Object>>} one result per restored pull request
86
+ */
87
+ export const restorePullRequestsLeftInDraft = async ({ $, log = noopLog, formatAligned = null, reason = 'working session ended', reportError = null } = {}) => {
88
+ const pending = getOutstandingWorkingSessionDrafts();
89
+ if (pending.length === 0) {
90
+ return [];
91
+ }
92
+
93
+ await log(`🩹 Restoring ${pending.length} pull request(s) left in draft by this working session...`);
94
+ const results = [];
95
+ for (const entry of pending) {
96
+ results.push(await ensurePullRequestIsReady({ owner: entry.owner, repo: entry.repo, prNumber: entry.prNumber, $, log, formatAligned, reason, reportError }));
97
+ }
98
+ return results;
99
+ };
100
+
22
101
  /**
23
102
  * Fetch the draft/open state of a pull request.
24
103
  *
@@ -83,11 +162,20 @@ const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log
83
162
 
84
163
  // A merged or closed pull request cannot change its draft state; GitHub rejects it.
85
164
  if (status.state && status.state !== 'OPEN') {
165
+ // A merged/closed PR can no longer block anything, so stop tracking it (#2182).
166
+ untrackWorkingSessionDraft({ owner, repo, prNumber });
86
167
  await write('ℹ️', 'PR status:', `${status.state.toLowerCase()} - skipping ${label} conversion`);
87
168
  return { ok: true, changed: false, skipped: true, reason: `pr_${status.state.toLowerCase()}`, error: null };
88
169
  }
89
170
 
90
171
  if (status.isDraft === wantDraft) {
172
+ // Issue #2182: even when the PR already is a draft, this session now owns the
173
+ // obligation to convert it back, so it must be tracked like any other draft.
174
+ if (wantDraft) {
175
+ trackWorkingSessionDraft({ owner, repo, prNumber, reason });
176
+ } else {
177
+ untrackWorkingSessionDraft({ owner, repo, prNumber });
178
+ }
91
179
  await write('✅', 'PR status:', `Already in ${label}`);
92
180
  return { ok: true, changed: false, skipped: true, reason: 'already_in_target_state', error: null };
93
181
  }
@@ -96,6 +184,11 @@ const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log
96
184
  const convertResult = wantDraft ? await $`gh pr ready ${prNumber} --repo ${owner}/${repo} --undo` : await $`gh pr ready ${prNumber} --repo ${owner}/${repo}`;
97
185
 
98
186
  if (convertResult.code === 0) {
187
+ if (wantDraft) {
188
+ trackWorkingSessionDraft({ owner, repo, prNumber, reason });
189
+ } else {
190
+ untrackWorkingSessionDraft({ owner, repo, prNumber });
191
+ }
99
192
  await write('✅', 'PR converted:', `Now in ${label}`);
100
193
  return { ok: true, changed: true, skipped: false, reason: null, error: null };
101
194
  }
@@ -134,4 +227,7 @@ export default {
134
227
  getPullRequestDraftState,
135
228
  ensurePullRequestIsDraft,
136
229
  ensurePullRequestIsReady,
230
+ getOutstandingWorkingSessionDrafts,
231
+ restorePullRequestsLeftInDraft,
232
+ resetWorkingSessionDrafts,
137
233
  };
package/src/qwen.lib.mjs CHANGED
@@ -32,6 +32,7 @@ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-
32
32
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
33
33
  import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
34
34
  import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
35
+ import { ensureGeminiFamilyMemoryDisabled, isAgentMemoryDisabled } from './agent-memory-policy.lib.mjs'; // Issue #2178
35
36
 
36
37
  export const mapModelToId = model => qwenModels[model] || model;
37
38
 
@@ -524,6 +525,9 @@ export const executeQwenCommand = async params => {
524
525
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
525
526
 
526
527
  const mappedModel = mapModelToId(argv.model || defaultModels.qwen);
528
+ // Issue #2178: Qwen Code still ships the `save_memory` tool it inherited from
529
+ // Gemini CLI. Exclude it so nothing this task learns outlives the container.
530
+ if (isAgentMemoryDisabled(argv)) await ensureGeminiFamilyMemoryDisabled({ tool: 'qwen', log });
527
531
  // Issue #2130: Formal AI runs the native CLI against a local Formal AI server (no argv wrapper).
528
532
  const toolInvocation = await resolveFormalAiToolExecution({ tool: 'qwen', model: argv.model || defaultModels.qwen, toolPath: qwenPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv) });
529
533
  const qwenEnv = { ...process.env, ...toolInvocation.env };
@@ -48,6 +48,23 @@ const { AUTO_MERGE_BLOCKED_MARKER, buildAutoMergeBlockedComment, reportAutomatio
48
48
 
49
49
  const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
50
50
 
51
+ // Issue #2182: a pull request left in draft state by a restart iteration reports
52
+ // mergeable=MERGEABLE/CLEAN, but `gh pr merge` refuses it with "Pull Request is
53
+ // still a draft". Restore "ready for review" once and retry instead of failing
54
+ // (or, in the watch loop, retrying forever).
55
+ const { classifyMergeError, MERGE_ERROR_CATEGORIES } = await import('./merge-error-classification.lib.mjs');
56
+ const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
57
+ const { reportError } = await import('./sentry.lib.mjs');
58
+
59
+ /**
60
+ * Mark the pull request ready for review because its draft state is what blocks
61
+ * the merge. Returns true when the state was restored (or already correct).
62
+ */
63
+ const restoreReadyForReview = async ({ owner, repo, prNumber, reason }) => {
64
+ const result = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason, reportError });
65
+ return result?.ok === true;
66
+ };
67
+
51
68
  const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
52
69
 
53
70
  /**
@@ -161,7 +178,7 @@ export const attemptAutoMerge = async params => {
161
178
  await log(formatAligned('✅', 'CI checks passed:', 'Checking mergeability...', 2));
162
179
 
163
180
  // Check if PR is mergeable
164
- const mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
181
+ let mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
165
182
  if (mergeStatus.terminal) {
166
183
  await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', mergeStatus.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible', 2), { level: 'error' });
167
184
  await reportAutomationStop({
@@ -178,6 +195,15 @@ export const attemptAutoMerge = async params => {
178
195
  return { success: false, reason: 'terminal_github_entity_error', error: mergeStatus.reason };
179
196
  }
180
197
 
198
+ if (!mergeStatus.mergeable && mergeStatus.isDraft) {
199
+ // Issue #2182: the only blocker is the draft state this tool set itself.
200
+ await log(formatAligned('📝', 'PR is a draft:', 'restoring "ready for review" before merging', 2), { level: 'warning' });
201
+ const restored = await restoreReadyForReview({ owner, repo, prNumber, reason: 'auto-merge: draft blocks the merge' });
202
+ if (restored) {
203
+ mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
204
+ }
205
+ }
206
+
181
207
  if (!mergeStatus.mergeable) {
182
208
  await log(formatAligned('⚠️', 'PR not mergeable:', mergeStatus.reason || 'Unknown reason', 2));
183
209
  return { success: false, reason: 'not_mergeable', error: mergeStatus.reason };
@@ -198,7 +224,17 @@ export const attemptAutoMerge = async params => {
198
224
  if (deleteAfterMerge) {
199
225
  await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
200
226
  }
201
- const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
227
+ let mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
228
+
229
+ // Issue #2182: GitHub can still refuse the merge because of the draft state
230
+ // even when `gh pr view` already answered CLEAN/MERGEABLE (stale read).
231
+ // Restore "ready for review" once and retry the merge exactly once.
232
+ if (!mergeResult.success && classifyMergeError(mergeResult.error).category === MERGE_ERROR_CATEGORIES.DRAFT) {
233
+ await log(formatAligned('🔧', 'Self-healing:', 'GitHub refused the merge because the PR is a draft - marking it ready', 2), { level: 'warning' });
234
+ if (await restoreReadyForReview({ owner, repo, prNumber, reason: 'auto-merge: GitHub rejected the merge because the PR is a draft' })) {
235
+ mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
236
+ }
237
+ }
202
238
 
203
239
  if (mergeResult.success) {
204
240
  await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
@@ -223,7 +259,9 @@ export const attemptAutoMerge = async params => {
223
259
 
224
260
  return { success: true, reason: 'merged' };
225
261
  } else {
226
- await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2));
262
+ const classification = classifyMergeError(mergeResult.error);
263
+ await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2), { level: 'warning' });
264
+ await log(formatAligned('', 'Failure category:', classification.category, 2), { level: 'warning' });
227
265
  await reportAutomationStop({
228
266
  $,
229
267
  owner,
@@ -232,6 +270,7 @@ export const attemptAutoMerge = async params => {
232
270
  reason: 'merge_failed',
233
271
  mode: 'auto-merge',
234
272
  message: mergeResult.error || 'GitHub rejected the merge request.',
273
+ details: [classification.resolution, `Failure category: ${classification.category}`].filter(Boolean),
235
274
  verbose: argv.verbose,
236
275
  log,
237
276
  });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Guard rails for the --auto-merge / --auto-restart-until-mergeable watch loop.
3
+ *
4
+ * Issue #2182: a single task stayed in the "Processing" state for 4d 12h 13m 35s
5
+ * because the watch loop had no answer for a pull request it could never merge:
6
+ * no wall-clock ceiling, no classification of the merge failure and no reaction to
7
+ * a pull request left in draft. Those three decisions live here so that
8
+ * `watchUntilMergeable()` only keeps the control flow (`continue` / `return`) and
9
+ * the loop state.
10
+ *
11
+ * These are *defense in depth*. The root cause of #2182 is that hive-mind did not
12
+ * put the pull request back to "ready for review" when the working session ended;
13
+ * that is fixed in pr-draft-state.lib.mjs and its callers. The guards below make
14
+ * sure that a pull request which is a draft anyway - for instance because a human
15
+ * drafted it, or because the process was SIGKILLed before the safety nets ran -
16
+ * costs a few seconds instead of several days.
17
+ *
18
+ * @see https://github.com/link-assistant/hive-mind/issues/2182
19
+ */
20
+
21
+ const { classifyMergeError, MAX_CONSECUTIVE_MERGE_FAILURES } = await import('./merge-error-classification.lib.mjs');
22
+ const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
23
+
24
+ export { MAX_CONSECUTIVE_MERGE_FAILURES };
25
+
26
+ /**
27
+ * How long to wait after restoring "ready for review" before the next
28
+ * mergeability check. Short on purpose: nothing is running, we only need GitHub
29
+ * to reflect the new state.
30
+ */
31
+ export const DRAFT_RECHECK_DELAY_MS = 5000;
32
+
33
+ /** Default wall-clock ceiling for the auto-restart-until-mergeable loop. */
34
+ export const DEFAULT_WATCH_TIMEOUT_HOURS = 24;
35
+
36
+ /** How many times the loop may restore "ready for review" before giving up. */
37
+ export const MAX_DRAFT_SELF_HEALS = 3;
38
+
39
+ /**
40
+ * Normalize the `--auto-restart-until-mergeable-timeout-hours` value. `0` (and
41
+ * any negative input) means "no wall-clock limit"; when the flag is absent the
42
+ * default keeps a run from silently occupying a queue slot for days, as happened
43
+ * in the reported 4d 12h run.
44
+ *
45
+ * @param {number|string|null|undefined} raw
46
+ * @returns {number} hours, 0 = unlimited
47
+ */
48
+ export const normalizeWatchTimeoutHours = raw => {
49
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_WATCH_TIMEOUT_HOURS;
50
+ const parsed = Number(raw);
51
+ // A non-numeric value is a typo, not a request for an unlimited run: fall back
52
+ // to the default instead of silently disabling the timeout again (issue #2182).
53
+ if (!Number.isFinite(parsed)) return DEFAULT_WATCH_TIMEOUT_HOURS;
54
+ if (parsed <= 0) return 0;
55
+ return parsed;
56
+ };
57
+
58
+ /**
59
+ * Decide whether the watch loop has exceeded its wall-clock ceiling.
60
+ *
61
+ * @returns {{message: string, details: string[]}|null} null while within budget
62
+ */
63
+ export const evaluateWatchTimeout = ({ watchTimeoutHours, watchStartedAt, now, checksCompleted }) => {
64
+ if (!(watchTimeoutHours > 0)) return null;
65
+ const elapsedHours = (now - watchStartedAt) / 3600000;
66
+ if (elapsedHours < watchTimeoutHours) return null;
67
+ return {
68
+ message: `Auto-restart-until-mergeable stopped after ${elapsedHours.toFixed(1)}h (limit: ${watchTimeoutHours}h, ${checksCompleted} checks). The pull request never became mergeable.`,
69
+ details: ['Raise or disable the limit with --auto-restart-until-mergeable-timeout-hours (0 = unlimited).'],
70
+ };
71
+ };
72
+
73
+ /**
74
+ * React to a pull request that is still a draft while no AI session is running.
75
+ *
76
+ * GitHub answers mergeable=MERGEABLE / mergeStateStatus=CLEAN for such a pull
77
+ * request, so nothing else in the loop notices - the merge then fails with
78
+ * "Pull Request is still a draft" on every single check, which is exactly the
79
+ * 2692-iteration loop reported in #2182.
80
+ *
81
+ * @param {Object} options
82
+ * @param {{draftSelfHealCount: number}} options.state - mutable loop state
83
+ * @returns {Promise<{action: 'stop'|'retry'|'continue', reason?: string}>}
84
+ */
85
+ export const resolveDraftBlocker = async ({ owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose, state }) => {
86
+ await log(formatAligned('📝', 'PR is a draft:', 'no AI session is running - restoring "ready for review"', 2), { level: 'warning' });
87
+
88
+ if (state.draftSelfHealCount >= MAX_DRAFT_SELF_HEALS) {
89
+ const message = `Pull request #${prNumber} keeps returning to draft state (${state.draftSelfHealCount} restore attempts). A draft pull request cannot be merged.`;
90
+ await log(formatAligned('❌', 'DRAFT STATE UNRECOVERABLE:', message, 2), { level: 'error' });
91
+ await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'draft_pull_request', mode: 'auto-restart-until-mergeable', message, details: ['Mark the pull request as ready for review manually (gh pr ready), then rerun.'], verbose, log });
92
+ return { action: 'stop', reason: 'draft_pull_request' };
93
+ }
94
+
95
+ state.draftSelfHealCount++;
96
+ const readyResult = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'auto-restart-until-mergeable: draft blocks the merge', reportError });
97
+ if (readyResult?.ok) {
98
+ await log(formatAligned('✅', 'Draft restored:', `PR #${prNumber} is ready for review again (attempt ${state.draftSelfHealCount}/${MAX_DRAFT_SELF_HEALS})`, 2));
99
+ return { action: 'retry' };
100
+ }
101
+
102
+ await log(formatAligned('⚠️', 'Draft restore failed:', readyResult?.error || 'unknown error', 2), { level: 'warning' });
103
+ return { action: 'continue' };
104
+ };
105
+
106
+ /**
107
+ * Classify a failed `gh pr merge` and decide what the watch loop should do next.
108
+ *
109
+ * Before #2182 every failure - terminal or not - printed "Will continue
110
+ * monitoring..." and was retried every 120 seconds forever (5384 identical
111
+ * failures in the reported run).
112
+ *
113
+ * @param {Object} options
114
+ * @param {{draftSelfHealCount: number, consecutiveMergeFailures: number}} options.state - mutable loop state
115
+ * @returns {Promise<{action: 'stop'|'retry'|'continue', reason?: string}>}
116
+ */
117
+ export const resolveMergeFailure = async ({ error, owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose, state }) => {
118
+ state.consecutiveMergeFailures++;
119
+ const classification = classifyMergeError(error);
120
+ await log(formatAligned('⚠️', 'Auto-merge failed:', error || 'Unknown error', 2), { level: 'warning' });
121
+ await log(formatAligned('', 'Failure category:', `${classification.category} (attempt ${state.consecutiveMergeFailures}/${MAX_CONSECUTIVE_MERGE_FAILURES})`, 2), { level: 'warning' });
122
+ if (classification.resolution) {
123
+ await log(formatAligned('', 'Resolution:', classification.resolution, 2), { level: 'warning' });
124
+ }
125
+
126
+ if (classification.recoverable && classification.category === 'draft' && state.draftSelfHealCount < MAX_DRAFT_SELF_HEALS) {
127
+ state.draftSelfHealCount++;
128
+ await log(formatAligned('🔧', 'Self-healing:', `marking PR #${prNumber} as ready for review (attempt ${state.draftSelfHealCount}/${MAX_DRAFT_SELF_HEALS})`, 2));
129
+ const readyResult = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'auto-merge: GitHub rejected the merge because the PR is a draft', reportError });
130
+ if (readyResult?.ok) return { action: 'retry' };
131
+ }
132
+
133
+ if (classification.terminal || state.consecutiveMergeFailures >= MAX_CONSECUTIVE_MERGE_FAILURES) {
134
+ const message = `GitHub refused to merge pull request #${prNumber}: ${error || 'Unknown error'}`;
135
+ await log(formatAligned('❌', 'AUTO-MERGE STOPPED:', classification.terminal ? 'the failure is terminal - retrying cannot succeed' : `${state.consecutiveMergeFailures} consecutive merge failures`, 2), { level: 'error' });
136
+ await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'merge_failed', mode: 'auto-merge', message, details: [classification.resolution || 'Merge the pull request manually after resolving the cause.', `Failure category: ${classification.category}`], verbose, log });
137
+ return { action: 'stop', reason: 'merge_failed' };
138
+ }
139
+
140
+ return { action: 'continue' };
141
+ };
142
+
143
+ export default {
144
+ DRAFT_RECHECK_DELAY_MS,
145
+ DEFAULT_WATCH_TIMEOUT_HOURS,
146
+ MAX_DRAFT_SELF_HEALS,
147
+ MAX_CONSECUTIVE_MERGE_FAILURES,
148
+ normalizeWatchTimeoutHours,
149
+ evaluateWatchTimeout,
150
+ resolveDraftBlocker,
151
+ resolveMergeFailure,
152
+ };
@@ -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
  });