@link-assistant/hive-mind 2.13.4 → 2.13.5
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 +19 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/gemini.lib.mjs +5 -1
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +4 -277
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
|
@@ -33,6 +33,7 @@ import { isDockerIsolation, sessionStartMs, resolveOomKilledState, resolveStaleE
|
|
|
33
33
|
// Issue #2134: kill-cause diagnostics + the matching pull-request notice.
|
|
34
34
|
import { buildKillCompletionSections, announceKillOnPullRequest } from './session-monitor.kill-sections.lib.mjs';
|
|
35
35
|
import { runKillRecoveryForCompletion } from './session-kill-resume.lib.mjs';
|
|
36
|
+
import { createSessionRegistryQueries } from './session-monitor.queries.lib.mjs';
|
|
36
37
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
37
38
|
export { DOCKER_TERMINAL_FOOTER_GRACE_MS } from './session-monitor.docker-terminal.lib.mjs';
|
|
38
39
|
export { STALE_EXECUTING_MIN_AGE_MS, DOCKER_BACKEND_GONE_GRACE_MS } from './session-monitor.stale-executing.lib.mjs';
|
|
@@ -1142,275 +1143,14 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1142
1143
|
}
|
|
1143
1144
|
return { resumed, skipped };
|
|
1144
1145
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
* still block duplicate commands for the same URL, which prevents accidental
|
|
1157
|
-
* re-runs. Isolation-backed sessions have no timeout since their completion
|
|
1158
|
-
* is reliably detected by monitorSessions().
|
|
1159
|
-
*
|
|
1160
|
-
* @param {string} url - The GitHub URL to check (issue or PR URL)
|
|
1161
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1162
|
-
* @returns {{isActive: boolean, sessionName: string|null}} Whether an active session exists for this URL
|
|
1163
|
-
*/
|
|
1164
|
-
export function hasActiveSessionForUrl(url, verbose = false) {
|
|
1165
|
-
if (!url) return { isActive: false, sessionName: null };
|
|
1166
|
-
// Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
|
|
1167
|
-
const normalizedUrl = normalizeSessionUrl(url);
|
|
1168
|
-
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1169
|
-
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
1170
|
-
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
|
|
1174
|
-
if (verbose) {
|
|
1175
|
-
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
|
|
1176
|
-
console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
|
|
1177
|
-
}
|
|
1178
|
-
return { isActive: true, sessionName };
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
if (verbose) {
|
|
1182
|
-
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
1183
|
-
}
|
|
1184
|
-
return { isActive: false, sessionName: null };
|
|
1185
|
-
}
|
|
1186
|
-
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1187
|
-
/**
|
|
1188
|
-
* Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
|
|
1189
|
-
* and report whether it can be stopped by forwarding CTRL+C to the
|
|
1190
|
-
* start-command session UUID.
|
|
1191
|
-
*
|
|
1192
|
-
* The `/stop <url>` Telegram flow originally consulted only the in-memory solve
|
|
1193
|
-
* queue. But a `/solve` or `/codex` that starts immediately (queue empty)
|
|
1194
|
-
* dispatches straight to a detached isolation session and is removed from the
|
|
1195
|
-
* queue's `processing` Map the moment it is launched. From that point on the
|
|
1196
|
-
* session-monitor's in-memory registry is the only place that still knows the
|
|
1197
|
-
* URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
|
|
1198
|
-
* even though the task was clearly running. This helper exposes that registry
|
|
1199
|
-
* so the stop flow can recover the UUID and interrupt the session.
|
|
1200
|
-
*
|
|
1201
|
-
* A session is stoppable when it was launched with an isolation backend and its
|
|
1202
|
-
* start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
|
|
1203
|
-
* non-isolation screen sessions are reported but marked `stoppable: false`
|
|
1204
|
-
* because `$ --stop` cannot interrupt them.
|
|
1205
|
-
*
|
|
1206
|
-
* @param {string} url - GitHub issue or PR URL (any normalization)
|
|
1207
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1208
|
-
* @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
|
|
1209
|
-
* isolationBackend: string|null, stoppable: boolean }|null} Match or null
|
|
1210
|
-
*/
|
|
1211
|
-
export function findStoppableSessionByUrl(url, verbose = false) {
|
|
1212
|
-
if (!url) return null;
|
|
1213
|
-
const normalizedUrl = normalizeSessionUrl(url);
|
|
1214
|
-
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1215
|
-
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
// Issue #1586: skip expired non-isolation sessions — they are no longer running.
|
|
1219
|
-
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
1220
|
-
continue;
|
|
1221
|
-
}
|
|
1222
|
-
// The UUID `$ --stop` expects is the start-command session id. For
|
|
1223
|
-
// isolation sessions it is tracked either as sessionInfo.sessionId or as
|
|
1224
|
-
// the (UUID-shaped) session key itself.
|
|
1225
|
-
const candidateId = sessionInfo.sessionId || sessionName;
|
|
1226
|
-
const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
|
|
1227
|
-
const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
|
|
1228
|
-
if (verbose) {
|
|
1229
|
-
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
|
|
1230
|
-
console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
|
|
1231
|
-
}
|
|
1232
|
-
return {
|
|
1233
|
-
sessionName,
|
|
1234
|
-
sessionId,
|
|
1235
|
-
sessionInfo,
|
|
1236
|
-
isolationBackend: sessionInfo.isolationBackend || null,
|
|
1237
|
-
stoppable,
|
|
1238
|
-
};
|
|
1239
|
-
}
|
|
1240
|
-
if (verbose) {
|
|
1241
|
-
console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
|
|
1242
|
-
}
|
|
1243
|
-
return null;
|
|
1244
|
-
}
|
|
1245
|
-
/**
|
|
1246
|
-
* Async active-session check for command handlers.
|
|
1247
|
-
*
|
|
1248
|
-
* Isolation-backed sessions are refreshed through `$ --status` before they
|
|
1249
|
-
* block a duplicate URL, so completed screen-isolated runs no longer require
|
|
1250
|
-
* waiting for the background polling interval.
|
|
1251
|
-
*
|
|
1252
|
-
* @param {string} url - The GitHub URL to check
|
|
1253
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1254
|
-
* @param {Object} [options] - Test/support options
|
|
1255
|
-
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
1256
|
-
* @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
|
|
1257
|
-
*/
|
|
1258
|
-
export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
|
|
1259
|
-
if (!url) return { isActive: false, sessionName: null };
|
|
1260
|
-
const normalizedUrl = normalizeSessionUrl(url);
|
|
1261
|
-
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1262
|
-
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
1263
|
-
continue;
|
|
1264
|
-
}
|
|
1265
|
-
if (!sessionInfo.isolationBackend) {
|
|
1266
|
-
if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
1267
|
-
return { isActive: true, sessionName, status: null };
|
|
1268
|
-
}
|
|
1269
|
-
continue;
|
|
1270
|
-
}
|
|
1271
|
-
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
1272
|
-
verbose,
|
|
1273
|
-
statusProvider: options.statusProvider,
|
|
1274
|
-
});
|
|
1275
|
-
if (state.running) {
|
|
1276
|
-
if (verbose) {
|
|
1277
|
-
console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
|
|
1278
|
-
}
|
|
1279
|
-
return { isActive: true, sessionName, status: state.status || null };
|
|
1280
|
-
}
|
|
1281
|
-
if (verbose) {
|
|
1282
|
-
console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
|
|
1283
|
-
}
|
|
1284
|
-
sessionInfo.lastKnownStatus = state.status || null;
|
|
1285
|
-
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
1286
|
-
}
|
|
1287
|
-
if (verbose) {
|
|
1288
|
-
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
1289
|
-
}
|
|
1290
|
-
return { isActive: false, sessionName: null };
|
|
1291
|
-
}
|
|
1292
|
-
/**
|
|
1293
|
-
* Refresh tracked isolation sessions and count only those that are executing.
|
|
1294
|
-
*
|
|
1295
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1296
|
-
* @param {Object} [options] - Test/support options
|
|
1297
|
-
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
1298
|
-
* @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
|
|
1299
|
-
*/
|
|
1300
|
-
export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
|
|
1301
|
-
const sessions = [];
|
|
1302
|
-
const byTool = {};
|
|
1303
|
-
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1304
|
-
if (!sessionInfo.isolationBackend) {
|
|
1305
|
-
continue;
|
|
1306
|
-
}
|
|
1307
|
-
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
1308
|
-
verbose,
|
|
1309
|
-
statusProvider: options.statusProvider,
|
|
1310
|
-
});
|
|
1311
|
-
if (!state.running) {
|
|
1312
|
-
sessionInfo.lastKnownStatus = state.status || null;
|
|
1313
|
-
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
1314
|
-
continue;
|
|
1315
|
-
}
|
|
1316
|
-
const tool = sessionInfo.tool || 'claude';
|
|
1317
|
-
sessions.push(sessionName);
|
|
1318
|
-
byTool[tool] = (byTool[tool] || 0) + 1;
|
|
1319
|
-
}
|
|
1320
|
-
return { count: sessions.length, sessions, byTool };
|
|
1321
|
-
}
|
|
1322
|
-
/**
|
|
1323
|
-
* Return the currently-executing tracked sessions with the details needed to
|
|
1324
|
-
* render them as a clickable list in `/queue`: the issue/PR
|
|
1325
|
-
* `url`, the `tool`, the start time, and (for isolation sessions) the backend
|
|
1326
|
-
* status. Both isolation and non-isolation screen sessions are included so the
|
|
1327
|
-
* list matches what is actually executing — the queue's own in-memory
|
|
1328
|
-
* `processing` Map is empty once a task has been dispatched to a detached
|
|
1329
|
-
* session, which is why executing tasks were previously not listed.
|
|
1330
|
-
*
|
|
1331
|
-
* Liveness is determined the same way as {@link monitorSessions}: isolation
|
|
1332
|
-
* sessions via `$ --status`, non-isolation screen sessions via a timeout window
|
|
1333
|
-
* plus a best-effort `screen -ls` check.
|
|
1334
|
-
*
|
|
1335
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1336
|
-
* @param {Object} [options] - Test/support options
|
|
1337
|
-
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
1338
|
-
* @param {Function} [options.screenChecker] - Optional screen-existence checker
|
|
1339
|
-
* @returns {Promise<Array<{sessionName: string, url: string|null, tool: string, status: string|null, startTime: (Date|string|number|null), isolationBackend: (string|null)}>>}
|
|
1340
|
-
* @see https://github.com/link-assistant/hive-mind/issues/1837
|
|
1341
|
-
*/
|
|
1342
|
-
export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
1343
|
-
const items = [];
|
|
1344
|
-
const screenChecker = options.screenChecker || checkScreenSessionExists;
|
|
1345
|
-
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1346
|
-
let running;
|
|
1347
|
-
let status = null;
|
|
1348
|
-
if (sessionInfo.isolationBackend) {
|
|
1349
|
-
// Forward every injectable seam so the listing applies the same #1927
|
|
1350
|
-
// stale-`executing` reconciliation the monitor does — a session that
|
|
1351
|
-
// start-command still reports as `executing` but whose backend is gone (or
|
|
1352
|
-
// whose log footer shows a kill) must not be listed as running — and so the
|
|
1353
|
-
// whole path stays controllable from tests.
|
|
1354
|
-
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
1355
|
-
verbose,
|
|
1356
|
-
statusProvider: options.statusProvider,
|
|
1357
|
-
exitFromLog: options.exitFromLog,
|
|
1358
|
-
backendAlive: options.backendAlive,
|
|
1359
|
-
sessionRunning: options.sessionRunning,
|
|
1360
|
-
});
|
|
1361
|
-
running = state.running;
|
|
1362
|
-
status = state.status || null;
|
|
1363
|
-
if (!running) {
|
|
1364
|
-
sessionInfo.lastKnownStatus = state.status || null;
|
|
1365
|
-
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
1366
|
-
continue;
|
|
1367
|
-
}
|
|
1368
|
-
} else {
|
|
1369
|
-
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
1370
|
-
const elapsed = Date.now() - startTime.getTime();
|
|
1371
|
-
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
1372
|
-
if (verbose) {
|
|
1373
|
-
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s; excluded from running list`);
|
|
1374
|
-
}
|
|
1375
|
-
continue;
|
|
1376
|
-
}
|
|
1377
|
-
running = await screenChecker(sessionName);
|
|
1378
|
-
if (!running) {
|
|
1379
|
-
continue;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
items.push({
|
|
1383
|
-
sessionName,
|
|
1384
|
-
url: sessionInfo.url || null,
|
|
1385
|
-
tool: sessionInfo.tool || 'claude',
|
|
1386
|
-
status,
|
|
1387
|
-
startTime: sessionInfo.startTime || null,
|
|
1388
|
-
isolationBackend: sessionInfo.isolationBackend || null,
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
if (verbose) {
|
|
1392
|
-
console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
|
|
1393
|
-
}
|
|
1394
|
-
return items;
|
|
1395
|
-
}
|
|
1396
|
-
/**
|
|
1397
|
-
* Get statistics about session tracking
|
|
1398
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
1399
|
-
* @returns {Object} Statistics object
|
|
1400
|
-
*/
|
|
1401
|
-
export function getSessionStats(verbose = false) {
|
|
1402
|
-
const sessions = Array.from(activeSessions.values());
|
|
1403
|
-
const isolated = sessions.filter(s => s.isolationBackend);
|
|
1404
|
-
if (verbose) {
|
|
1405
|
-
console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
|
|
1406
|
-
}
|
|
1407
|
-
return {
|
|
1408
|
-
total: activeSessions.size,
|
|
1409
|
-
executing: activeSessions.size,
|
|
1410
|
-
executed: 0,
|
|
1411
|
-
successful: 0,
|
|
1412
|
-
failed: 0,
|
|
1413
|
-
isolated: isolated.length,
|
|
1414
|
-
storageType: 'in-memory',
|
|
1415
|
-
};
|
|
1416
|
-
}
|
|
1146
|
+
// Issue #2175: the read-only registry queries live in their own module so this
|
|
1147
|
+
// file stays under the 1350-line early-warning threshold (see issue #1593).
|
|
1148
|
+
const { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats } = createSessionRegistryQueries({
|
|
1149
|
+
activeSessions,
|
|
1150
|
+
normalizeSessionUrl,
|
|
1151
|
+
isNonIsolationSessionActive,
|
|
1152
|
+
getIsolationSessionState,
|
|
1153
|
+
checkScreenSessionExists,
|
|
1154
|
+
NON_ISOLATION_SESSION_TIMEOUT_MS,
|
|
1155
|
+
});
|
|
1156
|
+
export { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats };
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only queries over the session-monitor registry.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from session-monitor.lib.mjs (issue #2175) so that file stays under
|
|
5
|
+
* the 1350-line early-warning threshold the CI file-headroom check enforces
|
|
6
|
+
* (long files cause concurrent PR merge conflicts — issue #1593).
|
|
7
|
+
*
|
|
8
|
+
* These functions never mutate the registry beyond refreshing the cached
|
|
9
|
+
* last-known status of a session that turned out to have finished, which is
|
|
10
|
+
* exactly what the monitor itself does on the next poll. They are exposed
|
|
11
|
+
* through a factory so the registry Map and the liveness helpers stay private
|
|
12
|
+
* to session-monitor.lib.mjs.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2175
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bind the registry queries to a session-monitor instance.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} deps
|
|
21
|
+
* @param {Map<string, object>} deps.activeSessions in-memory session registry
|
|
22
|
+
* @param {(url: string) => string} deps.normalizeSessionUrl
|
|
23
|
+
* @param {(sessionName: string, sessionInfo: object, verbose?: boolean) => boolean} deps.isNonIsolationSessionActive
|
|
24
|
+
* @param {(sessionName: string, sessionInfo: object, options?: object) => Promise<object>} deps.getIsolationSessionState
|
|
25
|
+
* @param {(sessionName: string) => Promise<boolean>} deps.checkScreenSessionExists
|
|
26
|
+
* @param {number} deps.NON_ISOLATION_SESSION_TIMEOUT_MS
|
|
27
|
+
* @returns {{hasActiveSessionForUrl: Function, findStoppableSessionByUrl: Function, hasActiveSessionForUrlAsync: Function, getRunningTrackedIsolationSessions: Function, getRunningSessionItems: Function, getSessionStats: Function}}
|
|
28
|
+
*/
|
|
29
|
+
export function createSessionRegistryQueries({ activeSessions, normalizeSessionUrl, isNonIsolationSessionActive, getIsolationSessionState, checkScreenSessionExists, NON_ISOLATION_SESSION_TIMEOUT_MS }) {
|
|
30
|
+
/**
|
|
31
|
+
* Issue #1567: Check if there's an active session for a given URL.
|
|
32
|
+
* This prevents concurrent sessions on the same PR/issue, which causes
|
|
33
|
+
* iteration number jumps, duplicate "Ready to merge" comments, and other
|
|
34
|
+
* inconsistencies when two auto-restart-until-mergeable processes run
|
|
35
|
+
* simultaneously.
|
|
36
|
+
*
|
|
37
|
+
* Issue #1586: Non-isolation sessions (plain start-screen) cannot reliably
|
|
38
|
+
* detect completion because the screen stays alive via `exec bash`. To avoid
|
|
39
|
+
* permanent false positives, non-isolation sessions are auto-expired after
|
|
40
|
+
* NON_ISOLATION_SESSION_TIMEOUT_MS (10 minutes). Within that window they
|
|
41
|
+
* still block duplicate commands for the same URL, which prevents accidental
|
|
42
|
+
* re-runs. Isolation-backed sessions have no timeout since their completion
|
|
43
|
+
* is reliably detected by monitorSessions().
|
|
44
|
+
*
|
|
45
|
+
* @param {string} url - The GitHub URL to check (issue or PR URL)
|
|
46
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
47
|
+
* @returns {{isActive: boolean, sessionName: string|null}} Whether an active session exists for this URL
|
|
48
|
+
*/
|
|
49
|
+
function hasActiveSessionForUrl(url, verbose = false) {
|
|
50
|
+
if (!url) return { isActive: false, sessionName: null };
|
|
51
|
+
// Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
|
|
52
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
53
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
54
|
+
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
55
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
|
|
59
|
+
if (verbose) {
|
|
60
|
+
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
|
|
61
|
+
console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
|
|
62
|
+
}
|
|
63
|
+
return { isActive: true, sessionName };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (verbose) {
|
|
67
|
+
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
68
|
+
}
|
|
69
|
+
return { isActive: false, sessionName: null };
|
|
70
|
+
}
|
|
71
|
+
const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
72
|
+
/**
|
|
73
|
+
* Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
|
|
74
|
+
* and report whether it can be stopped by forwarding CTRL+C to the
|
|
75
|
+
* start-command session UUID.
|
|
76
|
+
*
|
|
77
|
+
* The `/stop <url>` Telegram flow originally consulted only the in-memory solve
|
|
78
|
+
* queue. But a `/solve` or `/codex` that starts immediately (queue empty)
|
|
79
|
+
* dispatches straight to a detached isolation session and is removed from the
|
|
80
|
+
* queue's `processing` Map the moment it is launched. From that point on the
|
|
81
|
+
* session-monitor's in-memory registry is the only place that still knows the
|
|
82
|
+
* URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
|
|
83
|
+
* even though the task was clearly running. This helper exposes that registry
|
|
84
|
+
* so the stop flow can recover the UUID and interrupt the session.
|
|
85
|
+
*
|
|
86
|
+
* A session is stoppable when it was launched with an isolation backend and its
|
|
87
|
+
* start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
|
|
88
|
+
* non-isolation screen sessions are reported but marked `stoppable: false`
|
|
89
|
+
* because `$ --stop` cannot interrupt them.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} url - GitHub issue or PR URL (any normalization)
|
|
92
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
93
|
+
* @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
|
|
94
|
+
* isolationBackend: string|null, stoppable: boolean }|null} Match or null
|
|
95
|
+
*/
|
|
96
|
+
function findStoppableSessionByUrl(url, verbose = false) {
|
|
97
|
+
if (!url) return null;
|
|
98
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
99
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
100
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Issue #1586: skip expired non-isolation sessions — they are no longer running.
|
|
104
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// The UUID `$ --stop` expects is the start-command session id. For
|
|
108
|
+
// isolation sessions it is tracked either as sessionInfo.sessionId or as
|
|
109
|
+
// the (UUID-shaped) session key itself.
|
|
110
|
+
const candidateId = sessionInfo.sessionId || sessionName;
|
|
111
|
+
const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
|
|
112
|
+
const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
|
|
113
|
+
if (verbose) {
|
|
114
|
+
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
|
|
115
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
sessionName,
|
|
119
|
+
sessionId,
|
|
120
|
+
sessionInfo,
|
|
121
|
+
isolationBackend: sessionInfo.isolationBackend || null,
|
|
122
|
+
stoppable,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (verbose) {
|
|
126
|
+
console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Async active-session check for command handlers.
|
|
132
|
+
*
|
|
133
|
+
* Isolation-backed sessions are refreshed through `$ --status` before they
|
|
134
|
+
* block a duplicate URL, so completed screen-isolated runs no longer require
|
|
135
|
+
* waiting for the background polling interval.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} url - The GitHub URL to check
|
|
138
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
139
|
+
* @param {Object} [options] - Test/support options
|
|
140
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
141
|
+
* @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
|
|
142
|
+
*/
|
|
143
|
+
async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
|
|
144
|
+
if (!url) return { isActive: false, sessionName: null };
|
|
145
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
146
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
147
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!sessionInfo.isolationBackend) {
|
|
151
|
+
if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
152
|
+
return { isActive: true, sessionName, status: null };
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
157
|
+
verbose,
|
|
158
|
+
statusProvider: options.statusProvider,
|
|
159
|
+
});
|
|
160
|
+
if (state.running) {
|
|
161
|
+
if (verbose) {
|
|
162
|
+
console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
|
|
163
|
+
}
|
|
164
|
+
return { isActive: true, sessionName, status: state.status || null };
|
|
165
|
+
}
|
|
166
|
+
if (verbose) {
|
|
167
|
+
console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
|
|
168
|
+
}
|
|
169
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
170
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
171
|
+
}
|
|
172
|
+
if (verbose) {
|
|
173
|
+
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
174
|
+
}
|
|
175
|
+
return { isActive: false, sessionName: null };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Refresh tracked isolation sessions and count only those that are executing.
|
|
179
|
+
*
|
|
180
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
181
|
+
* @param {Object} [options] - Test/support options
|
|
182
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
183
|
+
* @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
|
|
184
|
+
*/
|
|
185
|
+
async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
|
|
186
|
+
const sessions = [];
|
|
187
|
+
const byTool = {};
|
|
188
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
189
|
+
if (!sessionInfo.isolationBackend) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
193
|
+
verbose,
|
|
194
|
+
statusProvider: options.statusProvider,
|
|
195
|
+
});
|
|
196
|
+
if (!state.running) {
|
|
197
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
198
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const tool = sessionInfo.tool || 'claude';
|
|
202
|
+
sessions.push(sessionName);
|
|
203
|
+
byTool[tool] = (byTool[tool] || 0) + 1;
|
|
204
|
+
}
|
|
205
|
+
return { count: sessions.length, sessions, byTool };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Return the currently-executing tracked sessions with the details needed to
|
|
209
|
+
* render them as a clickable list in `/queue`: the issue/PR
|
|
210
|
+
* `url`, the `tool`, the start time, and (for isolation sessions) the backend
|
|
211
|
+
* status. Both isolation and non-isolation screen sessions are included so the
|
|
212
|
+
* list matches what is actually executing — the queue's own in-memory
|
|
213
|
+
* `processing` Map is empty once a task has been dispatched to a detached
|
|
214
|
+
* session, which is why executing tasks were previously not listed.
|
|
215
|
+
*
|
|
216
|
+
* Liveness is determined the same way as {@link monitorSessions}: isolation
|
|
217
|
+
* sessions via `$ --status`, non-isolation screen sessions via a timeout window
|
|
218
|
+
* plus a best-effort `screen -ls` check.
|
|
219
|
+
*
|
|
220
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
221
|
+
* @param {Object} [options] - Test/support options
|
|
222
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
223
|
+
* @param {Function} [options.screenChecker] - Optional screen-existence checker
|
|
224
|
+
* @returns {Promise<Array<{sessionName: string, url: string|null, tool: string, status: string|null, startTime: (Date|string|number|null), isolationBackend: (string|null)}>>}
|
|
225
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1837
|
|
226
|
+
*/
|
|
227
|
+
async function getRunningSessionItems(verbose = false, options = {}) {
|
|
228
|
+
const items = [];
|
|
229
|
+
const screenChecker = options.screenChecker || checkScreenSessionExists;
|
|
230
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
231
|
+
let running;
|
|
232
|
+
let status = null;
|
|
233
|
+
if (sessionInfo.isolationBackend) {
|
|
234
|
+
// Forward every injectable seam so the listing applies the same #1927
|
|
235
|
+
// stale-`executing` reconciliation the monitor does — a session that
|
|
236
|
+
// start-command still reports as `executing` but whose backend is gone (or
|
|
237
|
+
// whose log footer shows a kill) must not be listed as running — and so the
|
|
238
|
+
// whole path stays controllable from tests.
|
|
239
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
240
|
+
verbose,
|
|
241
|
+
statusProvider: options.statusProvider,
|
|
242
|
+
exitFromLog: options.exitFromLog,
|
|
243
|
+
backendAlive: options.backendAlive,
|
|
244
|
+
sessionRunning: options.sessionRunning,
|
|
245
|
+
});
|
|
246
|
+
running = state.running;
|
|
247
|
+
status = state.status || null;
|
|
248
|
+
if (!running) {
|
|
249
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
250
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
255
|
+
const elapsed = Date.now() - startTime.getTime();
|
|
256
|
+
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
257
|
+
if (verbose) {
|
|
258
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s; excluded from running list`);
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
running = await screenChecker(sessionName);
|
|
263
|
+
if (!running) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
items.push({
|
|
268
|
+
sessionName,
|
|
269
|
+
url: sessionInfo.url || null,
|
|
270
|
+
tool: sessionInfo.tool || 'claude',
|
|
271
|
+
status,
|
|
272
|
+
startTime: sessionInfo.startTime || null,
|
|
273
|
+
isolationBackend: sessionInfo.isolationBackend || null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (verbose) {
|
|
277
|
+
console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
|
|
278
|
+
}
|
|
279
|
+
return items;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Get statistics about session tracking
|
|
283
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
284
|
+
* @returns {Object} Statistics object
|
|
285
|
+
*/
|
|
286
|
+
function getSessionStats(verbose = false) {
|
|
287
|
+
const sessions = Array.from(activeSessions.values());
|
|
288
|
+
const isolated = sessions.filter(s => s.isolationBackend);
|
|
289
|
+
if (verbose) {
|
|
290
|
+
console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
total: activeSessions.size,
|
|
294
|
+
executing: activeSessions.size,
|
|
295
|
+
executed: 0,
|
|
296
|
+
successful: 0,
|
|
297
|
+
failed: 0,
|
|
298
|
+
isolated: isolated.length,
|
|
299
|
+
storageType: 'in-memory',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats };
|
|
304
|
+
}
|