@link-assistant/hive-mind 2.11.13 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/package.json +4 -1
- package/src/agent-command.lib.mjs +74 -0
- package/src/agent.lib.mjs +59 -34
- package/src/agentic-cli-updater.lib.mjs +241 -0
- package/src/claude.connection.lib.mjs +209 -0
- package/src/claude.lib.mjs +6 -202
- package/src/codex.lib.mjs +0 -128
- package/src/formal-ai-isolation.lib.mjs +62 -0
- package/src/formal-ai-maintenance.lib.mjs +106 -0
- package/src/formal-ai-model.lib.mjs +25 -0
- package/src/formal-ai-runtime.lib.mjs +10 -0
- package/src/formal-ai-sidecar.lib.mjs +565 -0
- package/src/formal-ai-updater.lib.mjs +294 -0
- package/src/formal-ai-version.lib.mjs +100 -0
- package/src/formal-ai.lib.mjs +11 -16
- package/src/github-rate-limit.lib.mjs +3 -0
- package/src/github-url-parser.lib.mjs +255 -0
- package/src/github.lib.mjs +22 -343
- package/src/hive.mjs +0 -152
- package/src/interactive-mode.lib.mjs +0 -43
- package/src/isolation-runner.lib.mjs +44 -173
- package/src/limits.lib.mjs +0 -89
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -19
- package/src/session-monitor.lib.mjs +14 -172
- package/src/solve.auto-merge.lib.mjs +70 -164
- package/src/solve.mjs +31 -193
- package/src/solve.repository.lib.mjs +0 -83
- package/src/solve.results.lib.mjs +2 -92
- package/src/solve.session.lib.mjs +52 -19
- package/src/solve.tool-uncommitted.lib.mjs +22 -0
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +17 -65
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-merge-queue.lib.mjs +3 -155
- package/src/telegram-solve-queue.lib.mjs +9 -168
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/use-m-bootstrap.lib.mjs +6 -5
- package/src/use-with-retry.lib.mjs +128 -2
- package/src/working-session-summary.lib.mjs +47 -1
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* @see https://github.com/link-assistant/hive-mind/issues/380
|
|
19
19
|
* @see https://github.com/link-assistant/hive-mind/issues/1927
|
|
20
20
|
*/
|
|
21
|
-
|
|
22
21
|
import { exec as execCallback } from 'child_process';
|
|
23
22
|
import fs from 'fs/promises';
|
|
24
23
|
import { promisify } from 'util';
|
|
@@ -27,20 +26,16 @@ import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.li
|
|
|
27
26
|
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
28
27
|
import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
29
28
|
import { resolveFailedSessionPullRequestState } from './github-pr-state.lib.mjs';
|
|
30
|
-
// Issue #2117: a docker terminal failure that no anchored log footer corroborates
|
|
31
|
-
// may be an exit code start-command fabricated from the command's own output.
|
|
29
|
+
// Issue #2117: a docker terminal failure that no anchored log footer corroborates may be an exit code start-command fabricated from the command's own output.
|
|
32
30
|
import { clearUnverifiedDockerTerminalMarker as clearUnverifiedDockerTerminalMarkerImpl, shouldDeferUnverifiedDockerTerminal as shouldDeferUnverifiedDockerTerminalImpl } from './session-monitor.docker-terminal.lib.mjs';
|
|
33
31
|
import { isDockerIsolation, sessionStartMs, resolveOomKilledState, resolveStaleExecutingState as resolveStaleExecutingStateImpl } from './session-monitor.stale-executing.lib.mjs';
|
|
34
32
|
// Issue #2134: kill-cause diagnostics + the matching pull-request notice.
|
|
35
33
|
import { buildKillCompletionSections, announceKillOnPullRequest } from './session-monitor.kill-sections.lib.mjs';
|
|
36
34
|
import { runKillRecoveryForCompletion } from './session-kill-resume.lib.mjs';
|
|
37
|
-
|
|
38
35
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
39
36
|
export { DOCKER_TERMINAL_FOOTER_GRACE_MS } from './session-monitor.docker-terminal.lib.mjs';
|
|
40
37
|
export { STALE_EXECUTING_MIN_AGE_MS, DOCKER_BACKEND_GONE_GRACE_MS } from './session-monitor.stale-executing.lib.mjs';
|
|
41
|
-
|
|
42
38
|
const exec = promisify(execCallback);
|
|
43
|
-
|
|
44
39
|
// Lazy import for isolation runner (only when needed)
|
|
45
40
|
let _isolationRunner = null;
|
|
46
41
|
async function getIsolationRunner() {
|
|
@@ -51,14 +46,9 @@ async function getIsolationRunner() {
|
|
|
51
46
|
}
|
|
52
47
|
// In-memory session store
|
|
53
48
|
const activeSessions = new Map();
|
|
54
|
-
|
|
55
|
-
// Issue #1927: optional durable mirror of the in-memory registry. When set (by
|
|
56
|
-
// the bot at startup via setSessionStore), every track/complete is persisted so
|
|
57
|
-
// a restart can reload and keep monitoring detached sessions. Left null in unit
|
|
58
|
-
// tests and one-off CLI paths, where in-memory tracking is sufficient.
|
|
49
|
+
// Issue #1927: optional durable mirror of the in-memory registry. When set (by the bot at startup via setSessionStore), every track/complete is persisted so a restart can reload and keep monitoring detached sessions. Left null in unit tests and one-off CLI paths, where in-memory tracking is sufficient.
|
|
59
50
|
let sessionStore = null;
|
|
60
51
|
let sessionLogger = null;
|
|
61
|
-
|
|
62
52
|
/**
|
|
63
53
|
* Attach a durable session store (see session-store.lib.mjs) so tracked sessions
|
|
64
54
|
* survive a bot restart. Passing null disconnects the store (used by tests).
|
|
@@ -67,7 +57,6 @@ let sessionLogger = null;
|
|
|
67
57
|
export function setSessionStore(store) {
|
|
68
58
|
sessionStore = store || null;
|
|
69
59
|
}
|
|
70
|
-
|
|
71
60
|
/**
|
|
72
61
|
* Attach a structured logger (see bot-logger.lib.mjs) so session lifecycle
|
|
73
62
|
* transitions are recorded with timestamps. Optional; console is used otherwise.
|
|
@@ -76,19 +65,16 @@ export function setSessionStore(store) {
|
|
|
76
65
|
export function setSessionLogger(logger) {
|
|
77
66
|
sessionLogger = logger || null;
|
|
78
67
|
}
|
|
79
|
-
|
|
80
68
|
function logEvent(type, data) {
|
|
81
69
|
if (sessionLogger && typeof sessionLogger.event === 'function') {
|
|
82
70
|
sessionLogger.event(type, data);
|
|
83
71
|
}
|
|
84
72
|
}
|
|
85
|
-
|
|
86
73
|
export function resetSessionMonitorForTests() {
|
|
87
74
|
activeSessions.clear();
|
|
88
75
|
sessionStore = null;
|
|
89
76
|
sessionLogger = null;
|
|
90
77
|
}
|
|
91
|
-
|
|
92
78
|
/**
|
|
93
79
|
* Inject a stub isolation runner so tests can drive getIsolationSessionState
|
|
94
80
|
* without spawning real `$ --status` / docker probes. Pass `null` to restore the
|
|
@@ -97,7 +83,6 @@ export function resetSessionMonitorForTests() {
|
|
|
97
83
|
export function __setIsolationRunnerForTests(runner) {
|
|
98
84
|
_isolationRunner = runner;
|
|
99
85
|
}
|
|
100
|
-
|
|
101
86
|
/**
|
|
102
87
|
* Test-only accessor for getIsolationSessionState (otherwise module-private).
|
|
103
88
|
* Used by tests/test-issue-1939-docker-isolation.mjs to verify that an ambiguous
|
|
@@ -106,7 +91,6 @@ export function __setIsolationRunnerForTests(runner) {
|
|
|
106
91
|
export function getIsolationSessionStateForTests(sessionName, sessionInfo, options = {}) {
|
|
107
92
|
return getIsolationSessionState(sessionName, sessionInfo, options);
|
|
108
93
|
}
|
|
109
|
-
|
|
110
94
|
/**
|
|
111
95
|
* Issue #1586: Timeout for non-isolation sessions.
|
|
112
96
|
* Non-isolation (plain start-screen) sessions cannot reliably detect completion
|
|
@@ -121,7 +105,6 @@ export function getIsolationSessionStateForTests(sessionName, sessionInfo, optio
|
|
|
121
105
|
export const NON_ISOLATION_SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
122
106
|
export const DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY = 'on-failure';
|
|
123
107
|
export const DOCKER_TASK_CONTAINER_KEEP_POLICIES = ['always', 'on-failure', 'never'];
|
|
124
|
-
|
|
125
108
|
export function resolveDockerTaskContainerKeepPolicy({ env = process.env, verbose = false } = {}) {
|
|
126
109
|
const raw = String(env?.HIVE_MIND_KEEP_TASK_CONTAINER || '')
|
|
127
110
|
.trim()
|
|
@@ -133,7 +116,6 @@ export function resolveDockerTaskContainerKeepPolicy({ env = process.env, verbos
|
|
|
133
116
|
}
|
|
134
117
|
return DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY;
|
|
135
118
|
}
|
|
136
|
-
|
|
137
119
|
/**
|
|
138
120
|
* Check if a screen session exists
|
|
139
121
|
* @param {string} sessionName - Name of the screen session to check
|
|
@@ -148,7 +130,6 @@ export async function checkScreenSessionExists(sessionName) {
|
|
|
148
130
|
return false;
|
|
149
131
|
}
|
|
150
132
|
}
|
|
151
|
-
|
|
152
133
|
/**
|
|
153
134
|
* Track a new session for completion monitoring
|
|
154
135
|
*
|
|
@@ -169,12 +150,7 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
169
150
|
if (verbose) {
|
|
170
151
|
console.log(`[VERBOSE] Session ${sessionName} tracked in memory (mode: ${mode})`);
|
|
171
152
|
}
|
|
172
|
-
// Issue #1927: mirror to the durable store so a restart can resume monitoring.
|
|
173
|
-
// Only isolation-backed sessions are persisted — they are the ones tracked in
|
|
174
|
-
// `$` (start-command) with a reliable status record (requirement #2). Plain
|
|
175
|
-
// screen sessions are timeout-based best-effort; resuming them after a restart
|
|
176
|
-
// could fabricate a "finished" message with no real exit code, so they stay
|
|
177
|
-
// in-memory only.
|
|
153
|
+
// Issue #1927: mirror to the durable store so a restart can resume monitoring. Only isolation-backed sessions are persisted — they are the ones tracked in `$` (start-command) with a reliable status record (requirement #2). Plain screen sessions are timeout-based best-effort; resuming them after a restart could fabricate a "finished" message with no real exit code, so they stay in-memory only.
|
|
178
154
|
if (sessionStore && isPersistableSession(sessionInfo)) {
|
|
179
155
|
try {
|
|
180
156
|
sessionStore.persist(sessionName, sessionInfo);
|
|
@@ -191,7 +167,6 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
191
167
|
startTime: sessionInfo.startTime instanceof Date ? sessionInfo.startTime.toISOString() : sessionInfo.startTime || null,
|
|
192
168
|
});
|
|
193
169
|
}
|
|
194
|
-
|
|
195
170
|
/**
|
|
196
171
|
* Whether a session should be mirrored to the durable store. Only isolation
|
|
197
172
|
* sessions with a start-command UUID qualify (see trackSession rationale).
|
|
@@ -201,7 +176,6 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
|
|
|
201
176
|
function isPersistableSession(sessionInfo) {
|
|
202
177
|
return Boolean(sessionInfo?.isolationBackend && sessionInfo?.sessionId);
|
|
203
178
|
}
|
|
204
|
-
|
|
205
179
|
function persistSessionSnapshot(sessionName, sessionInfo) {
|
|
206
180
|
if (!sessionStore || !isPersistableSession(sessionInfo)) return;
|
|
207
181
|
try {
|
|
@@ -210,7 +184,6 @@ function persistSessionSnapshot(sessionName, sessionInfo) {
|
|
|
210
184
|
/* best effort — persistence must never break monitoring */
|
|
211
185
|
}
|
|
212
186
|
}
|
|
213
|
-
|
|
214
187
|
/**
|
|
215
188
|
* Look up the in-memory record for a session id (UUID for isolation sessions
|
|
216
189
|
* or the screen session name for non-isolation sessions). Returns null when no
|
|
@@ -225,7 +198,6 @@ export function getTrackedSessionInfo(sessionName) {
|
|
|
225
198
|
if (!sessionName) return null;
|
|
226
199
|
return activeSessions.get(sessionName) || null;
|
|
227
200
|
}
|
|
228
|
-
|
|
229
201
|
/**
|
|
230
202
|
* Issue #2052: record that an operator explicitly requested a session stop
|
|
231
203
|
* (e.g. Telegram `/stop <uuid>`). The subsequent SIGTERM/SIGKILL exit (143/137,
|
|
@@ -252,7 +224,6 @@ export function markSessionStopRequested(sessionId, { requestedBy = null, verbos
|
|
|
252
224
|
logEvent('session_stop_requested', { sessionName: key, sessionId, requestedBy: requestedBy || null });
|
|
253
225
|
return true;
|
|
254
226
|
}
|
|
255
|
-
|
|
256
227
|
/**
|
|
257
228
|
* Stop tracking a session that was registered optimistically but never actually
|
|
258
229
|
* started (e.g. the start-command launch failed). Removes it from the in-memory
|
|
@@ -278,7 +249,6 @@ export function untrackSession(sessionName, verbose = false) {
|
|
|
278
249
|
}
|
|
279
250
|
logEvent('session_untracked', { sessionName });
|
|
280
251
|
}
|
|
281
|
-
|
|
282
252
|
/**
|
|
283
253
|
* Get the number of active sessions being tracked
|
|
284
254
|
* @param {boolean} verbose - Whether to log verbose output
|
|
@@ -290,7 +260,6 @@ export function getActiveSessionCount(verbose = false) {
|
|
|
290
260
|
}
|
|
291
261
|
return activeSessions.size;
|
|
292
262
|
}
|
|
293
|
-
|
|
294
263
|
/**
|
|
295
264
|
* Get all active sessions
|
|
296
265
|
* @param {boolean} verbose - Whether to log verbose output
|
|
@@ -306,7 +275,6 @@ function getActiveSessions(verbose = false) {
|
|
|
306
275
|
}
|
|
307
276
|
return sessions;
|
|
308
277
|
}
|
|
309
|
-
|
|
310
278
|
/**
|
|
311
279
|
* Remove a session from tracking
|
|
312
280
|
* @param {string} sessionName - Name of the session to remove
|
|
@@ -318,8 +286,7 @@ function completeSession(sessionName, exitCode = 0, verbose = false, status = nu
|
|
|
318
286
|
if (verbose) {
|
|
319
287
|
console.log(`[VERBOSE] Session ${sessionName} removed from tracking (exit: ${exitCode}${status ? `, status: ${status}` : ''})`);
|
|
320
288
|
}
|
|
321
|
-
// Issue #1927: drop from the durable snapshot (and append a `complete` audit
|
|
322
|
-
// event recording how it ended) so a later restart does not try to resume it.
|
|
289
|
+
// Issue #1927: drop from the durable snapshot (and append a `complete` audit event recording how it ended) so a later restart does not try to resume it.
|
|
323
290
|
if (sessionStore && isPersistableSession(sessionInfo)) {
|
|
324
291
|
try {
|
|
325
292
|
sessionStore.remove(sessionName, { status, exitCode });
|
|
@@ -329,30 +296,21 @@ function completeSession(sessionName, exitCode = 0, verbose = false, status = nu
|
|
|
329
296
|
}
|
|
330
297
|
logEvent('session_completed', { sessionName, exitCode: exitCode ?? null, status: status || null });
|
|
331
298
|
}
|
|
332
|
-
|
|
333
299
|
function isMessageAlreadyUpdatedError(error) {
|
|
334
300
|
const message = String(error?.message || '').toLowerCase();
|
|
335
301
|
return message.includes('message is not modified');
|
|
336
302
|
}
|
|
337
|
-
|
|
338
303
|
function normalizeSessionUrl(url) {
|
|
339
|
-
// Strip the fragment first, then any trailing slashes, so URLs that carry a
|
|
340
|
-
// fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to
|
|
341
|
-
// the same value as the bare `.../issues/18`. Doing it in the other order
|
|
342
|
-
// would leave a dangling trailing slash. (Issue #1871.)
|
|
304
|
+
// Strip the fragment first, then any trailing slashes, so URLs that carry a fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to the same value as the bare `.../issues/18`. Doing it in the other order would leave a dangling trailing slash. (Issue #1871.)
|
|
343
305
|
return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
|
|
344
306
|
}
|
|
345
|
-
|
|
346
307
|
const GITHUB_PULL_REQUEST_URL_RE = /https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([0-9]+)/g;
|
|
347
|
-
|
|
348
308
|
export function extractPullRequestUrlFromText(text, { owner = null, repo = null } = {}) {
|
|
349
309
|
if (!text) return null;
|
|
350
|
-
|
|
351
310
|
const expectedOwner = owner ? String(owner).toLowerCase() : null;
|
|
352
311
|
const expectedRepo = repo ? String(repo).toLowerCase() : null;
|
|
353
312
|
const value = String(text);
|
|
354
313
|
GITHUB_PULL_REQUEST_URL_RE.lastIndex = 0;
|
|
355
|
-
|
|
356
314
|
let match;
|
|
357
315
|
while ((match = GITHUB_PULL_REQUEST_URL_RE.exec(value)) !== null) {
|
|
358
316
|
const [, matchOwner, matchRepo, pullNumber] = match;
|
|
@@ -360,13 +318,10 @@ export function extractPullRequestUrlFromText(text, { owner = null, repo = null
|
|
|
360
318
|
if (expectedRepo && matchRepo.toLowerCase() !== expectedRepo) continue;
|
|
361
319
|
return `https://github.com/${matchOwner}/${matchRepo}/pull/${pullNumber}`;
|
|
362
320
|
}
|
|
363
|
-
|
|
364
321
|
return null;
|
|
365
322
|
}
|
|
366
|
-
|
|
367
323
|
async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = false, readFile = fs.readFile } = {}) {
|
|
368
324
|
if (!logPath) return null;
|
|
369
|
-
|
|
370
325
|
try {
|
|
371
326
|
const logText = await readFile(logPath, 'utf8');
|
|
372
327
|
const pullRequestUrl = extractPullRequestUrlFromText(logText, { owner: ctx.owner, repo: ctx.repo });
|
|
@@ -381,7 +336,6 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
|
|
|
381
336
|
return null;
|
|
382
337
|
}
|
|
383
338
|
}
|
|
384
|
-
|
|
385
339
|
/**
|
|
386
340
|
* Issue #1945/#1988: Parse `📊 [DISK]` repository-size checkpoint markers out
|
|
387
341
|
* of the captured solve log, optionally add docker writable-layer sizes, and
|
|
@@ -416,7 +370,6 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
|
|
|
416
370
|
return '';
|
|
417
371
|
}
|
|
418
372
|
}
|
|
419
|
-
|
|
420
373
|
async function getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
421
374
|
if (sessionInfo?.isolationBackend !== 'docker') return null;
|
|
422
375
|
const containerName = sessionInfo.sessionId || sessionName;
|
|
@@ -436,7 +389,6 @@ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionIn
|
|
|
436
389
|
return null;
|
|
437
390
|
}
|
|
438
391
|
}
|
|
439
|
-
|
|
440
392
|
async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
441
393
|
const bytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose, sizeProvider });
|
|
442
394
|
if (!Number.isFinite(bytes)) return null;
|
|
@@ -445,40 +397,32 @@ async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessi
|
|
|
445
397
|
persistSessionSnapshot(sessionName, sessionInfo);
|
|
446
398
|
return bytes;
|
|
447
399
|
}
|
|
448
|
-
|
|
449
400
|
function getLastKnownDockerContainerFilesystemSize(sessionInfo) {
|
|
450
401
|
return Number.isFinite(sessionInfo?.containerFilesystemLastBytes) ? sessionInfo.containerFilesystemLastBytes : null;
|
|
451
402
|
}
|
|
452
|
-
|
|
453
403
|
function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
|
|
454
404
|
const outcome = classifySessionOutcome({ exitCode, status });
|
|
455
405
|
if (outcome.failed) return false;
|
|
456
406
|
if (exitCode === 0) return true;
|
|
457
|
-
|
|
458
407
|
const normalizedStatus = String(status || '')
|
|
459
408
|
.trim()
|
|
460
409
|
.toLowerCase();
|
|
461
410
|
return exitCode === null && (normalizedStatus === 'executed' || normalizedStatus === 'completed');
|
|
462
411
|
}
|
|
463
|
-
|
|
464
412
|
function formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) {
|
|
465
413
|
return ['*Docker container kept*', `Container: \`${containerName}\``, `Policy: \`HIVE_MIND_KEEP_TASK_CONTAINER=${keepPolicy}\``, `Inspect: \`docker start -ai ${containerName}\``, `Shell: \`docker exec -it ${containerName} sh\``, `Remove when done: \`docker rm -f ${containerName}\``].join('\n');
|
|
466
414
|
}
|
|
467
|
-
|
|
468
415
|
export function buildDockerTaskContainerCompletionAction({ sessionName, sessionInfo, exitCode = null, status = null, env = process.env, verbose = false } = {}) {
|
|
469
416
|
if (sessionInfo?.isolationBackend !== 'docker') {
|
|
470
417
|
return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
|
|
471
418
|
}
|
|
472
|
-
|
|
473
419
|
const containerName = sessionInfo.sessionId || sessionName || null;
|
|
474
420
|
if (!containerName) {
|
|
475
421
|
return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
|
|
476
422
|
}
|
|
477
|
-
|
|
478
423
|
const keepPolicy = resolveDockerTaskContainerKeepPolicy({ env, verbose });
|
|
479
424
|
const successful = isSuccessfulTaskCompletion({ exitCode, status });
|
|
480
425
|
const shouldKeep = keepPolicy === 'always' || (keepPolicy === 'on-failure' && !successful);
|
|
481
|
-
|
|
482
426
|
return {
|
|
483
427
|
applies: true,
|
|
484
428
|
containerName,
|
|
@@ -488,10 +432,8 @@ export function buildDockerTaskContainerCompletionAction({ sessionName, sessionI
|
|
|
488
432
|
extraSection: shouldKeep ? formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) : '',
|
|
489
433
|
};
|
|
490
434
|
}
|
|
491
|
-
|
|
492
435
|
async function applyDockerTaskContainerCompletionAction(action, { verbose = false, removeDockerContainer = null } = {}) {
|
|
493
436
|
if (!action?.applies || !action.shouldRemove || !action.containerName) return;
|
|
494
|
-
|
|
495
437
|
try {
|
|
496
438
|
const removeFn =
|
|
497
439
|
removeDockerContainer ||
|
|
@@ -513,7 +455,6 @@ async function applyDockerTaskContainerCompletionAction(action, { verbose = fals
|
|
|
513
455
|
}
|
|
514
456
|
}
|
|
515
457
|
}
|
|
516
|
-
|
|
517
458
|
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
518
459
|
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
519
460
|
const elapsed = Date.now() - startTime.getTime();
|
|
@@ -530,32 +471,24 @@ function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false)
|
|
|
530
471
|
}
|
|
531
472
|
return true;
|
|
532
473
|
}
|
|
533
|
-
|
|
534
474
|
function clearUnverifiedDockerTerminalMarker(sessionName, sessionInfo) {
|
|
535
475
|
clearUnverifiedDockerTerminalMarkerImpl(sessionInfo, () => persistSessionSnapshot(sessionName, sessionInfo));
|
|
536
476
|
}
|
|
537
|
-
|
|
538
477
|
function shouldDeferUnverifiedDockerTerminal(sessionName, sessionInfo, { exitCode, endTime, verbose }) {
|
|
539
478
|
return shouldDeferUnverifiedDockerTerminalImpl(sessionName, sessionInfo, { exitCode, endTime, verbose, persistSnapshot: () => persistSessionSnapshot(sessionName, sessionInfo) });
|
|
540
479
|
}
|
|
541
|
-
|
|
542
480
|
function resolveStaleExecutingState(sessionName, sessionInfo, statusResult, options) {
|
|
543
481
|
return resolveStaleExecutingStateImpl(sessionName, sessionInfo, statusResult, { ...options, persistSnapshot: () => persistSessionSnapshot(sessionName, sessionInfo) });
|
|
544
482
|
}
|
|
545
|
-
|
|
546
483
|
async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
|
|
547
484
|
const { verbose = false, statusProvider = null, exitFromLog = null, backendAlive = null, sessionRunning = null } = options;
|
|
548
485
|
const sessionId = sessionInfo.sessionId || sessionName;
|
|
549
|
-
|
|
550
486
|
try {
|
|
551
487
|
const runner = await getIsolationRunner();
|
|
552
488
|
const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
|
|
553
|
-
|
|
554
489
|
if (statusResult?.exists && statusResult.status) {
|
|
555
490
|
if (statusResult.oomKilled === true) {
|
|
556
|
-
// Issue #2134: `oomKilled` is a *container* flag — the kernel sets it when
|
|
557
|
-
// any process in the cgroup is OOM-killed — so it is verified against the
|
|
558
|
-
// log footer and container liveness before a kill is announced.
|
|
491
|
+
// Issue #2134: `oomKilled` is a *container* flag — the kernel sets it when any process in the cgroup is OOM-killed — so it is verified against the log footer and container liveness before a kill is announced.
|
|
559
492
|
return await resolveOomKilledState(sessionName, sessionInfo, statusResult, {
|
|
560
493
|
verbose,
|
|
561
494
|
runner,
|
|
@@ -565,34 +498,25 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
565
498
|
});
|
|
566
499
|
}
|
|
567
500
|
if (runner.isExecutingSessionStatus(statusResult.status)) {
|
|
568
|
-
// Issue #1927: an `executing` status is not trusted blindly — verify the
|
|
569
|
-
// process is really alive. start-command can keep reporting `executing`
|
|
570
|
-
// after a kill, which is exactly how an OOM-killed /solve went unreported.
|
|
501
|
+
// Issue #1927: an `executing` status is not trusted blindly — verify the process is really alive. start-command can keep reporting `executing` after a kill, which is exactly how an OOM-killed /solve went unreported.
|
|
571
502
|
const stale = await resolveStaleExecutingState(sessionName, sessionInfo, statusResult, { verbose, runner, exitFromLog, backendAlive });
|
|
572
503
|
if (stale) {
|
|
573
504
|
if (verbose) {
|
|
574
505
|
console.log(`[VERBOSE] Session ${sessionName} reported '${statusResult.status}' but is actually terminated (${stale.reason}); treating as ${stale.status} (exit ${stale.exitCode})`);
|
|
575
506
|
}
|
|
576
|
-
// Rewrite the status payload so downstream completion formatting sees
|
|
577
|
-
// the real terminal status/exit code instead of the stale `executing`.
|
|
507
|
+
// Rewrite the status payload so downstream completion formatting sees the real terminal status/exit code instead of the stale `executing`.
|
|
578
508
|
const correctedStatus = stale.status || 'killed';
|
|
579
509
|
const corrected = { ...statusResult, status: correctedStatus, exitCode: stale.exitCode, endTime: statusResult.endTime || stale.endTime || null };
|
|
580
510
|
return { running: false, exitCode: stale.exitCode, status: correctedStatus, statusResult: corrected, stale: true };
|
|
581
511
|
}
|
|
582
|
-
// Back to a plain `executing` report: any earlier unverified terminal
|
|
583
|
-
// failure was provisional and is now moot (issue #2117).
|
|
512
|
+
// Back to a plain `executing` report: any earlier unverified terminal failure was provisional and is now moot (issue #2117).
|
|
584
513
|
clearUnverifiedDockerTerminalMarker(sessionName, sessionInfo);
|
|
585
514
|
return { running: true, exitCode: null, status: statusResult.status, statusResult };
|
|
586
515
|
}
|
|
587
516
|
if (runner.isTerminalSessionStatus(statusResult.status)) {
|
|
588
517
|
const exitCode = statusResult.exitCode !== undefined ? statusResult.exitCode : null;
|
|
589
518
|
const logPath = statusResult.logPath || sessionInfo?.logPath || null;
|
|
590
|
-
// The log FOOTER is the authoritative terminal result. It is anchored on
|
|
591
|
-
// the `=====` separator (see parseSessionExitFooter), so — unlike the
|
|
592
|
-
// exit code `$ --status` derives from an unanchored full-log scan — it
|
|
593
|
-
// cannot be forged by output the wrapped command printed (issue #2117).
|
|
594
|
-
// Prefer it whenever it exists: that both recovers a real code from a
|
|
595
|
-
// missing/sentinel status (issue #1927) and overrides a fabricated one.
|
|
519
|
+
// The log FOOTER is the authoritative terminal result. It is anchored on the `=====` separator (see parseSessionExitFooter), so — unlike the exit code `$ --status` derives from an unanchored full-log scan — it cannot be forged by output the wrapped command printed (issue #2117). Prefer it whenever it exists: that both recovers a real code from a missing/sentinel status (issue #1927) and overrides a fabricated one.
|
|
596
520
|
const readFooter = exitFromLog || runner.readSessionExitFromLog;
|
|
597
521
|
const footer = logPath && readFooter ? readFooter(logPath, { verbose }) : null;
|
|
598
522
|
if (footer?.finished) {
|
|
@@ -604,29 +528,16 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
604
528
|
clearUnverifiedDockerTerminalMarker(sessionName, sessionInfo);
|
|
605
529
|
return { running: false, exitCode: footerExitCode, status: correctedStatus, statusResult: { ...statusResult, status: correctedStatus, exitCode: footerExitCode } };
|
|
606
530
|
}
|
|
607
|
-
// Issue #1939: a native docker session can report a terminal status
|
|
608
|
-
// ("executed") with the unknown exit-code sentinel (-1) while the
|
|
609
|
-
// container is still running. When the log footer above did not recover
|
|
610
|
-
// a real terminal exit, such a status is provisional — fall through to
|
|
611
|
-
// isSessionRunning() below, which cross-checks the live container via
|
|
612
|
-
// `docker inspect` before we notify the user the work finished.
|
|
531
|
+
// Issue #1939: a native docker session can report a terminal status ("executed") with the unknown exit-code sentinel (-1) while the container is still running. When the log footer above did not recover a real terminal exit, such a status is provisional — fall through to isSessionRunning() below, which cross-checks the live container via `docker inspect` before we notify the user the work finished.
|
|
613
532
|
const dockerSession = isDockerIsolation(sessionInfo, statusResult);
|
|
614
533
|
const ambiguousDockerTerminal = dockerSession && typeof runner.isUnknownDockerExitCode === 'function' && runner.isUnknownDockerExitCode(exitCode);
|
|
615
|
-
// Issue #2117: a docker terminal FAILURE with no corroborating footer is
|
|
616
|
-
// provisional too — start-command can fabricate that exit code from the
|
|
617
|
-
// command's own output. Give the real footer a moment to appear instead
|
|
618
|
-
// of announcing a failure the run never had. Only a *freshly* reported
|
|
619
|
-
// end time can still be in that race, so an older terminal record is
|
|
620
|
-
// still reported without delay.
|
|
534
|
+
// Issue #2117: a docker terminal FAILURE with no corroborating footer is provisional too — start-command can fabricate that exit code from the command's own output. Give the real footer a moment to appear instead of announcing a failure the run never had. Only a *freshly* reported end time can still be in that race, so an older terminal record is still reported without delay.
|
|
621
535
|
const normalizedExitCode = normalizeExitCode(exitCode);
|
|
622
536
|
const unverifiedDockerFailure = dockerSession && !ambiguousDockerTerminal && normalizedExitCode !== null && normalizedExitCode !== 0;
|
|
623
537
|
if (unverifiedDockerFailure && shouldDeferUnverifiedDockerTerminal(sessionName, sessionInfo, { exitCode, endTime: statusResult.endTime || null, verbose })) {
|
|
624
538
|
return { running: true, exitCode: null, status: statusResult.status, statusResult, deferred: true };
|
|
625
539
|
}
|
|
626
|
-
// Issue #2134: even after the grace window, a container that is verifiably
|
|
627
|
-
// still alive cannot have produced a terminal failure — the same liveness
|
|
628
|
-
// ladder used for `oomKilled` applies here, so no kill is announced while
|
|
629
|
-
// the working session keeps running (that is exactly what #2134 reported).
|
|
540
|
+
// Issue #2134: even after the grace window, a container that is verifiably still alive cannot have produced a terminal failure — the same liveness ladder used for `oomKilled` applies here, so no kill is announced while the working session keeps running (that is exactly what #2134 reported).
|
|
630
541
|
if (unverifiedDockerFailure) {
|
|
631
542
|
const probe = backendAlive || runner.checkBackendSessionAlive;
|
|
632
543
|
let alive = null;
|
|
@@ -650,9 +561,7 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
650
561
|
}
|
|
651
562
|
}
|
|
652
563
|
}
|
|
653
|
-
|
|
654
|
-
// The status record is unavailable (no `exists`/`status`). Fall back to a
|
|
655
|
-
// direct backend liveness check. `sessionRunning` is injectable purely so
|
|
564
|
+
// The status record is unavailable (no `exists`/`status`). Fall back to a direct backend liveness check. `sessionRunning` is injectable purely so
|
|
656
565
|
// this path is testable without the real `$`/`screen` binaries; production
|
|
657
566
|
// always uses the runner's real check.
|
|
658
567
|
const checkRunning = sessionRunning || runner.isSessionRunning;
|
|
@@ -692,7 +601,6 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
692
601
|
return { running: false, exitCode: null, status: null, statusResult: null };
|
|
693
602
|
}
|
|
694
603
|
}
|
|
695
|
-
|
|
696
604
|
/**
|
|
697
605
|
* Monitor active sessions and send notifications when they complete
|
|
698
606
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
|
@@ -700,22 +608,18 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
700
608
|
*/
|
|
701
609
|
export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
702
610
|
const sessions = getActiveSessions(verbose);
|
|
703
|
-
|
|
704
611
|
if (sessions.length === 0) {
|
|
705
612
|
return;
|
|
706
613
|
}
|
|
707
|
-
|
|
708
614
|
if (verbose) {
|
|
709
615
|
console.log(`[VERBOSE] Checking ${sessions.length} active session(s)...`);
|
|
710
616
|
}
|
|
711
|
-
|
|
712
617
|
for (const { sessionName, sessionInfo } of sessions) {
|
|
713
618
|
let stillRunning;
|
|
714
619
|
let exitCode = null;
|
|
715
620
|
let statusResult = null;
|
|
716
621
|
let resolvedStatus = null;
|
|
717
622
|
let observedContainerFilesystemBytes = null;
|
|
718
|
-
|
|
719
623
|
if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
|
|
720
624
|
// Isolation mode: use $ --status, with screen -ls only as a fallback
|
|
721
625
|
// when the status record is unavailable. Terminal $ statuses are
|
|
@@ -761,17 +665,14 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
761
665
|
}
|
|
762
666
|
}
|
|
763
667
|
}
|
|
764
|
-
|
|
765
668
|
if (sessionInfo?.isolationBackend === 'docker') {
|
|
766
669
|
observedContainerFilesystemBytes = await refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
|
|
767
670
|
verbose,
|
|
768
671
|
sizeProvider: options.dockerContainerSizeProvider,
|
|
769
672
|
});
|
|
770
673
|
}
|
|
771
|
-
|
|
772
674
|
if (!stillRunning) {
|
|
773
675
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
774
|
-
|
|
775
676
|
let dockerTaskContainerAction = null;
|
|
776
677
|
try {
|
|
777
678
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
@@ -783,7 +684,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
783
684
|
env: options.env || process.env,
|
|
784
685
|
verbose,
|
|
785
686
|
});
|
|
786
|
-
|
|
787
687
|
// Issue #1688/#1905: Resolve the created PR from GitHub or, when its
|
|
788
688
|
// linked-issue API lags, from the completed solve log.
|
|
789
689
|
let pullRequestUrl = null;
|
|
@@ -799,7 +699,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
799
699
|
console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
|
|
800
700
|
}
|
|
801
701
|
}
|
|
802
|
-
|
|
803
702
|
let pullRequestState = null;
|
|
804
703
|
const completionOutcome = classifySessionOutcome({ exitCode: finalExitCode, status: resolvedStatus });
|
|
805
704
|
try {
|
|
@@ -816,7 +715,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
816
715
|
} catch (stateError) {
|
|
817
716
|
if (verbose) console.log(`[VERBOSE] Pull request state resolution failed for ${sessionName}: ${stateError?.message || stateError}`);
|
|
818
717
|
}
|
|
819
|
-
|
|
820
718
|
// Issue #594: append an end-of-task limits snapshot/delta. Cached
|
|
821
719
|
// helpers prevent parallel sessions from stampeding the upstream API.
|
|
822
720
|
const limitsExtraSections = [];
|
|
@@ -846,7 +744,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
846
744
|
}
|
|
847
745
|
}
|
|
848
746
|
}
|
|
849
|
-
|
|
850
747
|
// Issue #1927: for a killed /solve, offer a command using the last tool
|
|
851
748
|
// session ID in the log. Do not auto-relaunch work that may reliably OOM.
|
|
852
749
|
const resumeExtraSections = [];
|
|
@@ -878,7 +775,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
878
775
|
console.log(`[VERBOSE] Could not build resume section for ${sessionName}: ${resumeError?.message || resumeError}`);
|
|
879
776
|
}
|
|
880
777
|
}
|
|
881
|
-
|
|
882
778
|
// Issue #1945/#1988: append a "💾 Disk usage" block from repository
|
|
883
779
|
// size markers and, for docker isolation, the container writable layer.
|
|
884
780
|
const diskExtraSections = [];
|
|
@@ -899,7 +795,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
899
795
|
}
|
|
900
796
|
}
|
|
901
797
|
const dockerTaskContainerExtraSections = dockerTaskContainerAction?.extraSection ? [dockerTaskContainerAction.extraSection] : [];
|
|
902
|
-
|
|
903
798
|
// Issue #2134: say exactly WHY a session was killed, and warn when a
|
|
904
799
|
// session merely survived a kill event instead of reporting a plain
|
|
905
800
|
// success. The pull request gets the very same report below.
|
|
@@ -913,7 +808,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
913
808
|
readFile: options.readFile,
|
|
914
809
|
env: options.env || process.env,
|
|
915
810
|
});
|
|
916
|
-
|
|
917
811
|
// Issue #2134: `--on-session-kill=resume` must actually start a new
|
|
918
812
|
// working session, and both surfaces must say so. Done before the
|
|
919
813
|
// message is built so the Telegram report and the pull-request notice
|
|
@@ -935,7 +829,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
935
829
|
killRecovery = recovered.recovery;
|
|
936
830
|
if (recovered.section) killReport.sections.push(recovered.section);
|
|
937
831
|
}
|
|
938
|
-
|
|
939
832
|
const message = formatSessionCompletionMessage({
|
|
940
833
|
sessionName,
|
|
941
834
|
sessionInfo,
|
|
@@ -947,7 +840,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
947
840
|
pullRequestState,
|
|
948
841
|
extraSections: [...limitsExtraSections, ...killReport.sections, ...resumeExtraSections, ...diskExtraSections, ...dockerTaskContainerExtraSections],
|
|
949
842
|
});
|
|
950
|
-
|
|
951
843
|
if (killReport.killed || killReport.recovered) {
|
|
952
844
|
const notice = await announceKillOnPullRequest({
|
|
953
845
|
pullRequestUrl,
|
|
@@ -971,7 +863,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
971
863
|
console.log(`[VERBOSE] Killed-session notice not posted for ${sessionName}: ${notice.skipped || 'unknown reason'}`);
|
|
972
864
|
}
|
|
973
865
|
}
|
|
974
|
-
|
|
975
866
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
976
867
|
let notifyFromChatId = null;
|
|
977
868
|
let notifyMessageId = null;
|
|
@@ -984,7 +875,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
984
875
|
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
985
876
|
notifyMessageId = sent?.message_id || null;
|
|
986
877
|
}
|
|
987
|
-
|
|
988
878
|
// Issue #1688: forward the same completion message to every /subscribe-d user
|
|
989
879
|
// in their private chat with the bot. Failures are logged but don't block
|
|
990
880
|
// completion of the parent session.
|
|
@@ -1008,7 +898,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
1008
898
|
console.error(`[session-monitor] notifySubscribers failed for ${sessionName}:`, notifyError);
|
|
1009
899
|
}
|
|
1010
900
|
}
|
|
1011
|
-
|
|
1012
901
|
await applyDockerTaskContainerCompletionAction(dockerTaskContainerAction, {
|
|
1013
902
|
verbose,
|
|
1014
903
|
removeDockerContainer: options.removeDockerContainer,
|
|
@@ -1034,7 +923,6 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
1034
923
|
}
|
|
1035
924
|
}
|
|
1036
925
|
}
|
|
1037
|
-
|
|
1038
926
|
/**
|
|
1039
927
|
* Look up the URL of a pull request linked to the issue this session worked on.
|
|
1040
928
|
* Returns null when the session was already operating on a PR, the URL context
|
|
@@ -1059,7 +947,6 @@ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, l
|
|
|
1059
947
|
if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
|
|
1060
948
|
return null;
|
|
1061
949
|
}
|
|
1062
|
-
|
|
1063
950
|
if (typeof lookupLinkedPullRequest === 'function') {
|
|
1064
951
|
const linkedPullRequestUrl = await lookupLinkedPullRequest(ctx);
|
|
1065
952
|
if (linkedPullRequestUrl) return linkedPullRequestUrl;
|
|
@@ -1080,20 +967,16 @@ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, l
|
|
|
1080
967
|
}
|
|
1081
968
|
}
|
|
1082
969
|
}
|
|
1083
|
-
|
|
1084
970
|
const logPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
1085
971
|
const pullRequestUrlFromLog = await resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose, readFile });
|
|
1086
972
|
if (pullRequestUrlFromLog) return pullRequestUrlFromLog;
|
|
1087
|
-
|
|
1088
973
|
if (verbose && logPath) {
|
|
1089
974
|
console.log(`[VERBOSE] No PR URL found for issue ${ctx.owner}/${ctx.repo}#${ctx.number} in session log ${logPath}`);
|
|
1090
975
|
} else if (verbose) {
|
|
1091
976
|
console.log(`[VERBOSE] No session log path available for PR URL fallback for issue ${ctx.owner}/${ctx.repo}#${ctx.number}`);
|
|
1092
977
|
}
|
|
1093
|
-
|
|
1094
978
|
return null;
|
|
1095
979
|
}
|
|
1096
|
-
|
|
1097
980
|
/**
|
|
1098
981
|
* Start the session monitoring interval
|
|
1099
982
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
|
@@ -1113,7 +996,6 @@ export function startSessionMonitoring(bot, verbose = false, intervalMs = 30000,
|
|
|
1113
996
|
console.log(`📊 Session monitoring started (checking every ${intervalMs / 1000} seconds, storage: ${storage})`);
|
|
1114
997
|
return timer;
|
|
1115
998
|
}
|
|
1116
|
-
|
|
1117
999
|
/**
|
|
1118
1000
|
* Issue #1927 (requirements #2 and #4): after a bot restart, reload the sessions
|
|
1119
1001
|
* that were still being tracked when the previous process died and re-register
|
|
@@ -1140,12 +1022,10 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1140
1022
|
const { store = sessionStore, verbose = false, botStartTime = Math.floor(Date.now() / 1000) } = options;
|
|
1141
1023
|
const resumed = [];
|
|
1142
1024
|
const skipped = [];
|
|
1143
|
-
|
|
1144
1025
|
if (!store) {
|
|
1145
1026
|
if (verbose) console.log('[VERBOSE] resumeTrackedSessions: no durable session store configured, nothing to resume');
|
|
1146
1027
|
return { resumed, skipped };
|
|
1147
1028
|
}
|
|
1148
|
-
|
|
1149
1029
|
let persisted;
|
|
1150
1030
|
try {
|
|
1151
1031
|
persisted = store.load();
|
|
@@ -1153,7 +1033,6 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1153
1033
|
console.error(`[session-monitor] resumeTrackedSessions: could not load persisted sessions: ${error.message}`);
|
|
1154
1034
|
return { resumed, skipped };
|
|
1155
1035
|
}
|
|
1156
|
-
|
|
1157
1036
|
for (const { sessionName, sessionInfo } of persisted) {
|
|
1158
1037
|
if (activeSessions.has(sessionName)) {
|
|
1159
1038
|
skipped.push({ sessionName, reason: 'already-tracked' });
|
|
@@ -1167,7 +1046,6 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1167
1046
|
if (verbose) console.log(`[VERBOSE] Skipping resume of ${sessionName}: started after bot start`);
|
|
1168
1047
|
continue;
|
|
1169
1048
|
}
|
|
1170
|
-
|
|
1171
1049
|
activeSessions.set(sessionName, sessionInfo);
|
|
1172
1050
|
resumed.push({ sessionName, sessionInfo });
|
|
1173
1051
|
logEvent('session_resumed', {
|
|
@@ -1181,16 +1059,13 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1181
1059
|
console.log(`[VERBOSE] Resumed tracking of session ${sessionName} (url: ${sessionInfo.url || 'n/a'}, command: ${sessionInfo.command || 'n/a'}, backend: ${sessionInfo.isolationBackend || 'screen'})`);
|
|
1182
1060
|
}
|
|
1183
1061
|
}
|
|
1184
|
-
|
|
1185
1062
|
if (resumed.length > 0) {
|
|
1186
1063
|
console.log(`♻️ Resumed monitoring of ${resumed.length} session(s) from durable store after restart`);
|
|
1187
1064
|
} else if (verbose) {
|
|
1188
1065
|
console.log('[VERBOSE] resumeTrackedSessions: no eligible sessions to resume');
|
|
1189
1066
|
}
|
|
1190
|
-
|
|
1191
1067
|
return { resumed, skipped };
|
|
1192
1068
|
}
|
|
1193
|
-
|
|
1194
1069
|
/**
|
|
1195
1070
|
* Issue #1567: Check if there's an active session for a given URL.
|
|
1196
1071
|
* This prevents concurrent sessions on the same PR/issue, which causes
|
|
@@ -1212,10 +1087,8 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1212
1087
|
*/
|
|
1213
1088
|
export function hasActiveSessionForUrl(url, verbose = false) {
|
|
1214
1089
|
if (!url) return { isActive: false, sessionName: null };
|
|
1215
|
-
|
|
1216
1090
|
// Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
|
|
1217
1091
|
const normalizedUrl = normalizeSessionUrl(url);
|
|
1218
|
-
|
|
1219
1092
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1220
1093
|
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
1221
1094
|
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
@@ -1229,15 +1102,12 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
1229
1102
|
return { isActive: true, sessionName };
|
|
1230
1103
|
}
|
|
1231
1104
|
}
|
|
1232
|
-
|
|
1233
1105
|
if (verbose) {
|
|
1234
1106
|
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
1235
1107
|
}
|
|
1236
1108
|
return { isActive: false, sessionName: null };
|
|
1237
1109
|
}
|
|
1238
|
-
|
|
1239
1110
|
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;
|
|
1240
|
-
|
|
1241
1111
|
/**
|
|
1242
1112
|
* Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
|
|
1243
1113
|
* and report whether it can be stopped by forwarding CTRL+C to the
|
|
@@ -1264,9 +1134,7 @@ const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-
|
|
|
1264
1134
|
*/
|
|
1265
1135
|
export function findStoppableSessionByUrl(url, verbose = false) {
|
|
1266
1136
|
if (!url) return null;
|
|
1267
|
-
|
|
1268
1137
|
const normalizedUrl = normalizeSessionUrl(url);
|
|
1269
|
-
|
|
1270
1138
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1271
1139
|
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
1272
1140
|
continue;
|
|
@@ -1275,19 +1143,16 @@ export function findStoppableSessionByUrl(url, verbose = false) {
|
|
|
1275
1143
|
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
1276
1144
|
continue;
|
|
1277
1145
|
}
|
|
1278
|
-
|
|
1279
1146
|
// The UUID `$ --stop` expects is the start-command session id. For
|
|
1280
1147
|
// isolation sessions it is tracked either as sessionInfo.sessionId or as
|
|
1281
1148
|
// the (UUID-shaped) session key itself.
|
|
1282
1149
|
const candidateId = sessionInfo.sessionId || sessionName;
|
|
1283
1150
|
const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
|
|
1284
1151
|
const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
|
|
1285
|
-
|
|
1286
1152
|
if (verbose) {
|
|
1287
1153
|
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
|
|
1288
1154
|
console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
|
|
1289
1155
|
}
|
|
1290
|
-
|
|
1291
1156
|
return {
|
|
1292
1157
|
sessionName,
|
|
1293
1158
|
sessionId,
|
|
@@ -1296,13 +1161,11 @@ export function findStoppableSessionByUrl(url, verbose = false) {
|
|
|
1296
1161
|
stoppable,
|
|
1297
1162
|
};
|
|
1298
1163
|
}
|
|
1299
|
-
|
|
1300
1164
|
if (verbose) {
|
|
1301
1165
|
console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
|
|
1302
1166
|
}
|
|
1303
1167
|
return null;
|
|
1304
1168
|
}
|
|
1305
|
-
|
|
1306
1169
|
/**
|
|
1307
1170
|
* Async active-session check for command handlers.
|
|
1308
1171
|
*
|
|
@@ -1318,21 +1181,17 @@ export function findStoppableSessionByUrl(url, verbose = false) {
|
|
|
1318
1181
|
*/
|
|
1319
1182
|
export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
|
|
1320
1183
|
if (!url) return { isActive: false, sessionName: null };
|
|
1321
|
-
|
|
1322
1184
|
const normalizedUrl = normalizeSessionUrl(url);
|
|
1323
|
-
|
|
1324
1185
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1325
1186
|
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
1326
1187
|
continue;
|
|
1327
1188
|
}
|
|
1328
|
-
|
|
1329
1189
|
if (!sessionInfo.isolationBackend) {
|
|
1330
1190
|
if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
1331
1191
|
return { isActive: true, sessionName, status: null };
|
|
1332
1192
|
}
|
|
1333
1193
|
continue;
|
|
1334
1194
|
}
|
|
1335
|
-
|
|
1336
1195
|
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
1337
1196
|
verbose,
|
|
1338
1197
|
statusProvider: options.statusProvider,
|
|
@@ -1343,20 +1202,17 @@ export async function hasActiveSessionForUrlAsync(url, verbose = false, options
|
|
|
1343
1202
|
}
|
|
1344
1203
|
return { isActive: true, sessionName, status: state.status || null };
|
|
1345
1204
|
}
|
|
1346
|
-
|
|
1347
1205
|
if (verbose) {
|
|
1348
1206
|
console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
|
|
1349
1207
|
}
|
|
1350
1208
|
sessionInfo.lastKnownStatus = state.status || null;
|
|
1351
1209
|
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
1352
1210
|
}
|
|
1353
|
-
|
|
1354
1211
|
if (verbose) {
|
|
1355
1212
|
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
1356
1213
|
}
|
|
1357
1214
|
return { isActive: false, sessionName: null };
|
|
1358
1215
|
}
|
|
1359
|
-
|
|
1360
1216
|
/**
|
|
1361
1217
|
* Refresh tracked isolation sessions and count only those that are executing.
|
|
1362
1218
|
*
|
|
@@ -1368,31 +1224,25 @@ export async function hasActiveSessionForUrlAsync(url, verbose = false, options
|
|
|
1368
1224
|
export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
|
|
1369
1225
|
const sessions = [];
|
|
1370
1226
|
const byTool = {};
|
|
1371
|
-
|
|
1372
1227
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1373
1228
|
if (!sessionInfo.isolationBackend) {
|
|
1374
1229
|
continue;
|
|
1375
1230
|
}
|
|
1376
|
-
|
|
1377
1231
|
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
1378
1232
|
verbose,
|
|
1379
1233
|
statusProvider: options.statusProvider,
|
|
1380
1234
|
});
|
|
1381
|
-
|
|
1382
1235
|
if (!state.running) {
|
|
1383
1236
|
sessionInfo.lastKnownStatus = state.status || null;
|
|
1384
1237
|
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
1385
1238
|
continue;
|
|
1386
1239
|
}
|
|
1387
|
-
|
|
1388
1240
|
const tool = sessionInfo.tool || 'claude';
|
|
1389
1241
|
sessions.push(sessionName);
|
|
1390
1242
|
byTool[tool] = (byTool[tool] || 0) + 1;
|
|
1391
1243
|
}
|
|
1392
|
-
|
|
1393
1244
|
return { count: sessions.length, sessions, byTool };
|
|
1394
1245
|
}
|
|
1395
|
-
|
|
1396
1246
|
/**
|
|
1397
1247
|
* Return the currently-executing tracked sessions with the details needed to
|
|
1398
1248
|
* render them as a clickable list in `/queue`: the issue/PR
|
|
@@ -1416,11 +1266,9 @@ export async function getRunningTrackedIsolationSessions(verbose = false, option
|
|
|
1416
1266
|
export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
1417
1267
|
const items = [];
|
|
1418
1268
|
const screenChecker = options.screenChecker || checkScreenSessionExists;
|
|
1419
|
-
|
|
1420
1269
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
1421
1270
|
let running;
|
|
1422
1271
|
let status = null;
|
|
1423
|
-
|
|
1424
1272
|
if (sessionInfo.isolationBackend) {
|
|
1425
1273
|
// Forward every injectable seam so the listing applies the same #1927
|
|
1426
1274
|
// stale-`executing` reconciliation the monitor does — a session that
|
|
@@ -1455,7 +1303,6 @@ export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
|
1455
1303
|
continue;
|
|
1456
1304
|
}
|
|
1457
1305
|
}
|
|
1458
|
-
|
|
1459
1306
|
items.push({
|
|
1460
1307
|
sessionName,
|
|
1461
1308
|
url: sessionInfo.url || null,
|
|
@@ -1465,14 +1312,11 @@ export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
|
1465
1312
|
isolationBackend: sessionInfo.isolationBackend || null,
|
|
1466
1313
|
});
|
|
1467
1314
|
}
|
|
1468
|
-
|
|
1469
1315
|
if (verbose) {
|
|
1470
1316
|
console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
|
|
1471
1317
|
}
|
|
1472
|
-
|
|
1473
1318
|
return items;
|
|
1474
1319
|
}
|
|
1475
|
-
|
|
1476
1320
|
/**
|
|
1477
1321
|
* Get statistics about session tracking
|
|
1478
1322
|
* @param {boolean} verbose - Whether to log verbose output
|
|
@@ -1481,11 +1325,9 @@ export async function getRunningSessionItems(verbose = false, options = {}) {
|
|
|
1481
1325
|
export function getSessionStats(verbose = false) {
|
|
1482
1326
|
const sessions = Array.from(activeSessions.values());
|
|
1483
1327
|
const isolated = sessions.filter(s => s.isolationBackend);
|
|
1484
|
-
|
|
1485
1328
|
if (verbose) {
|
|
1486
1329
|
console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
|
|
1487
1330
|
}
|
|
1488
|
-
|
|
1489
1331
|
return {
|
|
1490
1332
|
total: activeSessions.size,
|
|
1491
1333
|
executing: activeSessions.size,
|