@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.15.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 98fb373: Guarantee that a working session converts its pull request back to "ready for review" (issue #2182).
8
+
9
+ A task ran for 4d 12h 13m 35s and printed `✅ PR IS MERGEABLE!` 2692 times, each followed by `GraphQL: Pull Request is still a draft (mergePullRequest)`. The pull request was a draft because hive-mind had put it there and never took it back out: over the whole 102 244-line run it performed exactly one draft/ready conversion — `Converting PR: To draft mode` — and zero conversions back. The only draft → ready transition came from the AI model itself, running `gh pr ready 142` because the prompt asked it to.
10
+
11
+ **The state machine is now symmetric.** `pr-draft-state.lib.mjs` tracks every draft it hands out, so the matching ready conversion is guaranteed by code rather than requested from the AI:
12
+
13
+ - `executeToolIteration` converts the pull request back to ready in a `finally` block, so a crash, an API error or an aborted tool process still ends the iteration with a mergeable pull request. Previously it drafted the pull request (issue #2123) and had no counterpart at all.
14
+ - `endWorkSession` performs the ready conversion unconditionally. It used to be gated behind `isContinueMode`, which was `false` for the entire reported run, so the one place responsible for the transition never ran. Only the session _comments_ stay gated — they are `--watch`/`--auto-continue` reporting, not state.
15
+ - `solve.mjs` converts the pull request to ready **before** starting the auto-merge watch loop. The AI working session is over at that point; the loop that follows can run for days, and `endWorkSession()` sits behind it.
16
+ - The CTRL+C handler and the fatal-error handler drain the outstanding-draft registry, so an aborted session cannot leave a pull request permanently unmergeable. On interrupt this runs before the log upload, which can be cut off by the isolation backend's SIGKILL (issue #2052).
17
+ - `solve.results.lib.mjs` no longer shells out to `gh pr ready` inline; every transition goes through the state machine, so merged/closed pull requests are skipped and the registry stays accurate.
18
+
19
+ The prompt line asking the AI to mark the pull request ready stays, but nothing depends on it any more.
20
+
21
+ **Defence in depth** — each of these alone would also have ended the reported run, and they bound the damage of a draft pull request whatever its origin:
22
+
23
+ - **`checkPRMergeable` ignored `isDraft`.** A draft pull request with no other blockers reports `mergeable: MERGEABLE` with `mergeStateStatus: CLEAN` — GitHub does not return `DRAFT` there — so the old `mergeable === 'MERGEABLE'` test said yes. Mergeability is now decided by `evaluatePullRequestMergeability`, which treats a draft as not mergeable and reports why. `getMergeBlockers` emits a `draft` blocker on both its normal path and the early "checks have not started yet" path.
24
+ - **Merge failures were unclassified.** Every failed `gh pr merge` was logged as "Will continue monitoring...", regardless of cause. `classifyMergeError` now sorts the error into draft/conflict/blocked/closed/permission/not-mergeable/unknown, the loop self-heals a draft up to three times by marking the pull request ready, and any category stops after `MAX_CONSECUTIVE_MERGE_FAILURES` (3) instead of retrying indefinitely.
25
+ - **The watch loop had no wall-clock limit.** `--auto-restart-until-mergeable-timeout-hours` is new and defaults to 24; the loop now checks elapsed time on every pass and stops with a `watch_timeout` reason.
26
+
27
+ The single-shot merge attempt, the Telegram merge queue and its wait loop use the same classification, so a draft is skipped with the real reason instead of timing out. Every draft/ready conversion now logs the reason it was made, so the log answers "who drafted this and who was supposed to undo it" directly. Full analysis, the run log excerpts and a reproduction script are in `docs/case-studies/issue-2182/`.
28
+
3
29
  ## 2.15.0
4
30
 
5
31
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -21,6 +21,9 @@ import { githubLimits } from './config.lib.mjs';
21
21
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
22
22
  import { getTerminalGitHubEntityErrorMessage, isTerminalGitHubEntityError } from './github-terminal-state.lib.mjs';
23
23
  import { cancellableSleep } from './interruptible-sleep.lib.mjs';
24
+ // Issue #2182: draft detection and merge-failure classification live in one
25
+ // pure module shared by every merge call site.
26
+ import { classifyMergeError, evaluatePullRequestMergeability } from './merge-error-classification.lib.mjs';
24
27
 
25
28
  // Issue #1722: gh api `--paginate --slurp` responses for repos with many
26
29
  // historical workflow runs can easily exceed Node's default 1 MB exec buffer
@@ -466,7 +469,12 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
466
469
  for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
467
470
  if (isCancelled?.()) return { mergeable: false, reason: 'Operation was cancelled', cancelled: true };
468
471
  try {
469
- const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
472
+ // Issue #2182: `isDraft` MUST be part of this query. GitHub answers
473
+ // mergeable=MERGEABLE / mergeStateStatus=CLEAN for a draft pull request
474
+ // with no other blockers, so without this field a draft PR was declared
475
+ // mergeable and `gh pr merge` failed forever with
476
+ // "Pull Request is still a draft".
477
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json isDraft,mergeable,mergeStateStatus`);
470
478
  const pr = JSON.parse(stdout.trim());
471
479
 
472
480
  // Issue #1339: If mergeStateStatus is 'UNKNOWN', GitHub is still computing.
@@ -486,36 +494,16 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, o
486
494
  return { mergeable: false, mergeableState: pr.mergeable, mergeStateStatus: pr.mergeStateStatus, reason: `Merge state: UNKNOWN (GitHub could not compute mergeability after ${MAX_UNKNOWN_RETRIES} attempts)` };
487
495
  }
488
496
 
489
- const mergeable = pr.mergeable === 'MERGEABLE';
490
- let reason = null;
491
-
492
- if (!mergeable) {
493
- switch (pr.mergeStateStatus) {
494
- case 'BLOCKED':
495
- reason = 'PR is blocked (possibly by branch protection rules)';
496
- break;
497
- case 'BEHIND':
498
- reason = 'PR branch is behind the base branch';
499
- break;
500
- case 'DIRTY':
501
- reason = 'PR has merge conflicts';
502
- break;
503
- case 'UNSTABLE':
504
- reason = 'PR has failing required status checks';
505
- break;
506
- case 'DRAFT':
507
- reason = 'PR is a draft';
508
- break;
509
- default:
510
- reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
511
- }
512
- }
497
+ const evaluation = evaluatePullRequestMergeability(pr);
513
498
 
514
499
  if (verbose) {
515
- console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
500
+ // Issue #2182: isDraft is logged explicitly. In the reported 4.5-day run
501
+ // the log only ever showed "mergeable: true, state: CLEAN", which hid the
502
+ // actual blocker.
503
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${evaluation.mergeable}, state: ${pr.mergeStateStatus}, isDraft: ${pr.isDraft === true}`);
516
504
  }
517
505
 
518
- return { mergeable, mergeableState: pr.mergeable, mergeStateStatus: pr.mergeStateStatus, reason };
506
+ return { mergeable: evaluation.mergeable, isDraft: evaluation.isDraft, mergeableState: evaluation.mergeableState, mergeStateStatus: evaluation.mergeStateStatus, reason: evaluation.reason };
519
507
  } catch (error) {
520
508
  if (isTerminalGitHubEntityError(error)) {
521
509
  const terminalError = getTerminalGitHubEntityErrorMessage(error);
@@ -607,13 +595,21 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
607
595
 
608
596
  return { success: true, error: null };
609
597
  } catch (error) {
598
+ // Issue #2182: classify the failure so watch loops can stop (or self-heal)
599
+ // instead of retrying an impossible merge every 120 seconds forever.
600
+ const classification = classifyMergeError(error.message);
610
601
  if (verbose) {
611
602
  console.log(`[VERBOSE] /merge: Failed to merge PR #${prNumber}: ${error.message}`);
603
+ console.log(`[VERBOSE] /merge: Failure category: ${classification.category} (terminal=${classification.terminal}, recoverable=${classification.recoverable})`);
612
604
  }
613
- return { success: false, error: error.message };
605
+ return { success: false, error: error.message, category: classification.category, terminal: classification.terminal, recoverable: classification.recoverable, resolution: classification.resolution };
614
606
  }
615
607
  }
616
608
 
609
+ // Issue #2182: re-exported so merge call sites can import classification from
610
+ // the same module they already use for merging.
611
+ export { classifyMergeError, evaluatePullRequestMergeability, MERGE_ERROR_CATEGORIES, MAX_CONSECUTIVE_MERGE_FAILURES } from './merge-error-classification.lib.mjs';
612
+
617
613
  /**
618
614
  * Parse and validate a repository URL for the merge command
619
615
  * @param {string} url - Repository URL
@@ -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
  };
@@ -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
+ };