@link-assistant/hive-mind 1.56.6 → 1.56.8
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 +14 -0
- package/package.json +2 -2
- package/src/agent.lib.mjs +31 -4
- package/src/auto-iteration-limits.lib.mjs +33 -0
- package/src/claude.lib.mjs +9 -4
- package/src/codex.lib.mjs +47 -5
- package/src/hive.config.lib.mjs +1 -1
- package/src/hive.mjs +3 -0
- package/src/isolation-runner.lib.mjs +86 -27
- package/src/models/index.mjs +17 -0
- package/src/opencode.lib.mjs +28 -6
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/session-monitor.lib.mjs +161 -77
- package/src/solve.auto-continue.lib.mjs +14 -0
- package/src/solve.auto-merge.lib.mjs +91 -24
- package/src/solve.config.lib.mjs +25 -3
- package/src/solve.error-handlers.lib.mjs +1 -1
- package/src/solve.execution.lib.mjs +1 -1
- package/src/solve.mjs +12 -15
- package/src/solve.pre-pr-failure-notifier.lib.mjs +1 -1
- package/src/solve.results.lib.mjs +14 -8
- package/src/solve.watch.lib.mjs +14 -9
- package/src/telegram-bot.mjs +9 -9
- package/src/telegram-isolation.lib.mjs +2 -2
- package/src/telegram-solve-queue.lib.mjs +80 -34
- package/src/tool-retry.lib.mjs +118 -0
|
@@ -28,12 +28,6 @@ async function getIsolationRunner() {
|
|
|
28
28
|
}
|
|
29
29
|
return _isolationRunner;
|
|
30
30
|
}
|
|
31
|
-
// Legacy accessor for querySessionStatus
|
|
32
|
-
async function getQuerySessionStatus() {
|
|
33
|
-
const mod = await getIsolationRunner();
|
|
34
|
-
return mod.querySessionStatus;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
31
|
// In-memory session store
|
|
38
32
|
const activeSessions = new Map();
|
|
39
33
|
|
|
@@ -65,51 +59,6 @@ export async function checkScreenSessionExists(sessionName) {
|
|
|
65
59
|
}
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
/**
|
|
69
|
-
* Check if an isolated session is still running.
|
|
70
|
-
* Uses isolation-runner's isSessionRunning which includes screen -ls fallback
|
|
71
|
-
* for screen-backend sessions to work around start-command UUID mismatch.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} sessionId - UUID of the isolated session (screen session name)
|
|
74
|
-
* @param {Object} [options] - Options
|
|
75
|
-
* @param {string} [options.backend] - Isolation backend ('screen', 'tmux', 'docker')
|
|
76
|
-
* @param {boolean} [options.verbose] - Whether to log verbose output
|
|
77
|
-
* @returns {Promise<boolean>} True if session is still running
|
|
78
|
-
* @see https://github.com/link-assistant/hive-mind/issues/1545
|
|
79
|
-
*/
|
|
80
|
-
async function checkIsolatedSessionRunning(sessionId, options = {}) {
|
|
81
|
-
const opts = typeof options === 'boolean' ? { verbose: options } : options;
|
|
82
|
-
const { backend, verbose = false } = opts;
|
|
83
|
-
try {
|
|
84
|
-
const runner = await getIsolationRunner();
|
|
85
|
-
return await runner.isSessionRunning(sessionId, { backend, verbose });
|
|
86
|
-
} catch (error) {
|
|
87
|
-
if (verbose) {
|
|
88
|
-
console.error(`[VERBOSE] Error checking isolated session ${sessionId}: ${error.message}`);
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Get the exit code of a completed isolated session
|
|
96
|
-
* @param {string} sessionId - UUID of the isolated session
|
|
97
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
98
|
-
* @returns {Promise<number|null>} Exit code or null if unknown
|
|
99
|
-
*/
|
|
100
|
-
async function getIsolatedSessionExitCode(sessionId, verbose = false) {
|
|
101
|
-
try {
|
|
102
|
-
const queryStatus = await getQuerySessionStatus();
|
|
103
|
-
const result = await queryStatus(sessionId, verbose);
|
|
104
|
-
if (result.exists && result.status === 'executed') {
|
|
105
|
-
return result.exitCode;
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
62
|
/**
|
|
114
63
|
* Track a new session for completion monitoring
|
|
115
64
|
*
|
|
@@ -172,6 +121,67 @@ function completeSession(sessionName, exitCode = 0, verbose = false) {
|
|
|
172
121
|
}
|
|
173
122
|
}
|
|
174
123
|
|
|
124
|
+
function normalizeSessionUrl(url) {
|
|
125
|
+
return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
129
|
+
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
130
|
+
const elapsed = Date.now() - startTime.getTime();
|
|
131
|
+
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
132
|
+
if (verbose) {
|
|
133
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
|
|
134
|
+
}
|
|
135
|
+
activeSessions.delete(sessionName);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (verbose) {
|
|
139
|
+
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
140
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
|
|
146
|
+
const { verbose = false, statusProvider = null } = options;
|
|
147
|
+
const sessionId = sessionInfo.sessionId || sessionName;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const runner = await getIsolationRunner();
|
|
151
|
+
const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
|
|
152
|
+
|
|
153
|
+
if (statusResult?.exists && statusResult.status) {
|
|
154
|
+
if (runner.isExecutingSessionStatus(statusResult.status)) {
|
|
155
|
+
return { running: true, exitCode: null, status: statusResult.status, statusResult };
|
|
156
|
+
}
|
|
157
|
+
if (runner.isTerminalSessionStatus(statusResult.status)) {
|
|
158
|
+
return {
|
|
159
|
+
running: false,
|
|
160
|
+
exitCode: statusResult.exitCode !== undefined ? statusResult.exitCode : null,
|
|
161
|
+
status: statusResult.status,
|
|
162
|
+
statusResult,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const running = await runner.isSessionRunning(sessionId, {
|
|
168
|
+
backend: sessionInfo.isolationBackend,
|
|
169
|
+
verbose,
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
running,
|
|
173
|
+
exitCode: running ? null : (statusResult?.exitCode ?? null),
|
|
174
|
+
status: statusResult?.status || null,
|
|
175
|
+
statusResult,
|
|
176
|
+
};
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (verbose) {
|
|
179
|
+
console.error(`[VERBOSE] Error refreshing isolated session ${sessionId}: ${error.message}`);
|
|
180
|
+
}
|
|
181
|
+
return { running: false, exitCode: null, status: null, statusResult: null };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
/**
|
|
176
186
|
* Monitor active sessions and send notifications when they complete
|
|
177
187
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
|
@@ -193,15 +203,12 @@ export async function monitorSessions(bot, verbose = false) {
|
|
|
193
203
|
let exitCode = null;
|
|
194
204
|
|
|
195
205
|
if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
|
|
196
|
-
// Isolation mode: use $ --status with screen -ls
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (!stillRunning) {
|
|
203
|
-
exitCode = await getIsolatedSessionExitCode(sessionInfo.sessionId, verbose);
|
|
204
|
-
}
|
|
206
|
+
// Isolation mode: use $ --status, with screen -ls only as a fallback
|
|
207
|
+
// when the status record is unavailable. Terminal $ statuses are
|
|
208
|
+
// authoritative so completed screen sessions do not stay blocked.
|
|
209
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, { verbose });
|
|
210
|
+
stillRunning = state.running;
|
|
211
|
+
exitCode = state.exitCode;
|
|
205
212
|
} else {
|
|
206
213
|
// Issue #1586: Non-isolation screen sessions cannot reliably detect
|
|
207
214
|
// completion because start-screen keeps the screen alive via `exec bash`.
|
|
@@ -294,27 +301,14 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
294
301
|
if (!url) return { isActive: false, sessionName: null };
|
|
295
302
|
|
|
296
303
|
// Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
|
|
297
|
-
const
|
|
298
|
-
const normalizedUrl = normalizeUrl(url);
|
|
304
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
299
305
|
|
|
300
306
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
301
307
|
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
302
|
-
if (!sessionInfo.isolationBackend) {
|
|
303
|
-
|
|
304
|
-
const elapsed = Date.now() - startTime.getTime();
|
|
305
|
-
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
306
|
-
if (verbose) {
|
|
307
|
-
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
|
|
308
|
-
}
|
|
309
|
-
activeSessions.delete(sessionName);
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
if (verbose) {
|
|
313
|
-
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
314
|
-
console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
|
|
315
|
-
}
|
|
308
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
309
|
+
continue;
|
|
316
310
|
}
|
|
317
|
-
if (sessionInfo.url &&
|
|
311
|
+
if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
|
|
318
312
|
if (verbose) {
|
|
319
313
|
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
|
|
320
314
|
console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
|
|
@@ -329,6 +323,96 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
329
323
|
return { isActive: false, sessionName: null };
|
|
330
324
|
}
|
|
331
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Async active-session check for command handlers.
|
|
328
|
+
*
|
|
329
|
+
* Isolation-backed sessions are refreshed through `$ --status` before they
|
|
330
|
+
* block a duplicate URL, so completed screen-isolated runs no longer require
|
|
331
|
+
* waiting for the background polling interval.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} url - The GitHub URL to check
|
|
334
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
335
|
+
* @param {Object} [options] - Test/support options
|
|
336
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
337
|
+
* @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
|
|
338
|
+
*/
|
|
339
|
+
export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
|
|
340
|
+
if (!url) return { isActive: false, sessionName: null };
|
|
341
|
+
|
|
342
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
343
|
+
|
|
344
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
345
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!sessionInfo.isolationBackend) {
|
|
350
|
+
if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
351
|
+
return { isActive: true, sessionName, status: null };
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
357
|
+
verbose,
|
|
358
|
+
statusProvider: options.statusProvider,
|
|
359
|
+
});
|
|
360
|
+
if (state.running) {
|
|
361
|
+
if (verbose) {
|
|
362
|
+
console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
|
|
363
|
+
}
|
|
364
|
+
return { isActive: true, sessionName, status: state.status || null };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (verbose) {
|
|
368
|
+
console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
|
|
369
|
+
}
|
|
370
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
371
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (verbose) {
|
|
375
|
+
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
376
|
+
}
|
|
377
|
+
return { isActive: false, sessionName: null };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Refresh tracked isolation sessions and count only those that are executing.
|
|
382
|
+
*
|
|
383
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
384
|
+
* @param {Object} [options] - Test/support options
|
|
385
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
386
|
+
* @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
|
|
387
|
+
*/
|
|
388
|
+
export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
|
|
389
|
+
const sessions = [];
|
|
390
|
+
const byTool = {};
|
|
391
|
+
|
|
392
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
393
|
+
if (!sessionInfo.isolationBackend) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
398
|
+
verbose,
|
|
399
|
+
statusProvider: options.statusProvider,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!state.running) {
|
|
403
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
404
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const tool = sessionInfo.tool || 'claude';
|
|
409
|
+
sessions.push(sessionName);
|
|
410
|
+
byTool[tool] = (byTool[tool] || 0) + 1;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return { count: sessions.length, sessions, byTool };
|
|
414
|
+
}
|
|
415
|
+
|
|
332
416
|
/**
|
|
333
417
|
* Get statistics about session tracking
|
|
334
418
|
* @param {boolean} verbose - Whether to log verbose output
|
|
@@ -48,6 +48,7 @@ const { extractLinkedIssueNumber } = githubLinking;
|
|
|
48
48
|
|
|
49
49
|
// Import configuration
|
|
50
50
|
import { autoContinue, limitReset } from './config.lib.mjs';
|
|
51
|
+
import { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationCounter, normalizeAutoIterationLimit } from './auto-iteration-limits.lib.mjs';
|
|
51
52
|
|
|
52
53
|
// Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
|
|
53
54
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
@@ -79,6 +80,15 @@ const formatWaitTime = ms => {
|
|
|
79
80
|
// See: https://github.com/link-assistant/hive-mind/issues/1152
|
|
80
81
|
export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, shouldAttachLogs, tempDir = null, isRestart = false) => {
|
|
81
82
|
try {
|
|
83
|
+
const maxAutoResumeIterations = normalizeAutoIterationLimit(argv.autoResumeMaxIterations);
|
|
84
|
+
const currentAutoResumeIteration = normalizeAutoIterationCounter(argv.autoResumeIteration);
|
|
85
|
+
|
|
86
|
+
if (hasReachedAutoIterationLimit(currentAutoResumeIteration, maxAutoResumeIterations)) {
|
|
87
|
+
await log(`\n⚠️ Auto-${isRestart ? 'restart' : 'resume'} limit reached: ${currentAutoResumeIteration}/${formatAutoIterationLimit(maxAutoResumeIterations)}`);
|
|
88
|
+
await safeExit(1, `Auto-${isRestart ? 'restart' : 'resume'} limit reached`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const nextAutoResumeIteration = currentAutoResumeIteration + 1;
|
|
82
92
|
const resetTime = global.limitResetTime;
|
|
83
93
|
const timezone = global.limitTimezone || null;
|
|
84
94
|
const baseWaitMs = calculateWaitTime(resetTime);
|
|
@@ -125,6 +135,7 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
|
|
|
125
135
|
const actionType = isRestart ? 'Restarting' : 'Resuming';
|
|
126
136
|
await log(`\n✅ Limit reset time reached (+ ${bufferMinutes} min buffer + ${jitterSeconds}s jitter)! ${actionType} session...`);
|
|
127
137
|
await log(` Current time: ${new Date().toLocaleTimeString()}`);
|
|
138
|
+
await log(` Auto-${isRestart ? 'restart' : 'resume'} iteration: ${maxAutoResumeIterations === 0 ? nextAutoResumeIteration : `${nextAutoResumeIteration}/${maxAutoResumeIterations}`}`);
|
|
128
139
|
|
|
129
140
|
// Recursively call the solve script
|
|
130
141
|
// For resume: use --resume with session ID to maintain context
|
|
@@ -153,6 +164,8 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
|
|
|
153
164
|
if (argv.autoRestartOnLimitReset) {
|
|
154
165
|
resumeArgs.push('--auto-restart-on-limit-reset');
|
|
155
166
|
}
|
|
167
|
+
resumeArgs.push('--auto-resume-iteration', String(nextAutoResumeIteration));
|
|
168
|
+
resumeArgs.push('--auto-resume-max-iterations', String(maxAutoResumeIterations));
|
|
156
169
|
|
|
157
170
|
// Pass session type for proper comment differentiation
|
|
158
171
|
// See: https://github.com/link-assistant/hive-mind/issues/1152
|
|
@@ -162,6 +175,7 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
|
|
|
162
175
|
// Preserve other flags from original invocation
|
|
163
176
|
if (argv.tool && argv.tool !== 'claude') resumeArgs.push('--tool', argv.tool);
|
|
164
177
|
if (argv.model !== 'sonnet') resumeArgs.push('--model', argv.model);
|
|
178
|
+
if (argv.fallbackModel) resumeArgs.push('--fallback-model', argv.fallbackModel);
|
|
165
179
|
if (argv.verbose) resumeArgs.push('--verbose');
|
|
166
180
|
if (argv.fork) resumeArgs.push('--fork');
|
|
167
181
|
if (shouldAttachLogs) resumeArgs.push('--attach-logs');
|
|
@@ -60,6 +60,7 @@ const { READY_TO_MERGE_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrac
|
|
|
60
60
|
|
|
61
61
|
// Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
|
|
62
62
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
63
|
+
const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
66
|
* Main function: Watch and restart until PR becomes mergeable
|
|
@@ -73,6 +74,8 @@ export const watchUntilMergeable = async params => {
|
|
|
73
74
|
const MIN_CI_CHECK_INTERVAL_SECONDS = 120;
|
|
74
75
|
const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
|
|
75
76
|
const isAutoMerge = argv.autoMerge || false;
|
|
77
|
+
const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
|
|
78
|
+
const maxAutoResumeIterations = normalizeAutoIterationLimit(argv.autoResumeMaxIterations);
|
|
76
79
|
// Issue #1503/#1573/#1612: repo-wide action gating is opt-in strict mode.
|
|
77
80
|
// The config default may be bypassed when this module is reused directly, so normalize here.
|
|
78
81
|
const waitForAllRepoActionsFlag = argv.waitForAllActionsInRepositoryBeforeMergeable ?? argv['wait-for-all-actions-in-repository-before-mergeable'] ?? argv.waitForAllActionsInRepositoryBeforeMergable ?? argv['wait-for-all-actions-in-repository-before-mergable'] ?? false;
|
|
@@ -83,6 +86,7 @@ export const watchUntilMergeable = async params => {
|
|
|
83
86
|
|
|
84
87
|
// Issue #1323: Track actual AI restarts separately from check cycle iterations
|
|
85
88
|
let restartCount = 0;
|
|
89
|
+
let limitResumeCount = 0;
|
|
86
90
|
|
|
87
91
|
// Issue #1371: In-memory dedup for "Ready to merge" comment (per-session, not all-time)
|
|
88
92
|
let readyToMergeCommentPosted = false;
|
|
@@ -102,6 +106,8 @@ export const watchUntilMergeable = async params => {
|
|
|
102
106
|
await log(formatAligned('', 'Mode:', isAutoMerge ? 'Auto-merge (will merge when ready)' : 'Auto-restart-until-mergeable (will NOT auto-merge)', 2));
|
|
103
107
|
await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds (minimum: ${MIN_CI_CHECK_INTERVAL_SECONDS}s)`, 2));
|
|
104
108
|
await log(formatAligned('', 'Initial cooldown:', `${INITIAL_COOLDOWN_SECONDS} seconds`, 2));
|
|
109
|
+
await log(formatAligned('', 'Max restart iterations:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
|
|
110
|
+
await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
|
|
105
111
|
await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
|
|
106
112
|
await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
|
|
107
113
|
await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
|
|
@@ -480,20 +486,85 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
480
486
|
}
|
|
481
487
|
|
|
482
488
|
if (shouldRestart) {
|
|
483
|
-
|
|
484
|
-
|
|
489
|
+
if (hasReachedAutoIterationLimit(restartCount, maxAutoRestartIterations)) {
|
|
490
|
+
await log('');
|
|
491
|
+
await log(formatAligned('⚠️', 'AUTO-RESTART LIMIT REACHED', `Stopping after ${restartCount} restart iteration${restartCount !== 1 ? 's' : ''}`));
|
|
492
|
+
await log(formatAligned('', 'Configured limit:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
|
|
493
|
+
await log(formatAligned('', 'Remaining blockers:', restartReason, 2));
|
|
494
|
+
await log('');
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
const limitComment = `## ⚠️ Auto-restart limit reached
|
|
498
|
+
|
|
499
|
+
Hive Mind stopped auto-restart-until-mergeable after ${restartCount} restart iteration${restartCount !== 1 ? 's' : ''}.
|
|
500
|
+
|
|
501
|
+
**Configured limit:** ${formatAutoIterationLimit(maxAutoRestartIterations)}
|
|
502
|
+
**Remaining reason:** ${restartReason}
|
|
503
|
+
|
|
504
|
+
No further AI sessions will be started automatically for this run. Please review the remaining blockers manually or rerun with a higher \`--auto-restart-max-iterations\` value.
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
*Auto-restart-until-mergeable stopped by the safety limit.*`;
|
|
508
|
+
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: limitComment });
|
|
509
|
+
} catch (commentError) {
|
|
510
|
+
reportError(commentError, {
|
|
511
|
+
context: 'post_auto_restart_limit_comment',
|
|
512
|
+
owner,
|
|
513
|
+
repo,
|
|
514
|
+
prNumber,
|
|
515
|
+
operation: 'comment_on_pr',
|
|
516
|
+
});
|
|
517
|
+
await log(formatAligned('', '⚠️ Could not post auto-restart limit comment to PR', '', 2));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { success: false, reason: 'auto_restart_limit_reached', latestSessionId, latestAnthropicCost };
|
|
521
|
+
}
|
|
485
522
|
|
|
486
523
|
// Add standard instructions for auto-restart-until-mergeable mode using shared utility
|
|
487
524
|
feedbackLines.push(...buildAutoRestartInstructions());
|
|
488
525
|
|
|
526
|
+
// Get PR merge state status
|
|
527
|
+
const prStateResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '.mergeStateStatus'`;
|
|
528
|
+
const mergeStateStatus = prStateResult.code === 0 ? prStateResult.stdout.toString().trim() : null;
|
|
529
|
+
|
|
530
|
+
// Issue #1572: Sync clean local branches with remote before restarting to avoid push failures.
|
|
531
|
+
// Issue #1664: Do not run git pull over an unfinished merge or other uncommitted state.
|
|
532
|
+
// The tool must see that state and either commit, continue, abort, or otherwise resolve it.
|
|
533
|
+
const effectiveBranch = prBranch || branchName;
|
|
534
|
+
if (shouldSyncBeforeRestart({ hasUncommittedChanges })) {
|
|
535
|
+
const pullResult = await $({ cwd: tempDir })`git pull origin ${effectiveBranch} 2>&1`;
|
|
536
|
+
if (pullResult.code === 0) {
|
|
537
|
+
await log(formatAligned('🔄', 'Synced:', `Local branch ${effectiveBranch} updated from remote`));
|
|
538
|
+
} else {
|
|
539
|
+
const pullOutput = `${pullResult.stdout || ''}${pullResult.stderr || ''}`.trim() || 'no output';
|
|
540
|
+
const pullLeftLocalChanges = await checkForUncommittedChanges(tempDir, argv);
|
|
541
|
+
if (pullLeftLocalChanges && /CONFLICT|MERGE_HEAD|unmerged|Automatic merge failed|not concluded your merge/i.test(pullOutput)) {
|
|
542
|
+
await log(formatAligned('⚠️', 'Sync produced merge state:', 'Proceeding with AI restart to resolve it', 2));
|
|
543
|
+
feedbackLines.push('');
|
|
544
|
+
feedbackLines.push('⚠️ Branch sync encountered an unfinished merge or conflicts:');
|
|
545
|
+
feedbackLines.push(pullOutput);
|
|
546
|
+
feedbackLines.push('');
|
|
547
|
+
feedbackLines.push('Please resolve the merge state before finishing.');
|
|
548
|
+
} else {
|
|
549
|
+
throw new Error(`git pull failed (code ${pullResult.code}): ${pullOutput}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} else {
|
|
553
|
+
await log(formatAligned('↪️', 'Skipping branch sync:', 'Local uncommitted/merge state must be resolved by the AI session', 2));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Issue #1323: Increment restart count only when a tool execution is about to start.
|
|
557
|
+
restartCount++;
|
|
558
|
+
|
|
489
559
|
await log(formatAligned('🔄', 'RESTART TRIGGERED:', restartReason));
|
|
490
|
-
await log(formatAligned('', 'Restart iteration:', `${restartCount}`, 2));
|
|
560
|
+
await log(formatAligned('', 'Restart iteration:', maxAutoRestartIterations === 0 ? `${restartCount}` : `${restartCount}/${maxAutoRestartIterations}`, 2));
|
|
491
561
|
await log('');
|
|
492
562
|
|
|
493
|
-
// Post a comment to PR about the restart
|
|
494
|
-
//
|
|
563
|
+
// Post a comment to PR about the restart after preflight succeeds, so every
|
|
564
|
+
// posted restart notification corresponds to an actual tool session.
|
|
495
565
|
try {
|
|
496
|
-
const
|
|
566
|
+
const limitText = maxAutoRestartIterations === 0 ? 'No automatic restart limit is configured.' : `This run will stop after ${maxAutoRestartIterations} restart iteration${maxAutoRestartIterations !== 1 ? 's' : ''}.`;
|
|
567
|
+
const commentBody = `## 🔄 ${AUTO_RESTART_MARKER} triggered (iteration ${restartCount})\n\n**Reason:** ${restartReason}\n\nStarting new session to address the issues.\n\n---\n*Auto-restart-until-mergeable mode is active. ${limitText}*`;
|
|
497
568
|
// Issue #1625: Track so this doesn't falsely count as an AI-authored comment
|
|
498
569
|
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
|
|
499
570
|
await log(formatAligned('', '💬 Posted auto-restart notification to PR', '', 2));
|
|
@@ -508,20 +579,6 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
508
579
|
await log(formatAligned('', '⚠️ Could not post comment to PR', '', 2));
|
|
509
580
|
}
|
|
510
581
|
|
|
511
|
-
// Get PR merge state status
|
|
512
|
-
const prStateResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '.mergeStateStatus'`;
|
|
513
|
-
const mergeStateStatus = prStateResult.code === 0 ? prStateResult.stdout.toString().trim() : null;
|
|
514
|
-
|
|
515
|
-
// Issue #1572: Sync local branch with remote before restarting to avoid push failures.
|
|
516
|
-
// Without this, the restarted session works on stale local state and can't push.
|
|
517
|
-
const effectiveBranch = prBranch || branchName;
|
|
518
|
-
const pullResult = await $({ cwd: tempDir })`git pull origin ${effectiveBranch} 2>&1`;
|
|
519
|
-
if (pullResult.code === 0) {
|
|
520
|
-
await log(formatAligned('🔄', 'Synced:', `Local branch ${effectiveBranch} updated from remote`));
|
|
521
|
-
} else {
|
|
522
|
-
throw new Error(`git pull failed (code ${pullResult.code}): ${pullResult.stdout || pullResult.stderr || 'no output'}`);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
582
|
// Execute the AI tool using shared utility
|
|
526
583
|
await log(formatAligned('🔄', 'Restarting:', `Running ${argv.tool.toUpperCase()} to address issues...`));
|
|
527
584
|
|
|
@@ -545,6 +602,15 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
545
602
|
// Issue #1570: Always post a GitHub comment to notify the user about the delay
|
|
546
603
|
// and when exactly execution will be resumed, so the user doesn't think the process is stuck.
|
|
547
604
|
if (isUsageLimitReached(toolResult)) {
|
|
605
|
+
if (hasReachedAutoIterationLimit(limitResumeCount, maxAutoResumeIterations)) {
|
|
606
|
+
await log('');
|
|
607
|
+
await log(formatAligned('⚠️', 'AUTO-RESUME LIMIT REACHED', `Stopping after ${limitResumeCount} limit-reset continuation${limitResumeCount !== 1 ? 's' : ''}`));
|
|
608
|
+
await log(formatAligned('', 'Configured limit:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
|
|
609
|
+
await log('');
|
|
610
|
+
return { success: false, reason: 'auto_resume_limit_reached', latestSessionId, latestAnthropicCost };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
limitResumeCount++;
|
|
548
614
|
const resumeSessionId = toolResult.sessionId;
|
|
549
615
|
const resetTime = toolResult.limitResetTime;
|
|
550
616
|
const baseWaitMs = resetTime ? calculateWaitTime(resetTime) : 0;
|
|
@@ -567,6 +633,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
567
633
|
await log(formatAligned('', 'Reset time:', resetTime || 'Unknown', 2));
|
|
568
634
|
await log(formatAligned('', 'Waiting:', `${waitMinutes} min (reset + ${bufferMinutes} min buffer + ${jitterSeconds}s jitter)`, 2));
|
|
569
635
|
await log(formatAligned('', 'Resume at:', resumeTimeUTC, 2));
|
|
636
|
+
await log(formatAligned('', 'Auto-resume iteration:', maxAutoResumeIterations === 0 ? `${limitResumeCount}` : `${limitResumeCount}/${maxAutoResumeIterations}`, 2));
|
|
570
637
|
await log(formatAligned('', 'Action:', 'Posting GitHub comment and waiting for limit reset', 2));
|
|
571
638
|
if (resumeSessionId) {
|
|
572
639
|
await log(formatAligned('', 'Session ID:', resumeSessionId, 2));
|
|
@@ -598,7 +665,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
598
665
|
toolName: `Anthropic ${(argv.tool || 'claude').charAt(0).toUpperCase() + (argv.tool || 'claude').slice(1)} Code`,
|
|
599
666
|
isAutoResumeEnabled: true,
|
|
600
667
|
autoResumeMode: 'restart',
|
|
601
|
-
requestedModel: argv.model,
|
|
668
|
+
requestedModel: argv.originalModel || argv.model,
|
|
602
669
|
tool: argv.tool || 'claude',
|
|
603
670
|
publicPricingEstimate: toolResult.publicPricingEstimate,
|
|
604
671
|
pricingInfo: toolResult.pricingInfo,
|
|
@@ -676,7 +743,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
676
743
|
errorMessage: `${argv.tool.toUpperCase()} execution failed after limit reset`,
|
|
677
744
|
sessionId: latestSessionId,
|
|
678
745
|
tempDir,
|
|
679
|
-
requestedModel: argv.model,
|
|
746
|
+
requestedModel: argv.originalModel || argv.model,
|
|
680
747
|
tool: argv.tool || 'claude',
|
|
681
748
|
});
|
|
682
749
|
}
|
|
@@ -726,7 +793,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
726
793
|
errorMessage: `${argv.tool.toUpperCase()} execution failed`,
|
|
727
794
|
sessionId: latestSessionId,
|
|
728
795
|
tempDir,
|
|
729
|
-
requestedModel: argv.model,
|
|
796
|
+
requestedModel: argv.originalModel || argv.model,
|
|
730
797
|
tool: argv.tool || 'claude',
|
|
731
798
|
});
|
|
732
799
|
}
|
|
@@ -791,7 +858,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
791
858
|
publicPricingEstimate: toolResult.publicPricingEstimate,
|
|
792
859
|
pricingInfo: toolResult.pricingInfo,
|
|
793
860
|
// Issue #1225: Pass model and tool info for PR comments
|
|
794
|
-
requestedModel: argv.model,
|
|
861
|
+
requestedModel: argv.originalModel || argv.model,
|
|
795
862
|
tool: argv.tool || 'claude',
|
|
796
863
|
// Issue #1508: Include budget stats (context/token/cost) for auto-restart log
|
|
797
864
|
resultModelUsage: toolResult.resultModelUsage || null,
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// This approach was adopted per issue #482 feedback to minimize custom code maintenance
|
|
9
9
|
|
|
10
10
|
import { enhanceErrorMessage, detectMalformedFlags } from './option-suggestions.lib.mjs';
|
|
11
|
-
import { defaultModels, buildModelOptionDescription, resolveRuntimeDefaultModel } from './models/index.mjs';
|
|
11
|
+
import { defaultModels, buildModelOptionDescription, resolveDefaultFallbackModel, resolveRuntimeDefaultModel } from './models/index.mjs';
|
|
12
12
|
import { validateBranchName } from './solve.branch.lib.mjs';
|
|
13
13
|
|
|
14
14
|
// Re-export for use by telegram-bot.mjs (avoids extra import lines there)
|
|
@@ -173,8 +173,19 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
173
173
|
},
|
|
174
174
|
'auto-restart-max-iterations': {
|
|
175
175
|
type: 'number',
|
|
176
|
-
description: 'Maximum number of auto-restart iterations
|
|
177
|
-
default:
|
|
176
|
+
description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
|
|
177
|
+
default: 5,
|
|
178
|
+
},
|
|
179
|
+
'auto-resume-max-iterations': {
|
|
180
|
+
type: 'number',
|
|
181
|
+
description: 'Maximum number of automatic resume/restart continuations after usage-limit resets (default: 5, 0 = unlimited)',
|
|
182
|
+
default: 5,
|
|
183
|
+
},
|
|
184
|
+
'auto-resume-iteration': {
|
|
185
|
+
type: 'number',
|
|
186
|
+
description: 'Internal: current automatic resume/restart continuation count',
|
|
187
|
+
default: 0,
|
|
188
|
+
hidden: true,
|
|
178
189
|
},
|
|
179
190
|
'auto-merge': {
|
|
180
191
|
type: 'boolean',
|
|
@@ -248,6 +259,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
248
259
|
description: 'Maximum thinking budget for calculating --think level mappings (default: 31999 for Claude Code). Values: off=0, low=max/4, medium=max/2, high=max*3/4, max=max.',
|
|
249
260
|
default: 31999,
|
|
250
261
|
},
|
|
262
|
+
'fallback-model': {
|
|
263
|
+
type: 'string',
|
|
264
|
+
description: 'Fallback model to switch to on model capacity/overload errors. When supported, retries resume the same session with this model. Defaults: claude opus/opus-4-7 -> opus-4-6; codex gpt-5.5 -> gpt-5.4; all others unset.',
|
|
265
|
+
default: undefined,
|
|
266
|
+
},
|
|
251
267
|
'show-thinking-content': {
|
|
252
268
|
type: 'boolean',
|
|
253
269
|
description: 'Show thinking content in Claude responses. Opus 4.7 omits thinking content by default; this option opts in to receive summarized thinking blocks. Disabled by default. Only affects --tool claude.',
|
|
@@ -616,6 +632,7 @@ export const parseArguments = async (yargs, hideBin) => {
|
|
|
616
632
|
// Yargs doesn't properly handle dynamic defaults based on other arguments,
|
|
617
633
|
// so we need to handle this manually after parsing
|
|
618
634
|
const modelExplicitlyProvided = rawArgs.includes('--model') || rawArgs.includes('-m') || rawArgs.includes('--worker-model');
|
|
635
|
+
const fallbackModelExplicitlyProvided = rawArgs.includes('--fallback-model');
|
|
619
636
|
const planModelExplicitlyProvided = rawArgs.includes('--plan-model');
|
|
620
637
|
|
|
621
638
|
// --plan flag expansion (Issue #1223)
|
|
@@ -681,6 +698,11 @@ export const parseArguments = async (yargs, hideBin) => {
|
|
|
681
698
|
argv.model = await resolveRuntimeDefaultModel(argv.tool);
|
|
682
699
|
}
|
|
683
700
|
|
|
701
|
+
if (argv.tool && !fallbackModelExplicitlyProvided) {
|
|
702
|
+
const defaultFallbackModel = resolveDefaultFallbackModel(argv.tool, argv.model);
|
|
703
|
+
argv.fallbackModel = defaultFallbackModel || undefined;
|
|
704
|
+
}
|
|
705
|
+
|
|
684
706
|
// Validate mutual exclusivity of --claude-file and --gitkeep-file
|
|
685
707
|
// Check if both are explicitly enabled (user passed both --claude-file and --gitkeep-file)
|
|
686
708
|
if (argv.claudeFile && argv.gitkeepFile) {
|
|
@@ -65,7 +65,7 @@ export const handleFailure = async options => {
|
|
|
65
65
|
verbose: argv.verbose,
|
|
66
66
|
errorMessage: cleanErrorMessage(error),
|
|
67
67
|
// Issue #1225: Pass model and tool info for PR comments
|
|
68
|
-
requestedModel: argv.model,
|
|
68
|
+
requestedModel: argv.originalModel || argv.model,
|
|
69
69
|
tool: argv.tool || 'claude',
|
|
70
70
|
});
|
|
71
71
|
if (logUploadSuccess) {
|
|
@@ -195,7 +195,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
|
|
|
195
195
|
verbose: argv.verbose || false,
|
|
196
196
|
errorMessage: cleanErrorMessage(error),
|
|
197
197
|
// Issue #1225: Pass model and tool info for PR comments
|
|
198
|
-
requestedModel: argv.model,
|
|
198
|
+
requestedModel: argv.originalModel || argv.model,
|
|
199
199
|
tool: argv.tool || 'claude',
|
|
200
200
|
});
|
|
201
201
|
|