@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.
- package/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +8 -0
- package/src/agent-memory-policy.lib.mjs +305 -0
- package/src/claude-quiet-config.lib.mjs +7 -2
- package/src/codex.lib.mjs +7 -0
- package/src/gemini.lib.mjs +5 -0
- package/src/github-merge.lib.mjs +24 -28
- package/src/merge-error-classification.lib.mjs +134 -0
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/pr-draft-state.lib.mjs +96 -0
- package/src/qwen.lib.mjs +4 -0
- package/src/solve.auto-merge-attempt.lib.mjs +42 -3
- package/src/solve.auto-merge-guards.lib.mjs +152 -0
- package/src/solve.auto-merge-helpers.lib.mjs +20 -1
- package/src/solve.auto-merge-preflight.lib.mjs +122 -0
- package/src/solve.auto-merge.lib.mjs +70 -84
- package/src/solve.config.lib.mjs +13 -0
- package/src/solve.interrupt.lib.mjs +18 -1
- package/src/solve.mjs +19 -0
- package/src/solve.restart-shared.lib.mjs +302 -278
- package/src/solve.results.lib.mjs +6 -12
- package/src/solve.session.lib.mjs +15 -4
- package/src/telegram-merge-queue.lib.mjs +3 -1
- package/src/telegram-merge-wait.lib.mjs +7 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|