@link-assistant/hive-mind 1.74.7 → 1.74.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.8
4
+
5
+ ### Patch Changes
6
+
7
+ - c132ce0: Fix `/stop <issue-or-pr-url>` so it can stop tasks that started immediately
8
+ (empty queue) or were already dispatched to a detached isolation session. The
9
+ URL lookup now also consults the session-monitor registry and forwards CTRL+C
10
+ to the tracked start-command UUID, so all three stop modes (issue URL, PR URL,
11
+ and session UUID) work end-to-end (#1871).
12
+
3
13
  ## 1.74.7
4
14
 
5
15
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.7",
3
+ "version": "1.74.8",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -150,7 +150,11 @@ function isMessageAlreadyUpdatedError(error) {
150
150
  }
151
151
 
152
152
  function normalizeSessionUrl(url) {
153
- return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
153
+ // Strip the fragment first, then any trailing slashes, so URLs that carry a
154
+ // fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to
155
+ // the same value as the bare `.../issues/18`. Doing it in the other order
156
+ // would leave a dangling trailing slash. (Issue #1871.)
157
+ return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
154
158
  }
155
159
 
156
160
  function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
@@ -488,6 +492,73 @@ export function hasActiveSessionForUrl(url, verbose = false) {
488
492
  return { isActive: false, sessionName: null };
489
493
  }
490
494
 
495
+ 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;
496
+
497
+ /**
498
+ * Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
499
+ * and report whether it can be stopped by forwarding CTRL+C to the
500
+ * start-command session UUID.
501
+ *
502
+ * The `/stop <url>` Telegram flow originally consulted only the in-memory solve
503
+ * queue. But a `/solve` or `/codex` that starts immediately (queue empty)
504
+ * dispatches straight to a detached isolation session and is removed from the
505
+ * queue's `processing` Map the moment it is launched. From that point on the
506
+ * session-monitor's in-memory registry is the only place that still knows the
507
+ * URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
508
+ * even though the task was clearly running. This helper exposes that registry
509
+ * so the stop flow can recover the UUID and interrupt the session.
510
+ *
511
+ * A session is stoppable when it was launched with an isolation backend and its
512
+ * start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
513
+ * non-isolation screen sessions are reported but marked `stoppable: false`
514
+ * because `$ --stop` cannot interrupt them.
515
+ *
516
+ * @param {string} url - GitHub issue or PR URL (any normalization)
517
+ * @param {boolean} verbose - Whether to log verbose output
518
+ * @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
519
+ * isolationBackend: string|null, stoppable: boolean }|null} Match or null
520
+ */
521
+ export function findStoppableSessionByUrl(url, verbose = false) {
522
+ if (!url) return null;
523
+
524
+ const normalizedUrl = normalizeSessionUrl(url);
525
+
526
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
527
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
528
+ continue;
529
+ }
530
+ // Issue #1586: skip expired non-isolation sessions — they are no longer running.
531
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
532
+ continue;
533
+ }
534
+
535
+ // The UUID `$ --stop` expects is the start-command session id. For
536
+ // isolation sessions it is tracked either as sessionInfo.sessionId or as
537
+ // the (UUID-shaped) session key itself.
538
+ const candidateId = sessionInfo.sessionId || sessionName;
539
+ const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
540
+ const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
541
+
542
+ if (verbose) {
543
+ const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
544
+ console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
545
+ }
546
+
547
+ return {
548
+ sessionName,
549
+ sessionId,
550
+ sessionInfo,
551
+ isolationBackend: sessionInfo.isolationBackend || null,
552
+ stoppable,
553
+ };
554
+ }
555
+
556
+ if (verbose) {
557
+ console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
558
+ }
559
+ return null;
560
+ }
561
+
491
562
  /**
492
563
  * Async active-session check for command handlers.
493
564
  *
@@ -43,7 +43,7 @@ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized
43
43
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
44
44
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
45
45
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
46
- const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
46
+ const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl } = await import('./session-monitor.lib.mjs');
47
47
  const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
48
48
  const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
49
49
 
@@ -1068,7 +1068,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1068
1068
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1069
1069
  const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1070
1070
  registerTopCommand(bot, sharedCommandOpts);
1071
- registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
1071
+ registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue, findRunningSessionByUrl: (url, verbose) => findStoppableSessionByUrl(url, verbose) });
1072
1072
  await registerLogCommand(bot, sharedCommandOpts);
1073
1073
  await registerTerminalWatchCommand(bot, sharedCommandOpts);
1074
1074
 
@@ -16,10 +16,15 @@
16
16
  * - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
17
17
  * the URL up in the in-memory solve queue and either cancels the queued
18
18
  * item or forwards CTRL+C to the running isolated session (issue #1780).
19
+ * - `/stop <issue-or-pr-url>` also consults the session-monitor registry of
20
+ * running detached sessions, so it can interrupt a task that started
21
+ * immediately (queue empty) and was therefore never left in the queue's
22
+ * `processing` Map — the case shown in issue #1871's screenshots.
19
23
  *
20
24
  * @see https://github.com/link-assistant/hive-mind/issues/1081
21
25
  * @see https://github.com/link-assistant/hive-mind/issues/524
22
26
  * @see https://github.com/link-assistant/hive-mind/issues/1780
27
+ * @see https://github.com/link-assistant/hive-mind/issues/1871
23
28
  * @see https://github.com/link-foundation/start/issues/112
24
29
  */
