@link-assistant/hive-mind 2.5.1 → 2.5.3

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.5.3
4
+
5
+ ### Patch Changes
6
+
7
+ - eb755b5: Recognize an operator-initiated `/stop` as "🛑 Work session stopped by user" instead of "killed — out of memory or forced kill (SIGKILL)" (issue #2052). The Telegram `/stop <uuid>` flow now records the stop via `markSessionStopRequested` before forwarding CTRL+C, so the resulting SIGTERM/SIGKILL exit (143/137) is reported as an intentional user stop. Adds `--verbose` interrupt timing traces (auto-commit vs log-upload) to make the `docker stop` grace-period race behind "no log uploaded on stop" measurable, plus a case study under `docs/case-studies/issue-2052`.
8
+
9
+ ## 2.5.2
10
+
11
+ ### Patch Changes
12
+
13
+ - da84b0c: Lower the default disk queue-admission threshold to 65% so isolation tasks retain safer free-space headroom.
14
+
3
15
  ## 2.5.1
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.5.1",
3
+ "version": "2.5.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -624,6 +624,7 @@ en
624
624
  finished "Work session finished successfully"
625
625
  failed "Work session failed (exit code: {{exitCode}})"
626
626
  killed "Work session {{reason}}{{exitSuffix}}"
627
+ stopped "Work session stopped by user{{requestedBy}}{{exitSuffix}}"
627
628
  duration
628
629
  label "Duration"
629
630
  session
@@ -624,6 +624,7 @@ hi
624
624
  finished "कार्य सत्र सफलतापूर्वक पूरा हुआ"
625
625
  failed "कार्य सत्र विफल हुआ (exit code: {{exitCode}})"
626
626
  killed "कार्य सत्र रोका गया: {{reason}}{{exitSuffix}}"
627
+ stopped "कार्य सत्र उपयोगकर्ता द्वारा रोका गया{{requestedBy}}{{exitSuffix}}"
627
628
  duration
628
629
  label "अवधि"
629
630
  session
@@ -624,6 +624,7 @@ ru
624
624
  finished "Рабочий сеанс успешно завершен"
625
625
  failed "Рабочий сеанс завершился с ошибкой (код выхода: {{exitCode}})"
626
626
  killed "Рабочий сеанс остановлен: {{reason}}{{exitSuffix}}"
627
+ stopped "Рабочий сеанс остановлен пользователем{{requestedBy}}{{exitSuffix}}"
627
628
  duration
628
629
  label "Длительность"
629
630
  session
@@ -624,6 +624,7 @@ zh
624
624
  finished "工作会话已成功完成"
625
625
  failed "工作会话失败(退出代码:{{exitCode}})"
626
626
  killed "工作会话已终止:{{reason}}{{exitSuffix}}"
627
+ stopped "工作会话已由用户停止{{requestedBy}}{{exitSuffix}}"
627
628
  duration
628
629
  label "耗时"
629
630
  session
@@ -239,7 +239,7 @@ function getThresholdConfig(linoKey, envVarThreshold, envVarStrategy, defaultThr
239
239
  * - 'enqueue': Block and wait in queue
240
240
  * - 'dequeue-one-at-a-time': Allow one command, block subsequent
241
241
  *
242
- * Issue #1981: disk now defaults to the normal wait/enqueue path at 80% used.
242
+ * Issue #2045: disk now defaults to the normal wait/enqueue path at 65% used.
243
243
  * Operators can still choose immediate rejection with HIVE_MIND_DISK_STRATEGY=reject.
244
244
  */
245
245
  export const QUEUE_CONFIG = {
@@ -248,8 +248,8 @@ export const QUEUE_CONFIG = {
248
248
  thresholds: {
249
249
  ram: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue'),
250
250
  cpu: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue'),
251
- // Issue #1981: wait instead of immediately rejecting when disk crosses 80%.
252
- disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.8, 'enqueue'),
251
+ // Issue #2045: wait instead of immediately rejecting when disk crosses 65%.
252
+ disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.65, 'enqueue'),
253
253
  claude5Hour: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
254
254
  claudeWeekly: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time'),
255
255
  codex5Hour: getThresholdConfig('codex5Hour', 'HIVE_MIND_CODEX_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CODEX_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
@@ -265,7 +265,7 @@ export const QUEUE_CONFIG = {
265
265
  // These are derived from thresholds.{metric}.value
266
266
  RAM_THRESHOLD: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue').value,
267
267
  CPU_THRESHOLD: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue').value,
268
- DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.8, 'enqueue').value,
268
+ DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.65, 'enqueue').value,
269
269
  CLAUDE_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
270
270
  CLAUDE_WEEKLY_THRESHOLD: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time').value,
271
271
  CODEX_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('codex5Hour', 'HIVE_MIND_CODEX_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CODEX_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
@@ -217,6 +217,33 @@ export function getTrackedSessionInfo(sessionName) {
217
217
  return activeSessions.get(sessionName) || null;
218
218
  }
219
219
 
220
+ /**
221
+ * Issue #2052: record that an operator explicitly requested a session stop
222
+ * (e.g. Telegram `/stop <uuid>`). The subsequent SIGTERM/SIGKILL exit (143/137,
223
+ * delivered by `docker stop`) is then reported as "🛑 Stopped by user" instead
224
+ * of the misleading "out of memory or forced kill (SIGKILL)". Matches by
225
+ * tracking key OR by the isolation `sessionId` UUID (which is how `/stop`
226
+ * addresses sessions).
227
+ * @param {string} sessionId - UUID or session name of the session being stopped
228
+ * @param {{requestedBy?: string|null, verbose?: boolean}} [opts]
229
+ * @returns {boolean} True when a tracked session was marked.
230
+ */
231
+ export function markSessionStopRequested(sessionId, { requestedBy = null, verbose = false } = {}) {
232
+ if (!sessionId) return false;
233
+ const target = activeSessions.get(sessionId) || Array.from(activeSessions.values()).find(info => info?.sessionId === sessionId) || null;
234
+ if (!target) {
235
+ if (verbose) console.log(`[VERBOSE] markSessionStopRequested: no tracked session found for ${sessionId}`);
236
+ return false;
237
+ }
238
+ target.stopRequestedByUser = true;
239
+ if (requestedBy) target.stopRequestedBy = requestedBy;
240
+ const key = target.sessionId || sessionId;
241
+ persistSessionSnapshot(key, target);
242
+ if (verbose) console.log(`[VERBOSE] markSessionStopRequested: ${sessionId} marked stopped by user${requestedBy ? ` (${requestedBy})` : ''}`);
243
+ logEvent('session_stop_requested', { sessionName: key, sessionId, requestedBy: requestedBy || null });
244
+ return true;
245
+ }
246
+
220
247
  /**
221
248
  * Stop tracking a session that was registered optimistically but never actually
222
249
  * started (e.g. the start-command launch failed). Removes it from the in-memory
@@ -22,10 +22,24 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
22
22
  const ctx = cleanupContext;
23
23
  if (!ctx.tempDir || !ctx.argv) return;
24
24
 
25
+ // Issue #2052: the interrupt handler races the isolation backend's grace
26
+ // period — `docker stop` sends SIGTERM, waits ~10s, then SIGKILL. Auto-commit
27
+ // is fast, but attaching a multi-MB log to a Gist/PR can take several seconds
28
+ // and may be cut off by SIGKILL, which is exactly "no log uploaded on stop".
29
+ // These verbose timing traces make the race measurable on the next iteration
30
+ // (they are silent unless --verbose is set).
31
+ const verbose = ctx.argv.verbose || false;
32
+ const startedAt = Date.now();
33
+ const trace = async message => {
34
+ if (verbose) await log(`[interrupt] +${Date.now() - startedAt}ms ${message}`, { verbose: true });
35
+ };
36
+ await trace('handler entered');
37
+
25
38
  await log('\n⚠️ Session interrupted by user (CTRL+C)');
26
39
 
27
40
  // Always auto-commit uncommitted changes on CTRL+C to preserve work
28
41
  if (ctx.branchName) {
42
+ await trace('auto-commit: start');
29
43
  try {
30
44
  await checkForUncommittedChanges(
31
45
  ctx.tempDir,
@@ -37,6 +51,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
37
51
  true, // always autoCommit on CTRL+C to preserve work
38
52
  false // no autoRestart
39
53
  );
54
+ await trace('auto-commit: done');
40
55
  } catch (commitError) {
41
56
  await log(`⚠️ Could not auto-commit changes on interrupt: ${commitError.message}`, {
42
57
  level: 'warning',
@@ -47,6 +62,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
47
62
  // Upload logs if --attach-logs is enabled and we have a PR
48
63
  if (shouldAttachLogs && ctx.prNumber && ctx.owner && ctx.repo) {
49
64
  await log('📎 Uploading interrupted session logs to Pull Request...');
65
+ await trace('log-upload: start');
50
66
  try {
51
67
  await attachLogToGitHub({
52
68
  logFile: getLogFile(),
@@ -63,6 +79,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
63
79
  requestedModel: ctx.argv.model,
64
80
  tool: ctx.argv.tool || 'claude',
65
81
  });
82
+ await trace('log-upload: done');
66
83
  } catch (uploadError) {
67
84
  await log(`⚠️ Could not upload logs on interrupt: ${uploadError.message}`, {
68
85
  level: 'warning',
@@ -285,6 +285,24 @@ export function registerStartStopCommands(bot, options) {
285
285
  return mod.getTrackedSessionInfo(sessionId);
286
286
  }
287
287
 
288
+ // Issue #2052: record that this stop was operator-initiated, so the eventual
289
+ // SIGTERM/SIGKILL exit (delivered by `docker stop`) is reported as
290
+ // "🛑 Stopped by user" instead of "out of memory or forced kill (SIGKILL)".
291
+ // Tolerant of a sync or async stub, and never lets a marking failure block
292
+ // the actual stop.
293
+ async function markSessionStopRequestedSafe(sessionId, requestedBy) {
294
+ try {
295
+ if (typeof options.markSessionStopRequested === 'function') {
296
+ return await options.markSessionStopRequested(sessionId, { requestedBy, verbose: VERBOSE });
297
+ }
298
+ const mod = await import('./session-monitor.lib.mjs');
299
+ return mod.markSessionStopRequested(sessionId, { requestedBy, verbose: VERBOSE });
300
+ } catch (error) {
301
+ console.error('[ERROR] /stop: markSessionStopRequested failed:', error);
302
+ return false;
303
+ }
304
+ }
305
+
288
306
  // Issue #1871: look a URL up in the session-monitor registry of running
289
307
  // detached sessions. A /solve or /codex that started immediately (queue
290
308
  // empty) is dispatched straight to an isolation session and removed from the
@@ -425,6 +443,12 @@ export function registerStartStopCommands(bot, options) {
425
443
  reply_to_message_id: message.message_id,
426
444
  });
427
445
 
446
+ // Issue #2052: mark the stop as user-initiated BEFORE forwarding CTRL+C, so
447
+ // even a fast SIGKILL race still finds the flag when the completion message
448
+ // is formatted.
449
+ const requestedBy = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || null;
450
+ await markSessionStopRequestedSafe(sessionId, requestedBy);
451
+
428
452
  let result;
429
453
  try {
430
454
  result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
@@ -132,13 +132,25 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
132
132
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
133
133
  const outcome = classifySessionOutcome({ exitCode: finalExitCode, status: statusResult?.status || null });
134
134
  const { failed, killed, signal } = outcome;
135
- const statusEmoji = failed ? '❌' : '✅';
136
135
  const messageLocale = locale || sessionInfo?.locale || null;
137
136
  // Issue #1927: a killed session (OOM/SIGKILL/SIGTERM) must never read as a
138
137
  // success, and the signal/reason is surfaced explicitly so an operator can
139
138
  // tell an out-of-memory kill apart from an ordinary non-zero exit.
139
+ // Issue #2052: when the operator explicitly requested a stop (e.g. Telegram
140
+ // `/stop <uuid>` → `docker stop` → SIGTERM then SIGKILL), the resulting signal
141
+ // exit (143/137) must NOT read as "out of memory or forced kill". A user stop
142
+ // is an orderly, intentional termination, so surface it as such regardless of
143
+ // which signal actually delivered the kill.
144
+ const stopRequestedByUser = Boolean(sessionInfo?.stopRequestedByUser);
145
+ let statusEmojiOverride = null;
140
146
  let statusText;
141
- if (killed) {
147
+ if (killed && stopRequestedByUser) {
148
+ const showCode = finalExitCode !== null && !(!signal && finalExitCode === 1);
149
+ const exitSuffix = showCode ? ` (exit code: ${finalExitCode})` : '';
150
+ const requestedBy = sessionInfo?.stopRequestedBy ? ` by ${sessionInfo.stopRequestedBy}` : '';
151
+ statusEmojiOverride = '🛑';
152
+ statusText = text(messageLocale, 'telegram.work_session_stopped', `Work session stopped by user${requestedBy}${exitSuffix}`, { requestedBy, exitCode: finalExitCode ?? '', signal: signal?.signal ?? '', exitSuffix });
153
+ } else if (killed) {
142
154
  // A real signal exit is always >128; an exit code of exactly 1 on a
143
155
  // status-only kill (process vanished, code unknown) is a synthesized failure
144
156
  // sentinel, so suppress the misleading "(exit code: 1)" in that case.
@@ -164,6 +176,7 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
164
176
  if (pullRequestUrl) resolvedInfoBlock = appendPullRequestLine(resolvedInfoBlock, pullRequestUrl, { locale: messageLocale });
165
177
  const details = resolvedInfoBlock ? `\n\n${resolvedInfoBlock}` : '';
166
178
 
179
+ const statusEmoji = statusEmojiOverride || (failed ? '❌' : '✅');
167
180
  let message = `${statusEmoji} *${statusText}*\n\n`;
168
181
  message += `⏱️ ${durationLabel}: ${formatSessionDurationSeconds(durationSeconds)}\n`;
169
182
  message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${isolationInfo}${details}`;