@link-assistant/hive-mind 2.0.18 → 2.0.20

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.20
4
+
5
+ ### Patch Changes
6
+
7
+ - 078f346: Reap successful Docker-isolated task containers at session completion, keep failed containers with cleanup instructions by default, and update Docker images to start-command 0.30.1.
8
+
9
+ ## 2.0.19
10
+
11
+ ### Patch Changes
12
+
13
+ - 8061979: Preserve per-command isolation overrides for queued Telegram solve commands.
14
+
3
15
  ## 2.0.18
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -831,6 +831,47 @@ export async function checkDockerContainerRunning(containerName, verbose = false
831
831
  }
832
832
  }
833
833
 
834
+ /**
835
+ * Best-effort removal for a Docker container backing a native
836
+ * `$ --isolated docker` session.
837
+ *
838
+ * start-command names the container after the `--session` value. The monitor
839
+ * calls this only after the session is terminal and after the host-side log has
840
+ * been inspected, so removing the container reclaims its writable layer without
841
+ * losing the captured task log. Never throws: completion notification must not
842
+ * fail just because Docker already removed the container.
843
+ *
844
+ * @param {string} containerName - Container name (the session UUID)
845
+ * @param {boolean} [verbose] - Enable verbose logging
846
+ * @returns {Promise<{success: boolean, output: string, error: string|null}>}
847
+ */
848
+ export async function removeDockerContainer(containerName, verbose = false) {
849
+ if (!containerName) {
850
+ return { success: false, output: '', error: 'missing container name' };
851
+ }
852
+
853
+ try {
854
+ const result = await $({ mirror: false })`docker rm -f ${containerName}`;
855
+ const stdout = result.stdout?.toString() || '';
856
+ const stderr = result.stderr?.toString() || '';
857
+ if (verbose) {
858
+ console.log(`[VERBOSE] isolation-runner: docker rm -f '${containerName}' succeeded`);
859
+ }
860
+ return { success: true, output: stdout || stderr, error: null };
861
+ } catch (error) {
862
+ const stderr = error?.stderr?.toString?.() || '';
863
+ const stdout = error?.stdout?.toString?.() || '';
864
+ if (verbose) {
865
+ console.log(`[VERBOSE] isolation-runner: docker rm -f '${containerName}' failed: ${stderr.trim() || error?.message || error}`);
866
+ }
867
+ return {
868
+ success: false,
869
+ output: stdout,
870
+ error: stderr.trim() || error?.message || String(error),
871
+ };
872
+ }
873
+ }
874
+
834
875
  /**
835
876
  * Check whether a tmux session with the given name still exists.
836
877
  * `tmux has-session -t <name>` exits 0 when it exists and non-zero otherwise,
@@ -110,6 +110,20 @@ export function getIsolationSessionStateForTests(sessionName, sessionInfo, optio
110
110
  * mechanism will no longer be needed.
111
111
  */
112
112
  export const NON_ISOLATION_SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
113
+ export const DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY = 'on-failure';
114
+ export const DOCKER_TASK_CONTAINER_KEEP_POLICIES = ['always', 'on-failure', 'never'];
115
+
116
+ export function resolveDockerTaskContainerKeepPolicy({ env = process.env, verbose = false } = {}) {
117
+ const raw = String(env?.HIVE_MIND_KEEP_TASK_CONTAINER || '')
118
+ .trim()
119
+ .toLowerCase();
120
+ if (!raw) return DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY;
121
+ if (DOCKER_TASK_CONTAINER_KEEP_POLICIES.includes(raw)) return raw;
122
+ if (verbose) {
123
+ console.log(`[VERBOSE] Invalid HIVE_MIND_KEEP_TASK_CONTAINER='${raw}', using '${DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY}'`);
124
+ }
125
+ return DEFAULT_DOCKER_TASK_CONTAINER_KEEP_POLICY;
126
+ }
113
127
 
