@link-assistant/hive-mind 2.1.1 → 2.1.3
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 +12 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +132 -32
- package/src/bidirectional-interactive.lib.mjs +169 -71
- package/src/exit-handler.lib.mjs +4 -2
- package/src/github.lib.mjs +2 -4
- package/src/live-input-capabilities.lib.mjs +221 -0
- package/src/solve.auto-merge-helpers.lib.mjs +66 -0
- package/src/solve.auto-merge.lib.mjs +34 -5
- package/src/solve.branch-divergence.lib.mjs +233 -3
- package/src/solve.config.lib.mjs +10 -10
- package/src/solve.fork-sync.lib.mjs +94 -38
- package/src/solve.mjs +1 -1
- package/src/solve.pre-pr-failure-notifier.lib.mjs +25 -30
- package/src/solve.validation.lib.mjs +3 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared capability matrix for live issue/PR event input.
|
|
3
|
+
*
|
|
4
|
+
* Issue #2007 asks solve to feed issue/PR events into the running AI tool "in
|
|
5
|
+
* all ways possible", and to provide a universal fallback for every tool that
|
|
6
|
+
* does not have a mid-session live input channel: wait for the current turn to
|
|
7
|
+
* finish in the JSON output, stop the process, and resume the AI session with
|
|
8
|
+
* the new events.
|
|
9
|
+
*
|
|
10
|
+
* Because of that fallback, live event input is *available* for every tool.
|
|
11
|
+
* Tools differ only in the delivery `mode`:
|
|
12
|
+
*
|
|
13
|
+
* - `stream` : the tool exposes a live stdin/JSON channel, so new events are
|
|
14
|
+
* written into the running process without restarting it. Claude
|
|
15
|
+
* and Agent (`--input-format stream-json`) are wired for this today.
|
|
16
|
+
* - `fallback`: no verified mid-session input channel exists yet, so solve uses
|
|
17
|
+
* the restart/resume loop (`--auto-restart-until-mergeable` /
|
|
18
|
+
* `watchUntilMergeable`). It waits for the current session to end,
|
|
19
|
+
* then resumes/restarts the AI with the new issue/PR events as
|
|
20
|
+
* feedback. This works for every tool.
|
|
21
|
+
*
|
|
22
|
+
* Missing native live-input features for each tool are reported upstream in the
|
|
23
|
+
* https://github.com/link-assistant/agent repository so they can be implemented
|
|
24
|
+
* (see `agentIssue`), after which a tool can graduate from `fallback` to `stream`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const ISSUE_2007_REQUIRED_EVENT_IDS = Object.freeze(['issue-title', 'issue-body', 'issue-comments', 'pull-request-comments']);
|
|
28
|
+
|
|
29
|
+
export const LIVE_INPUT_EVENT_SOURCES = Object.freeze([
|
|
30
|
+
Object.freeze({
|
|
31
|
+
id: 'issue-title',
|
|
32
|
+
label: 'Issue title updates',
|
|
33
|
+
requiredByIssue2007: true,
|
|
34
|
+
note: 'User-facing issue metadata should be streamed when it changes during a run.',
|
|
35
|
+
}),
|
|
36
|
+
Object.freeze({
|
|
37
|
+
id: 'issue-body',
|
|
38
|
+
label: 'Issue description updates',
|
|
39
|
+
requiredByIssue2007: true,
|
|
40
|
+
note: 'The issue body is treated as user feedback because it can change after the agent starts.',
|
|
41
|
+
}),
|
|
42
|
+
Object.freeze({
|
|
43
|
+
id: 'issue-comments',
|
|
44
|
+
label: 'Issue comments',
|
|
45
|
+
requiredByIssue2007: true,
|
|
46
|
+
note: 'New non-system issue comments are user feedback.',
|
|
47
|
+
}),
|
|
48
|
+
Object.freeze({
|
|
49
|
+
id: 'pull-request-comments',
|
|
50
|
+
label: 'Pull request comments',
|
|
51
|
+
requiredByIssue2007: true,
|
|
52
|
+
note: 'New non-system PR conversation comments and review comments should reach the agent.',
|
|
53
|
+
}),
|
|
54
|
+
Object.freeze({
|
|
55
|
+
id: 'pull-request-description',
|
|
56
|
+
label: 'Pull request description updates',
|
|
57
|
+
requiredByIssue2007: false,
|
|
58
|
+
note: 'Issue #2007 explicitly treats the PR description as AI-owned, so it is not a required user-feedback source.',
|
|
59
|
+
}),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const REQUIRED_EVENTS = ISSUE_2007_REQUIRED_EVENT_IDS;
|
|
63
|
+
|
|
64
|
+
// Delivery modes for live issue/PR event input.
|
|
65
|
+
export const LIVE_INPUT_MODE_STREAM = 'stream';
|
|
66
|
+
export const LIVE_INPUT_MODE_FALLBACK = 'fallback';
|
|
67
|
+
|
|
68
|
+
// Shared description of the universal restart/resume fallback so every tool
|
|
69
|
+
// entry reports it identically.
|
|
70
|
+
const FALLBACK_DESCRIPTION = 'Universal fallback: wait for the current AI turn to finish in the JSON output, stop the process, then resume/restart the AI session with the new issue/PR events as feedback via --auto-restart-until-mergeable (watchUntilMergeable). Works for every tool even without a live stdin channel.';
|
|
71
|
+
|
|
72
|
+
const CAPABILITIES = Object.freeze({
|
|
73
|
+
claude: Object.freeze({
|
|
74
|
+
tool: 'claude',
|
|
75
|
+
label: 'Claude',
|
|
76
|
+
available: true,
|
|
77
|
+
mode: LIVE_INPUT_MODE_STREAM,
|
|
78
|
+
liveStreaming: true,
|
|
79
|
+
supported: true,
|
|
80
|
+
option: '--auto-input-until-mergeable',
|
|
81
|
+
protocol: 'claude --input-format stream-json stdin NDJSON',
|
|
82
|
+
currentRunner: 'src/claude.lib.mjs keeps stdin as a pipe and attaches bidirectional-interactive.lib.mjs',
|
|
83
|
+
futureProtocol: '',
|
|
84
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
85
|
+
events: REQUIRED_EVENTS,
|
|
86
|
+
agentIssue: '',
|
|
87
|
+
unsupportedReason: '',
|
|
88
|
+
testing: 'Run solve with --tool claude --auto-input-until-mergeable, add an issue or PR comment while the Claude process is alive, and watch for the bidirectional handler to queue or stream a user frame into stdin.',
|
|
89
|
+
}),
|
|
90
|
+
codex: Object.freeze({
|
|
91
|
+
tool: 'codex',
|
|
92
|
+
label: 'Codex',
|
|
93
|
+
available: true,
|
|
94
|
+
mode: LIVE_INPUT_MODE_FALLBACK,
|
|
95
|
+
liveStreaming: false,
|
|
96
|
+
supported: false,
|
|
97
|
+
option: '--auto-input-until-mergeable',
|
|
98
|
+
protocol: 'Restart/resume fallback (no live stdin wired through solve for Codex yet).',
|
|
99
|
+
currentRunner: 'src/codex.lib.mjs uses codex exec with prompt/stdin context at process start',
|
|
100
|
+
futureProtocol: 'Codex app-server JSON-RPC turn/steer',
|
|
101
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
102
|
+
events: REQUIRED_EVENTS,
|
|
103
|
+
agentIssue: 'https://github.com/link-assistant/agent/issues',
|
|
104
|
+
unsupportedReason: 'The current solve Codex runner uses codex exec, whose stdin is one-shot prompt/context at process start. It does not expose a live JSON input pipe for mid-session issue/PR events, so the restart/resume fallback is used. Codex app-server turn/steer is the candidate protocol for a future live-streaming Codex runner.',
|
|
105
|
+
testing: 'Passing --tool codex --auto-input-until-mergeable activates the restart/resume fallback: the run finishes the current session, then resumes with the new issue/PR events.',
|
|
106
|
+
}),
|
|
107
|
+
agent: Object.freeze({
|
|
108
|
+
tool: 'agent',
|
|
109
|
+
label: 'Agent',
|
|
110
|
+
available: true,
|
|
111
|
+
mode: LIVE_INPUT_MODE_STREAM,
|
|
112
|
+
liveStreaming: true,
|
|
113
|
+
supported: true,
|
|
114
|
+
option: '--auto-input-until-mergeable',
|
|
115
|
+
protocol: 'agent --input-format stream-json --output-format stream-json stdin/stdout NDJSON',
|
|
116
|
+
currentRunner: 'src/agent.lib.mjs keeps stdin as a pipe and attaches bidirectional-interactive.lib.mjs when live input is enabled',
|
|
117
|
+
futureProtocol: '',
|
|
118
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
119
|
+
events: REQUIRED_EVENTS,
|
|
120
|
+
agentIssue: 'https://github.com/link-assistant/agent/pull/274',
|
|
121
|
+
unsupportedReason: '',
|
|
122
|
+
testing: 'Run solve with --tool agent --auto-input-until-mergeable, add an issue or PR comment while the Agent process is alive, and watch for the bidirectional handler to queue or stream a user frame into stdin.',
|
|
123
|
+
}),
|
|
124
|
+
opencode: Object.freeze({
|
|
125
|
+
tool: 'opencode',
|
|
126
|
+
label: 'OpenCode',
|
|
127
|
+
available: true,
|
|
128
|
+
mode: LIVE_INPUT_MODE_FALLBACK,
|
|
129
|
+
liveStreaming: false,
|
|
130
|
+
supported: false,
|
|
131
|
+
option: '--auto-input-until-mergeable',
|
|
132
|
+
protocol: 'Restart/resume fallback (no live JSON input channel wired through solve for OpenCode yet).',
|
|
133
|
+
currentRunner: 'src/opencode.lib.mjs uses a prompt-via-file/stdin pattern',
|
|
134
|
+
futureProtocol: '',
|
|
135
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
136
|
+
events: REQUIRED_EVENTS,
|
|
137
|
+
agentIssue: 'https://github.com/link-assistant/agent/issues',
|
|
138
|
+
unsupportedReason: 'No verified live JSON input channel is wired through solve for OpenCode yet, so the restart/resume fallback is used.',
|
|
139
|
+
testing: 'Passing --tool opencode --auto-input-until-mergeable activates the restart/resume fallback.',
|
|
140
|
+
}),
|
|
141
|
+
gemini: Object.freeze({
|
|
142
|
+
tool: 'gemini',
|
|
143
|
+
label: 'Gemini',
|
|
144
|
+
available: true,
|
|
145
|
+
mode: LIVE_INPUT_MODE_FALLBACK,
|
|
146
|
+
liveStreaming: false,
|
|
147
|
+
supported: false,
|
|
148
|
+
option: '--auto-input-until-mergeable',
|
|
149
|
+
protocol: 'Restart/resume fallback (no live JSON input channel wired through solve for Gemini yet).',
|
|
150
|
+
currentRunner: 'src/gemini.lib.mjs uses a prompt-driven process invocation',
|
|
151
|
+
futureProtocol: '',
|
|
152
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
153
|
+
events: REQUIRED_EVENTS,
|
|
154
|
+
agentIssue: 'https://github.com/link-assistant/agent/issues',
|
|
155
|
+
unsupportedReason: 'No verified live JSON input channel is wired through solve for Gemini yet, so the restart/resume fallback is used.',
|
|
156
|
+
testing: 'Passing --tool gemini --auto-input-until-mergeable activates the restart/resume fallback.',
|
|
157
|
+
}),
|
|
158
|
+
qwen: Object.freeze({
|
|
159
|
+
tool: 'qwen',
|
|
160
|
+
label: 'Qwen',
|
|
161
|
+
available: true,
|
|
162
|
+
mode: LIVE_INPUT_MODE_FALLBACK,
|
|
163
|
+
liveStreaming: false,
|
|
164
|
+
supported: false,
|
|
165
|
+
option: '--auto-input-until-mergeable',
|
|
166
|
+
protocol: 'Restart/resume fallback (no live JSON input channel wired through solve for Qwen yet).',
|
|
167
|
+
currentRunner: 'src/qwen.lib.mjs uses a prompt-driven process invocation',
|
|
168
|
+
futureProtocol: '',
|
|
169
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
170
|
+
events: REQUIRED_EVENTS,
|
|
171
|
+
agentIssue: 'https://github.com/link-assistant/agent/issues',
|
|
172
|
+
unsupportedReason: 'No verified live JSON input channel is wired through solve for Qwen yet, so the restart/resume fallback is used.',
|
|
173
|
+
testing: 'Passing --tool qwen --auto-input-until-mergeable activates the restart/resume fallback.',
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const UNKNOWN_CAPABILITY = tool =>
|
|
178
|
+
Object.freeze({
|
|
179
|
+
tool,
|
|
180
|
+
label: tool,
|
|
181
|
+
available: true,
|
|
182
|
+
mode: LIVE_INPUT_MODE_FALLBACK,
|
|
183
|
+
liveStreaming: false,
|
|
184
|
+
supported: false,
|
|
185
|
+
option: '--auto-input-until-mergeable',
|
|
186
|
+
protocol: 'Restart/resume fallback (no live JSON input channel wired through solve for this tool yet).',
|
|
187
|
+
currentRunner: 'Unknown or custom solve tool runner',
|
|
188
|
+
futureProtocol: '',
|
|
189
|
+
fallback: FALLBACK_DESCRIPTION,
|
|
190
|
+
events: REQUIRED_EVENTS,
|
|
191
|
+
agentIssue: 'https://github.com/link-assistant/agent/issues',
|
|
192
|
+
unsupportedReason: `No verified live JSON input channel is wired through solve for ${tool}, so the restart/resume fallback is used. Add a live-input capability entry (and report the missing native API to link-assistant/agent) once the runner has a long-lived stdin, JSON-RPC, or SDK channel that accepts new user turns mid-session.`,
|
|
193
|
+
testing: 'The flag activates the restart/resume fallback until a live-streaming runner is implemented.',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
export const getLiveInputCapability = tool => {
|
|
197
|
+
const normalizedTool = String(tool || '')
|
|
198
|
+
.trim()
|
|
199
|
+
.toLowerCase();
|
|
200
|
+
return CAPABILITIES[normalizedTool] || UNKNOWN_CAPABILITY(normalizedTool || 'unknown');
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the tool has a live *streaming* input channel (writes events into the
|
|
205
|
+
* running process). Kept as `isLiveInputSupported` for backward compatibility;
|
|
206
|
+
* it is the stream-mode predicate, not "is live input available at all".
|
|
207
|
+
*/
|
|
208
|
+
export const isLiveInputSupported = tool => getLiveInputCapability(tool).mode === LIVE_INPUT_MODE_STREAM;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether live issue/PR event input is available for the tool in *any* mode
|
|
212
|
+
* (streaming or restart/resume fallback). This is true for every tool.
|
|
213
|
+
*/
|
|
214
|
+
export const isLiveInputAvailable = tool => getLiveInputCapability(tool).available === true;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve the delivery mode ('stream' or 'fallback') for a tool.
|
|
218
|
+
*/
|
|
219
|
+
export const getLiveInputMode = tool => getLiveInputCapability(tool).mode;
|
|
220
|
+
|
|
221
|
+
export const getLiveInputCapabilityRows = () => Object.values(CAPABILITIES);
|
|
@@ -956,9 +956,75 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
956
956
|
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false, noWorkflowRunsForCommit };
|
|
957
957
|
};
|
|
958
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Issue #2007: Detect issue title/description changes across auto-restart
|
|
961
|
+
* iterations so the restart/resume fallback can deliver them as feedback for
|
|
962
|
+
* tools without a live input channel.
|
|
963
|
+
*
|
|
964
|
+
* The issue title and body are user-owned feedback surfaces (unlike the PR
|
|
965
|
+
* description, which #2007 treats as AI-owned). When they change while the AI
|
|
966
|
+
* is not streaming input, the next session must be told, otherwise the update
|
|
967
|
+
* would be silently ignored until the agent happens to re-read the issue.
|
|
968
|
+
*
|
|
969
|
+
* The first call (previousSnapshot = null) establishes the baseline and reports
|
|
970
|
+
* no change. Subsequent calls diff against the prior snapshot.
|
|
971
|
+
*
|
|
972
|
+
* @param {string} owner - Repository owner
|
|
973
|
+
* @param {string} repo - Repository name
|
|
974
|
+
* @param {number} issueNumber - Linked issue number
|
|
975
|
+
* @param {Object|null} previousSnapshot - Prior { title, body } snapshot, or null on first call
|
|
976
|
+
* @param {boolean} [verbose=false]
|
|
977
|
+
* @param {Function} [commandRunner=$] - Tagged-template command runner (injectable for tests)
|
|
978
|
+
* @returns {Promise<{changed: boolean, snapshot: Object|null, changes: Array<{field: string, from: string, to: string}>}>}
|
|
979
|
+
*/
|
|
980
|
+
export const checkForIssueMetadataChanges = async (owner, repo, issueNumber, previousSnapshot, verbose = false, commandRunner = $) => {
|
|
981
|
+
const empty = { changed: false, snapshot: previousSnapshot || null, changes: [] };
|
|
982
|
+
if (!issueNumber) return empty;
|
|
983
|
+
|
|
984
|
+
let snapshot;
|
|
985
|
+
try {
|
|
986
|
+
const result = await commandRunner`gh api repos/${owner}/${repo}/issues/${issueNumber} --jq '{title: .title, body: .body}'`;
|
|
987
|
+
if (result.code !== 0 || !result.stdout) return empty;
|
|
988
|
+
const parsed = JSON.parse(result.stdout.toString() || '{}');
|
|
989
|
+
snapshot = {
|
|
990
|
+
title: typeof parsed.title === 'string' ? parsed.title : '',
|
|
991
|
+
body: typeof parsed.body === 'string' ? parsed.body : '',
|
|
992
|
+
};
|
|
993
|
+
} catch (error) {
|
|
994
|
+
reportError(error, {
|
|
995
|
+
context: 'check_issue_metadata_changes',
|
|
996
|
+
owner,
|
|
997
|
+
repo,
|
|
998
|
+
issueNumber,
|
|
999
|
+
operation: 'fetch_issue_metadata',
|
|
1000
|
+
});
|
|
1001
|
+
return empty;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// First observation: establish the baseline without reporting a change.
|
|
1005
|
+
if (!previousSnapshot) {
|
|
1006
|
+
return { changed: false, snapshot, changes: [] };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const changes = [];
|
|
1010
|
+
if (snapshot.title !== previousSnapshot.title) {
|
|
1011
|
+
changes.push({ field: 'title', from: previousSnapshot.title, to: snapshot.title });
|
|
1012
|
+
}
|
|
1013
|
+
if (snapshot.body !== previousSnapshot.body) {
|
|
1014
|
+
changes.push({ field: 'body', from: previousSnapshot.body, to: snapshot.body });
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (verbose && changes.length > 0) {
|
|
1018
|
+
console.log(`[VERBOSE] Issue #${issueNumber} metadata changed: ${changes.map(c => c.field).join(', ')}`);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { changed: changes.length > 0, snapshot, changes };
|
|
1022
|
+
};
|
|
1023
|
+
|
|
959
1024
|
export default {
|
|
960
1025
|
checkForExistingComment,
|
|
961
1026
|
checkForNonBotComments,
|
|
1027
|
+
checkForIssueMetadataChanges,
|
|
962
1028
|
getMergeBlockers,
|
|
963
1029
|
shouldResetNoRunsCounter,
|
|
964
1030
|
};
|
|
@@ -59,7 +59,7 @@ import { limitReset } from './config.lib.mjs';
|
|
|
59
59
|
|
|
60
60
|
// Import helper functions extracted for file size management (Issue #1593)
|
|
61
61
|
const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
62
|
-
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers, shouldResetNoRunsCounter, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
|
|
62
|
+
const { checkForExistingComment, checkForNonBotComments, checkForIssueMetadataChanges, getMergeBlockers, shouldResetNoRunsCounter, trackAuthenticatedUserCommentsSince, nextMonotonicCheckTime } = autoMergeHelpers;
|
|
63
63
|
|
|
64
64
|
// Issue #1769: cancelled/stale CI re-run failures need a human action stop, not polling forever.
|
|
65
65
|
const cancelledCiRerunLib = await import('./cancelled-ci-rerun.lib.mjs');
|
|
@@ -137,7 +137,7 @@ export const watchUntilMergeable = async params => {
|
|
|
137
137
|
await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
|
|
138
138
|
await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
|
|
139
139
|
await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
|
|
140
|
-
await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
|
|
140
|
+
await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, issue title/description edits, CI failures, merge conflicts', 2));
|
|
141
141
|
// Issue #1708: Surface that --auto-input-until-mergeable streamed feedback
|
|
142
142
|
// into the prior session, so any restart triggered here is a fallback.
|
|
143
143
|
if (argv.autoInputUntilMergeable) {
|
|
@@ -157,6 +157,11 @@ export const watchUntilMergeable = async params => {
|
|
|
157
157
|
let iteration = 0;
|
|
158
158
|
let lastCheckTime = new Date();
|
|
159
159
|
|
|
160
|
+
// Issue #2007: Track the issue title/body across iterations so the
|
|
161
|
+
// restart/resume fallback can detect user edits to those surfaces and deliver
|
|
162
|
+
// them as feedback to the next session. The first check seeds the baseline.
|
|
163
|
+
let issueMetadataSnapshot = null;
|
|
164
|
+
|
|
160
165
|
while (true) {
|
|
161
166
|
iteration++;
|
|
162
167
|
const currentTime = new Date();
|
|
@@ -250,6 +255,14 @@ export const watchUntilMergeable = async params => {
|
|
|
250
255
|
trustAuthenticatedUserComments: true,
|
|
251
256
|
});
|
|
252
257
|
|
|
258
|
+
// Issue #2007: Detect issue title/description edits (user-owned feedback
|
|
259
|
+
// surfaces) so the fallback resumes the AI with them. The first iteration
|
|
260
|
+
// seeds the baseline and never reports a change.
|
|
261
|
+
const metadataCheck = await checkForIssueMetadataChanges(owner, repo, issueNumber, issueMetadataSnapshot, argv.verbose, $);
|
|
262
|
+
issueMetadataSnapshot = metadataCheck.snapshot || issueMetadataSnapshot;
|
|
263
|
+
const hasIssueMetadataChanges = metadataCheck.changed === true;
|
|
264
|
+
const issueMetadataChanges = metadataCheck.changes || [];
|
|
265
|
+
|
|
253
266
|
// Check for uncommitted changes using shared utility
|
|
254
267
|
const hasUncommittedChanges = await checkForUncommittedChanges(tempDir, argv);
|
|
255
268
|
|
|
@@ -264,8 +277,9 @@ export const watchUntilMergeable = async params => {
|
|
|
264
277
|
}
|
|
265
278
|
}
|
|
266
279
|
|
|
267
|
-
// If PR is mergeable, no blockers, no new comments,
|
|
268
|
-
|
|
280
|
+
// If PR is mergeable, no blockers, no new comments, no issue metadata
|
|
281
|
+
// edits, and no uncommitted changes
|
|
282
|
+
if (blockers.length === 0 && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges) {
|
|
269
283
|
// Issue #1503 (enhanced): Multi-mechanism consensus + repo-wide action check.
|
|
270
284
|
// Before declaring PR mergeable, run multiple independent CI detection mechanisms
|
|
271
285
|
// and require all to agree. This catches race conditions where CI starts between
|
|
@@ -420,6 +434,21 @@ export const watchUntilMergeable = async params => {
|
|
|
420
434
|
feedbackLines.push('Please review and address the feedback from these comments.');
|
|
421
435
|
}
|
|
422
436
|
|
|
437
|
+
// Issue #2007: Reason 1b: Issue title/description edited by the user.
|
|
438
|
+
if (hasIssueMetadataChanges) {
|
|
439
|
+
shouldRestart = true;
|
|
440
|
+
const changedFields = issueMetadataChanges.map(c => (c.field === 'title' ? 'title' : 'description')).join(' and ');
|
|
441
|
+
restartReason = restartReason ? `${restartReason}; Issue ${changedFields} edited` : `Issue ${changedFields} edited`;
|
|
442
|
+
feedbackLines.push(`✏️ The issue ${changedFields} was edited after the last session:`);
|
|
443
|
+
for (const change of issueMetadataChanges) {
|
|
444
|
+
const label = change.field === 'title' ? 'Issue title' : 'Issue description';
|
|
445
|
+
const updated = String(change.to ?? '');
|
|
446
|
+
feedbackLines.push(` - ${label} is now: "${updated.substring(0, 200)}${updated.length > 200 ? '...' : ''}"`);
|
|
447
|
+
}
|
|
448
|
+
feedbackLines.push('');
|
|
449
|
+
feedbackLines.push('Please re-read the updated issue and make sure your solution still matches the requirements.');
|
|
450
|
+
}
|
|
451
|
+
|
|
423
452
|
// Issue #1314: Check for billing limit errors BEFORE regular CI failures
|
|
424
453
|
// Billing limits require human intervention and should NOT trigger AI restarts
|
|
425
454
|
const billingBlocker = blockers.find(b => b.type === 'billing_limit');
|
|
@@ -581,7 +610,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
581
610
|
// take the restart path rather than the cancelled-review path.
|
|
582
611
|
const ciBlocker = ciFailureBlocker;
|
|
583
612
|
const hasMergeConflictBlocker = blockers.some(b => b.type === 'not_mergeable' && b.message?.includes('conflicts'));
|
|
584
|
-
if (externalReviewLimitBlocker && !ciBlocker && !billingBlocker && !cancelledBlocker && !hasNewComments && !hasUncommittedChanges && !hasMergeConflictBlocker) {
|
|
613
|
+
if (externalReviewLimitBlocker && !ciBlocker && !billingBlocker && !cancelledBlocker && !hasNewComments && !hasIssueMetadataChanges && !hasUncommittedChanges && !hasMergeConflictBlocker) {
|
|
585
614
|
await log('');
|
|
586
615
|
await log(formatAligned('🟡', 'READY FOR REVIEW', 'External review quota/credit limit requires human decision'));
|
|
587
616
|
for (const detail of externalReviewLimitBlocker.details || []) {
|
|
@@ -11,11 +11,243 @@ const outputOf = result => {
|
|
|
11
11
|
|
|
12
12
|
const shortSha = sha => (sha ? String(sha).slice(0, 12) : null);
|
|
13
13
|
|
|
14
|
+
export const FORK_DIVERGENCE_RESOLUTION_OPTION = '--allow-fork-divergence-resolution-using-force-push-with-lease';
|
|
15
|
+
|
|
14
16
|
const encodeRefForGitHubUrl = ref =>
|
|
15
17
|
encodeURI(String(ref || ''))
|
|
16
18
|
.replaceAll('#', '%23')
|
|
17
19
|
.replaceAll('?', '%3F');
|
|
18
20
|
|
|
21
|
+
const firstLine = value =>
|
|
22
|
+
String(value || '')
|
|
23
|
+
.split('\n')
|
|
24
|
+
.map(line => line.trim())
|
|
25
|
+
.find(Boolean) || '';
|
|
26
|
+
|
|
27
|
+
const sameLogin = (left, right) => Boolean(left && right && String(left).toLowerCase() === String(right).toLowerCase());
|
|
28
|
+
|
|
29
|
+
const formatCommitLine = commit => {
|
|
30
|
+
const sha = commit?.shortSha || shortSha(commit?.sha) || 'unknown';
|
|
31
|
+
const author = String(commit?.author || 'unknown author').trim();
|
|
32
|
+
const subject = String(commit?.subject || 'no subject').trim();
|
|
33
|
+
return `${sha} ${author} ${subject}`;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const formatCommitBullets = commits => {
|
|
37
|
+
const safeCommits = Array.isArray(commits) ? commits : [];
|
|
38
|
+
if (safeCommits.length === 0) return ['- No fork-only commits were returned by the inspection.'];
|
|
39
|
+
return safeCommits.map(commit => `- \`${formatCommitLine(commit)}\``);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const buildForkCompareUrl = ({ upstreamRepo, forkedRepo, branchName }) => {
|
|
43
|
+
const [upstreamOwner, upstreamName] = String(upstreamRepo || '').split('/');
|
|
44
|
+
const [forkOwner] = String(forkedRepo || '').split('/');
|
|
45
|
+
if (!upstreamOwner || !upstreamName || !forkOwner || !branchName) return null;
|
|
46
|
+
return `https://github.com/${upstreamRepo}/compare/${encodeRefForGitHubUrl(branchName)}...${encodeRefForGitHubUrl(`${forkOwner}:${branchName}`)}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const normalizeForkDivergenceSnapshot = (snapshot = {}) => {
|
|
50
|
+
const branchName = snapshot.branchName || 'the default branch';
|
|
51
|
+
const upstreamRepo = snapshot.upstreamRepo || 'the upstream repository';
|
|
52
|
+
const forkedRepo = snapshot.forkedRepo || 'the fork repository';
|
|
53
|
+
const forkRef = snapshot.forkRef || `origin/${branchName}`;
|
|
54
|
+
const upstreamRef = snapshot.upstreamRef || `upstream/${branchName}`;
|
|
55
|
+
const compareUrl = snapshot.compareUrl || buildForkCompareUrl({ upstreamRepo, forkedRepo, branchName });
|
|
56
|
+
const uniqueCommits = Array.isArray(snapshot.uniqueCommits) ? snapshot.uniqueCommits : [];
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...snapshot,
|
|
60
|
+
branchName,
|
|
61
|
+
upstreamRepo,
|
|
62
|
+
forkedRepo,
|
|
63
|
+
forkRef,
|
|
64
|
+
upstreamRef,
|
|
65
|
+
compareUrl,
|
|
66
|
+
uniqueCommits,
|
|
67
|
+
forkUniqueCount: Number.isFinite(snapshot.forkUniqueCount) ? snapshot.forkUniqueCount : null,
|
|
68
|
+
upstreamUniqueCount: Number.isFinite(snapshot.upstreamUniqueCount) ? snapshot.upstreamUniqueCount : null,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const buildActorLine = ({ currentUser, taskRequester }) => {
|
|
73
|
+
if (sameLogin(currentUser, taskRequester)) {
|
|
74
|
+
return `Hive Mind is authenticated as \`${currentUser}\`, the same GitHub user that requested this task.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (currentUser && taskRequester) {
|
|
78
|
+
return `Hive Mind is authenticated as \`${currentUser}\`; the task requester is \`${taskRequester}\`.`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (currentUser) {
|
|
82
|
+
return `Hive Mind is authenticated as \`${currentUser}\`; the task requester could not be resolved.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (taskRequester) {
|
|
86
|
+
return `The task requester is \`${taskRequester}\`; the authenticated Hive Mind user could not be resolved.`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return 'Hive Mind could not resolve the authenticated user or task requester.';
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const buildManualForkRepairCommands = ({ snapshot, solveCommand }) => {
|
|
93
|
+
const { branchName, forkedRepo, upstreamRef } = snapshot;
|
|
94
|
+
const commands = ['```bash', `gh repo delete ${forkedRepo} --yes`, `${solveCommand || 'solve <issue-url>'}`, '', '# Manual branch repair after preserving any fork-only commits:', 'git fetch upstream', `git reset --hard ${upstreamRef}`, `git push --force-with-lease origin ${branchName}`, '```'];
|
|
95
|
+
return commands.join('\n');
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export async function getForkDefaultBranchDivergenceSnapshot({ $, tempDir, branchName, forkedRepo, upstreamRepo }) {
|
|
99
|
+
const forkRef = `origin/${branchName}`;
|
|
100
|
+
const upstreamRef = `upstream/${branchName}`;
|
|
101
|
+
const compareUrl = buildForkCompareUrl({ upstreamRepo, forkedRepo, branchName });
|
|
102
|
+
|
|
103
|
+
const forkFetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
|
|
104
|
+
if (forkFetchResult.code !== 0) {
|
|
105
|
+
return {
|
|
106
|
+
branchName,
|
|
107
|
+
forkedRepo,
|
|
108
|
+
upstreamRepo,
|
|
109
|
+
forkRef,
|
|
110
|
+
upstreamRef,
|
|
111
|
+
compareUrl,
|
|
112
|
+
forkUniqueCount: null,
|
|
113
|
+
upstreamUniqueCount: null,
|
|
114
|
+
uniqueCommits: [],
|
|
115
|
+
fetchError: outputOf(forkFetchResult) || `failed to fetch ${forkRef}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const upstreamFetchResult = await $({ cwd: tempDir, silent: true })`git fetch upstream refs/heads/${branchName}:refs/remotes/upstream/${branchName} 2>&1`;
|
|
120
|
+
if (upstreamFetchResult.code !== 0) {
|
|
121
|
+
return {
|
|
122
|
+
branchName,
|
|
123
|
+
forkedRepo,
|
|
124
|
+
upstreamRepo,
|
|
125
|
+
forkRef,
|
|
126
|
+
upstreamRef,
|
|
127
|
+
compareUrl,
|
|
128
|
+
forkUniqueCount: null,
|
|
129
|
+
upstreamUniqueCount: null,
|
|
130
|
+
uniqueCommits: [],
|
|
131
|
+
fetchError: outputOf(upstreamFetchResult) || `failed to fetch ${upstreamRef}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const forkUniqueResult = await $({ cwd: tempDir, silent: true })`git rev-list --count ${upstreamRef}..${forkRef} 2>&1`;
|
|
136
|
+
const upstreamUniqueResult = await $({ cwd: tempDir, silent: true })`git rev-list --count ${forkRef}..${upstreamRef} 2>&1`;
|
|
137
|
+
const forkHeadResult = await $({ cwd: tempDir, silent: true })`git rev-parse ${forkRef} 2>&1`;
|
|
138
|
+
const upstreamHeadResult = await $({ cwd: tempDir, silent: true })`git rev-parse ${upstreamRef} 2>&1`;
|
|
139
|
+
const commitsResult = await $({ cwd: tempDir, silent: true })`git log --format=%H%x09%an%x09%s --max-count=20 ${upstreamRef}..${forkRef} 2>&1`;
|
|
140
|
+
|
|
141
|
+
const uniqueCommits =
|
|
142
|
+
commitsResult.code === 0
|
|
143
|
+
? commitsResult.stdout
|
|
144
|
+
.toString()
|
|
145
|
+
.trim()
|
|
146
|
+
.split('\n')
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.map(line => {
|
|
149
|
+
const [sha, author, ...subjectParts] = line.split('\t');
|
|
150
|
+
return {
|
|
151
|
+
sha,
|
|
152
|
+
shortSha: shortSha(sha),
|
|
153
|
+
author,
|
|
154
|
+
subject: subjectParts.join('\t'),
|
|
155
|
+
};
|
|
156
|
+
})
|
|
157
|
+
: [];
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
branchName,
|
|
161
|
+
forkedRepo,
|
|
162
|
+
upstreamRepo,
|
|
163
|
+
forkRef,
|
|
164
|
+
upstreamRef,
|
|
165
|
+
compareUrl,
|
|
166
|
+
forkUniqueCount: forkUniqueResult.code === 0 ? toCount(forkUniqueResult.stdout) : null,
|
|
167
|
+
upstreamUniqueCount: upstreamUniqueResult.code === 0 ? toCount(upstreamUniqueResult.stdout) : null,
|
|
168
|
+
forkHead: forkHeadResult.code === 0 ? firstLine(forkHeadResult.stdout) : null,
|
|
169
|
+
upstreamHead: upstreamHeadResult.code === 0 ? firstLine(upstreamHeadResult.stdout) : null,
|
|
170
|
+
uniqueCommits,
|
|
171
|
+
inspectError: commitsResult.code === 0 ? null : outputOf(commitsResult),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function buildForkDivergenceBlockedReason({ snapshot }) {
|
|
176
|
+
const details = normalizeForkDivergenceSnapshot(snapshot);
|
|
177
|
+
const lines = ['Repository setup halted - fork divergence requires user decision.', `Fork: ${details.forkedRepo}`, `Upstream: ${details.upstreamRepo}`, `Branch: ${details.branchName}`];
|
|
178
|
+
|
|
179
|
+
if (details.fetchError || details.inspectError) {
|
|
180
|
+
lines.push(`Inspection error: ${details.fetchError || details.inspectError}`);
|
|
181
|
+
lines.push('Hive Mind did not recommend automatic force-with-lease because it could not prove whether fork-only commits would be lost.');
|
|
182
|
+
return lines.join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
lines.push(`Fork-only commits that would be overwritten: ${details.forkUniqueCount ?? 'unknown'}`);
|
|
186
|
+
lines.push(`Upstream-only commits missing from fork: ${details.upstreamUniqueCount ?? 'unknown'}`);
|
|
187
|
+
if (details.compareUrl) lines.push(`Compare: ${details.compareUrl}`);
|
|
188
|
+
|
|
189
|
+
if ((details.forkUniqueCount ?? 0) > 0) {
|
|
190
|
+
lines.push('Fork-only commit list:');
|
|
191
|
+
for (const commit of details.uniqueCommits) {
|
|
192
|
+
lines.push(`- ${formatCommitLine(commit)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return lines.join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildForkDivergenceFailureActionSection({ snapshot = {}, currentUser = null, taskRequester = null, solveCommand = null } = {}) {
|
|
200
|
+
const details = normalizeForkDivergenceSnapshot(snapshot);
|
|
201
|
+
const actorLine = buildActorLine({ currentUser, taskRequester });
|
|
202
|
+
const sameUser = sameLogin(currentUser, taskRequester);
|
|
203
|
+
const hasInspectionError = Boolean(details.fetchError || details.inspectError);
|
|
204
|
+
const forkUniqueCount = details.forkUniqueCount;
|
|
205
|
+
const upstreamUniqueCount = details.upstreamUniqueCount;
|
|
206
|
+
|
|
207
|
+
const header = ['### What happened', `- Hive Mind checked \`${details.forkRef}\` in \`${details.forkedRepo}\` against \`${details.upstreamRef}\` in \`${details.upstreamRepo}\`.`, `- ${actorLine}`];
|
|
208
|
+
if (details.compareUrl) {
|
|
209
|
+
header.push(`- Compare the branch state: ${details.compareUrl}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (hasInspectionError) {
|
|
213
|
+
header.push(`- The safety inspection failed: ${details.fetchError || details.inspectError}`);
|
|
214
|
+
return `${header.join('\n')}
|
|
215
|
+
|
|
216
|
+
### What you can do
|
|
217
|
+
- Hive Mind did not recommend automatic force-with-lease because it could not prove whether fork-only commits would be overwritten.
|
|
218
|
+
- Ask a Hive Mind administrator to handle manual recreation or fix of the repository.
|
|
219
|
+
- Repository owner path: inspect \`${details.forkedRepo}\`, preserve any needed commits, then recreate or repair the fork default branch.`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (forkUniqueCount === 0) {
|
|
223
|
+
const rerunLine = sameUser ? `Rerun with \`${FORK_DIVERGENCE_RESOLUTION_OPTION}\` to let Hive Mind update \`${details.forkedRepo}\` using \`git push --force-with-lease origin ${details.branchName}\`.` : `Ask a Hive Mind administrator to rerun with \`${FORK_DIVERGENCE_RESOLUTION_OPTION}\` so Hive Mind can update \`${details.forkedRepo}\` using \`git push --force-with-lease origin ${details.branchName}\`.`;
|
|
224
|
+
|
|
225
|
+
return `${header.join('\n')}
|
|
226
|
+
- GitHub inspection found 0 commit(s) unique to \`${details.forkRef}\`; \`${details.forkRef}\` is ${upstreamUniqueCount ?? 'an unknown number of'} commit(s) behind \`${details.upstreamRef}\`.
|
|
227
|
+
|
|
228
|
+
### What you can do
|
|
229
|
+
- ${rerunLine}
|
|
230
|
+
- Delete or recreate the fork repository, or fix the repository manually, using these commands when you own the fork or are acting as the Hive Mind administrator:
|
|
231
|
+
|
|
232
|
+
${buildManualForkRepairCommands({ snapshot: details, solveCommand })}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const administratorLine = sameUser ? 'Delete or recreate the fork repository, or fix the repository manually, after preserving the fork-only commits listed above.' : 'Ask a Hive Mind administrator to handle manual recreation or fix of the repository.';
|
|
236
|
+
|
|
237
|
+
return `${header.join('\n')}
|
|
238
|
+
- \`${details.forkRef}\` has ${forkUniqueCount ?? 'an unknown number of'} commit(s) unique to \`${details.forkRef}\`; replacing it with \`${details.upstreamRef}\` would remove them from the fork default branch history.
|
|
239
|
+
|
|
240
|
+
### Commits that would be lost
|
|
241
|
+
${formatCommitBullets(details.uniqueCommits).join('\n')}
|
|
242
|
+
|
|
243
|
+
### What you can do
|
|
244
|
+
- ${administratorLine}
|
|
245
|
+
- Preserve the listed commit(s) first by moving them to another branch, cherry-picking them later, or confirming they are disposable.
|
|
246
|
+
- Manual repair example after preserving the listed commits:
|
|
247
|
+
|
|
248
|
+
${buildManualForkRepairCommands({ snapshot: details, solveCommand })}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
19
251
|
export function classifyPushRejection(errorOutput = '') {
|
|
20
252
|
const normalized = String(errorOutput || '').toLowerCase();
|
|
21
253
|
|
|
@@ -138,9 +370,7 @@ export function buildPushRejectionFailureActionSection({ owner, repo, branchName
|
|
|
138
370
|
- Inspect the remote branch: ${links.branchUrl}
|
|
139
371
|
- Compare the base and head branches: ${links.compareUrl}
|
|
140
372
|
- If the remote branch already contains the intended commit, rerun the solver. Matching remote branches are treated as usable after this fix.
|
|
141
|
-
-
|
|
142
|
-
|
|
143
|
-
Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
|
|
373
|
+
- Diverged-history path: merge or resolve \`${links.headBranchRef}\` against \`${links.baseBranchRef}\`, then rerun the solver.`;
|
|
144
374
|
}
|
|
145
375
|
|
|
146
376
|
export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null, owner = null, repo = null, defaultBranch = null, forkedRepo = null, classification = 'unknown' }) {
|