25
30
 
@@ -257,6 +262,12 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
257
262
  * When omitted, the URL flow degrades gracefully to a "no queue available"
258
263
  * message so unit tests for non-URL paths don't need to construct a queue.
259
264
  * See https://github.com/link-assistant/hive-mind/issues/1780.
265
+ * @param {Function} [options.findRunningSessionByUrl] - Override for tests; looks
266
+ * the URL up in the session-monitor registry of running detached sessions so
267
+ * `/stop <url>` can interrupt tasks that started immediately (queue empty) and
268
+ * were therefore never left in the queue's `processing` Map. When omitted, the
269
+ * real `findStoppableSessionByUrl` from session-monitor is lazy-imported.
270
+ * See https://github.com/link-assistant/hive-mind/issues/1871.
260
271
  */
261
272
  export function registerStartStopCommands(bot, options) {
262
273
  const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
@@ -274,6 +285,26 @@ export function registerStartStopCommands(bot, options) {
274
285
  return mod.getTrackedSessionInfo(sessionId);
275
286
  }
276
287
 
288
+ // Issue #1871: look a URL up in the session-monitor registry of running
289
+ // detached sessions. A /solve or /codex that started immediately (queue
290
+ // empty) is dispatched straight to an isolation session and removed from the
291
+ // queue's `processing` Map, so the queue lookup alone reports "no task found"
292
+ // for a task that is clearly running. The session monitor still knows the
293
+ // URL → start-command-UUID mapping, which lets /stop <url> recover and
294
+ // interrupt the session. Test stubs can inject findRunningSessionByUrl.
295
+ async function lookupRunningSessionByUrl(url) {
296
+ try {
297
+ if (typeof options.findRunningSessionByUrl === 'function') {
298
+ return await options.findRunningSessionByUrl(url, VERBOSE);
299
+ }
300
+ const mod = await import('./session-monitor.lib.mjs');
301
+ return mod.findStoppableSessionByUrl(url, VERBOSE);
302
+ } catch (error) {
303
+ console.error('[ERROR] /stop: findStoppableSessionByUrl failed:', error);
304
+ return null;
305
+ }
306
+ }
307
+
277
308
  /**
278
309
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
279
310
  * @param {Object} ctx - Telegraf context
@@ -529,18 +560,42 @@ export function registerStartStopCommands(bot, options) {
529
560
  const url = target.value;
530
561
  VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
531
562
 
532
- // Look up the queue item BEFORE auth so we can allow the original task
533
- // requester to cancel their own task in a group (#1783). The lookup
534
- // here does NOT mutate the queue actual cancel happens below in
535
- // resolveQueueLookupForUrl after auth has passed.
563
+ // Look up the queue item AND any running detached session BEFORE auth so
564
+ // we can allow the original task requester to cancel their own task in a
565
+ // group (#1783), regardless of whether the task is still queued or already
566
+ // dispatched to an isolation session (#1871). Neither lookup mutates
567
+ // state — actual cancel/stop happens below after auth has passed.
536
568
  const candidate = findQueueCandidateForUrl(url);
537
- const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null });
569
+ const runningSession = await lookupRunningSessionByUrl(url);
570
+ const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null, sessionInfo: runningSession?.sessionInfo || null });
538
571
  if (!ok) return;
539
572
 
540
573
  const lookup = resolveQueueLookupForUrl(url);
541
574
  VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
542
575
 
576
+ // Issue #1871: when the queue has no record of the task (it started
577
+ // immediately and was dispatched to a detached session) but the session
578
+ // monitor still tracks a running isolated session for this URL, forward
579
+ // CTRL+C to its start-command UUID. This is the common case for tasks
580
+ // that begin executing right away with `--isolation screen`.
581
+ const queueHasTask = lookup.action === 'cancel-queued' || lookup.action === 'stop-running';
582
+ if (!queueHasTask && runningSession?.stoppable && runningSession.sessionId) {
583
+ VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to tracked session ${runningSession.sessionId} for ${url} (queue action=${lookup.action})`);
584
+ await runStopIsolatedSessionFlow(ctx, runningSession.sessionId);
585
+ return;
586
+ }
587
+
543
588
  if (lookup.action === 'no-queue') {
589
+ // No solve queue in this context. If the session monitor found a
590
+ // running-but-non-stoppable (non-isolation) session, say so; otherwise
591
+ // fall back to the UUID hint.
592
+ if (runningSession) {
593
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
594
+ parse_mode: 'Markdown',
595
+ reply_to_message_id: message.message_id,
596
+ });
597
+ return;
598
+ }
544
599
  await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
545
600
  parse_mode: 'Markdown',
546
601
  reply_to_message_id: message.message_id,
@@ -549,6 +604,16 @@ export function registerStartStopCommands(bot, options) {
549
604
  }
550
605
 
551
606
  if (lookup.action === 'not-found') {
607
+ // The session monitor also had no stoppable session (otherwise we would
608
+ // have forwarded CTRL+C above). If it tracked a non-isolation session,
609
+ // explain why it can't be stopped; otherwise report not found.
610
+ if (runningSession) {
611
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
612
+ parse_mode: 'Markdown',
613
+ reply_to_message_id: message.message_id,
614
+ });
615
+ return;
616
+ }
552
617
  await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
553
618
  parse_mode: 'Markdown',
554
619
  reply_to_message_id: message.message_id,