114
128
  /**
115
129
  * Check if a screen session exists
@@ -345,6 +359,70 @@ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, read
345
359
  }
346
360
  }
347
361
 
362
+ function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
363
+ const outcome = classifySessionOutcome({ exitCode, status });
364
+ if (outcome.failed) return false;
365
+ if (exitCode === 0) return true;
366
+
367
+ const normalizedStatus = String(status || '')
368
+ .trim()
369
+ .toLowerCase();
370
+ return exitCode === null && (normalizedStatus === 'executed' || normalizedStatus === 'completed');
371
+ }
372
+
373
+ function formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) {
374
+ 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');
375
+ }
376
+
377
+ export function buildDockerTaskContainerCompletionAction({ sessionName, sessionInfo, exitCode = null, status = null, env = process.env, verbose = false } = {}) {
378
+ if (sessionInfo?.isolationBackend !== 'docker') {
379
+ return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
380
+ }
381
+
382
+ const containerName = sessionInfo.sessionId || sessionName || null;
383
+ if (!containerName) {
384
+ return { applies: false, containerName: null, keepPolicy: null, shouldRemove: false, extraSection: '' };
385
+ }
386
+
387
+ const keepPolicy = resolveDockerTaskContainerKeepPolicy({ env, verbose });
388
+ const successful = isSuccessfulTaskCompletion({ exitCode, status });
389
+ const shouldKeep = keepPolicy === 'always' || (keepPolicy === 'on-failure' && !successful);
390
+
391
+ return {
392
+ applies: true,
393
+ containerName,
394
+ keepPolicy,
395
+ successful,
396
+ shouldRemove: !shouldKeep,
397
+ extraSection: shouldKeep ? formatDockerTaskContainerKeptSection({ containerName, keepPolicy }) : '',
398
+ };
399
+ }
400
+
401
+ async function applyDockerTaskContainerCompletionAction(action, { verbose = false, removeDockerContainer = null } = {}) {
402
+ if (!action?.applies || !action.shouldRemove || !action.containerName) return;
403
+
404
+ try {
405
+ const removeFn =
406
+ removeDockerContainer ||
407
+ (async (containerName, removeVerbose) => {
408
+ const runner = await getIsolationRunner();
409
+ return runner.removeDockerContainer(containerName, removeVerbose);
410
+ });
411
+ const result = await removeFn(action.containerName, verbose);
412
+ if (verbose) {
413
+ if (result?.success) {
414
+ console.log(`[VERBOSE] Removed docker task container '${action.containerName}' after terminal session completion`);
415
+ } else {
416
+ console.log(`[VERBOSE] Could not remove docker task container '${action.containerName}': ${result?.error || 'unknown error'}`);
417
+ }
418
+ }
419
+ } catch (error) {
420
+ if (verbose) {
421
+ console.log(`[VERBOSE] Could not remove docker task container '${action.containerName}': ${error?.message || error}`);
422
+ }
423
+ }
424
+ }
425
+
348
426
  function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
349
427
  const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
350
428
  const elapsed = Date.now() - startTime.getTime();
@@ -602,8 +680,17 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
602
680
  if (!stillRunning) {
603
681
  console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
604
682
 
683
+ let dockerTaskContainerAction = null;
605
684
  try {
606
685
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
686
+ dockerTaskContainerAction = buildDockerTaskContainerCompletionAction({
687
+ sessionName,
688
+ sessionInfo,
689
+ exitCode: finalExitCode,
690
+ status: resolvedStatus,
691
+ env: options.env || process.env,
692
+ verbose,
693
+ });
607
694
 
608
695
  // Issue #1688/#1905: When the original /solve URL was an issue, look up
609
696
  // the created PR so the completion message can include both an
@@ -709,6 +796,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
709
796
  console.log(`[VERBOSE] Could not build disk diagnostics section for ${sessionName}: ${diskError?.message || diskError}`);
710
797
  }
711
798
  }
799
+ const dockerTaskContainerExtraSections = dockerTaskContainerAction?.extraSection ? [dockerTaskContainerAction.extraSection] : [];
712
800
 
713
801
  const message = formatSessionCompletionMessage({
714
802
  sessionName,
@@ -718,7 +806,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
718
806
  exitCode: finalExitCode,
719
807
  infoBlock: sessionInfo?.infoBlock || '',
720
808
  pullRequestUrl,
721
- extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections],
809
+ extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections, ...dockerTaskContainerExtraSections],
722
810
  });
723
811
 
724
812
  // Update the original reply message if messageId is available, otherwise send new message
@@ -758,10 +846,18 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
758
846
  }
759
847
  }
760
848
 
849
+ await applyDockerTaskContainerCompletionAction(dockerTaskContainerAction, {
850
+ verbose,
851
+ removeDockerContainer: options.removeDockerContainer,
852
+ });
761
853
  completeSession(sessionName, finalExitCode || 0, verbose, resolvedStatus);
762
854
  } catch (error) {
763
855
  console.error(`Failed to send completion notification for ${sessionName}:`, error);
764
856
  if (isMessageAlreadyUpdatedError(error)) {
857
+ await applyDockerTaskContainerCompletionAction(dockerTaskContainerAction, {
858
+ verbose,
859
+ removeDockerContainer: options.removeDockerContainer,
860
+ });
765
861
  completeSession(sessionName, exitCode || 0, verbose, resolvedStatus);
766
862
  } else {
767
863
  sessionInfo.lastNotificationError = error.message;
@@ -58,6 +58,8 @@ class SolveQueueItem {
58
58
  this.requester = options.requester;
59
59
  this.infoBlock = options.infoBlock;
60
60
  this.tool = options.tool || 'claude';
61
+ // Issue #1983: preserve per-command isolation through queued execution.
62
+ this.perCommandIsolation = options.perCommandIsolation || null;
61
63
  // Issue #1688: keep parsed URL context (owner/repo/number/type) so completion
62
64
  // notifications can look up linked PRs for issue URLs.
63
65
  this.urlContext = options.urlContext || null;