@link-assistant/hive-mind 2.11.13 → 2.12.0

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Cross-process exclusive locks over the durable bot state directory.
3
+ *
4
+ * Issue #2146 introduced two background maintainers — the Formal AI sidecar
5
+ * lifecycle and the agentic-CLI refresh — that must never interleave with the
6
+ * work they maintain. Both need the same primitive, so it lives here once.
7
+ *
8
+ * `mkdir` is the lock: it is atomic on every filesystem the bot runs on, unlike
9
+ * "check then create" with a regular file. A lock older than `staleMs` is
10
+ * broken so a killed process cannot deadlock the bot forever.
11
+ *
12
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ import { resolveBotStateDir } from './session-store.lib.mjs';
19
+
20
+ export const DEFAULT_STATE_LOCK_TIMEOUT_MS = 10 * 60 * 1000;
21
+ export const DEFAULT_STATE_LOCK_STALE_MS = 15 * 60 * 1000;
22
+ export const DEFAULT_STATE_LOCK_POLL_MS = 250;
23
+
24
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
25
+
26
+ /** Absolute path of the lock directory backing `name`. */
27
+ export const resolveStateLockPath = (name, env = process.env) => path.join(resolveBotStateDir(env), `${name}.lock`);
28
+
29
+ /**
30
+ * Run `fn` while holding the named state lock.
31
+ *
32
+ * @param {string} name - Lock name, e.g. `formal-ai-sidecar`.
33
+ * @param {() => Promise<any>} fn - Critical section.
34
+ * @returns {Promise<any>} Whatever `fn` resolves to.
35
+ */
36
+ export const withStateLock = async (name, fn, { env = process.env, fsImpl = fs, now = () => Date.now(), timeoutMs = DEFAULT_STATE_LOCK_TIMEOUT_MS, staleMs = DEFAULT_STATE_LOCK_STALE_MS, pollMs = DEFAULT_STATE_LOCK_POLL_MS, sleepImpl = sleep, log = null } = {}) => {
37
+ const lockPath = resolveStateLockPath(name, env);
38
+ fsImpl.mkdirSync(path.dirname(lockPath), { recursive: true });
39
+
40
+ const deadline = now() + timeoutMs;
41
+ for (;;) {
42
+ try {
43
+ fsImpl.mkdirSync(lockPath);
44
+ break;
45
+ } catch (error) {
46
+ if (error?.code !== 'EEXIST') throw error;
47
+
48
+ let age;
49
+ try {
50
+ age = now() - fsImpl.statSync(lockPath).mtimeMs;
51
+ } catch {
52
+ // The holder released it between mkdir and stat; retry immediately.
53
+ continue;
54
+ }
55
+
56
+ if (age > staleMs) {
57
+ if (log) await log(`⚠️ ${name} lock held for ${Math.round(age / 1000)}s with no owner; breaking it`);
58
+ try {
59
+ fsImpl.rmSync(lockPath, { recursive: true, force: true });
60
+ } catch {
61
+ // Another waiter won the race; retry normally.
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (now() >= deadline) throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the ${name} lock at ${lockPath}`, { cause: error });
67
+ await sleepImpl(pollMs);
68
+ }
69
+ }
70
+
71
+ try {
72
+ return await fn();
73
+ } finally {
74
+ try {
75
+ fsImpl.rmSync(lockPath, { recursive: true, force: true });
76
+ } catch {
77
+ // Losing the release is recoverable through the stale-lock path above.
78
+ }
79
+ }
80
+ };
81
+
82
+ export default { DEFAULT_STATE_LOCK_POLL_MS, DEFAULT_STATE_LOCK_STALE_MS, DEFAULT_STATE_LOCK_TIMEOUT_MS, resolveStateLockPath, withStateLock };
@@ -263,6 +263,7 @@ const { createSessionStore } = await import('./session-store.lib.mjs');
263
263
  const { createHeartbeat, resumeSessionsOnLaunch, createShutdownHandler } = await import('./bot-lifecycle.lib.mjs');
264
264
  const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
265
265
  const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
266
+ const { startFormalAiMaintenance } = await import('./formal-ai-maintenance.lib.mjs');
266
267
 
267
268
  // Initialize Sentry for error tracking
268
269
  await initializeSentry({
@@ -1244,6 +1245,7 @@ if (VERBOSE) {
1244
1245
  // Non-retryable errors (401 Unauthorized) cause immediate exit.
1245
1246
  const launchAbortController = new AbortController();
1246
1247
  let sessionMonitoringTimer = null;
1248
+ let formalAiMaintenance = null;
1247
1249
  let launchAnnouncementShown = false;
1248
1250
 
1249
1251
  function startSessionMonitoringOnce() {
@@ -1253,6 +1255,20 @@ function startSessionMonitoringOnce() {
1253
1255
  sessionMonitoringTimer = startSessionMonitoring(bot, VERBOSE, 30000, { isolationRunner });
1254
1256
  }
1255
1257
 
1258
+ // Issue #2146 (PR #2147 review): stop the Formal AI sidecar once no Formal AI
1259
+ // task holds a lease, then — while the host is idle — update its image (with a
1260
+ // non-destructive memory migration) and refresh the agentic CLIs. Every step is
1261
+ // best-effort and never blocks the bot.
1262
+ function startFormalAiMaintenanceOnce() {
1263
+ if (formalAiMaintenance) return;
1264
+ formalAiMaintenance = startFormalAiMaintenance({
1265
+ verbose: VERBOSE,
1266
+ log: async message => {
1267
+ console.log(message);
1268
+ },
1269
+ });
1270
+ }
1271
+
1256
1272
  // Issue #1927 (requirements #3/#4): a periodic timestamped heartbeat so the "last
1257
1273
  // time the bot was alive" is always discoverable from the log. The heartbeat
1258
1274
  // logic lives in bot-lifecycle.lib.mjs so it can be unit tested.
@@ -1274,6 +1290,7 @@ async function onBotLaunched() {
1274
1290
  await resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTime: BOT_START_TIME, verbose: VERBOSE, logger: botLogger });
1275
1291
 
1276
1292
  startSessionMonitoringOnce();
1293
+ startFormalAiMaintenanceOnce();
1277
1294
  heartbeat.start();
1278
1295
 
1279
1296
  if (VERBOSE) {
@@ -1372,6 +1389,7 @@ const handleShutdownSignal = createShutdownHandler({
1372
1389
  cleanup: () => {
1373
1390
  launchAbortController.abort();
1374
1391
  if (sessionMonitoringTimer) clearInterval(sessionMonitoringTimer);
1392
+ formalAiMaintenance?.stop();
1375
1393
  heartbeat.stop();
1376
1394
  stopSolveQueue();
1377
1395
  },
@@ -10,6 +10,7 @@
10
10
  import { buildUserMention } from './buildUserMention.lib.mjs';
11
11
  import { validateModelName } from './models/index.mjs';
12
12
  import { parseFixRepository } from './fix.ci-cd.lib.mjs';
13
+ import { getModelFromArgs } from './model-args.lib.mjs';
13
14
  import { escapeMarkdown } from './telegram-markdown.lib.mjs';
14
15
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
15
16
  import { mergeArgsWithOverrides } from './args-overrides.lib.mjs';
@@ -47,14 +48,6 @@ export function getFixToolFromArgs(args) {
47
48
  return 'claude';
48
49
  }
49
50
 
50
- function getModelFromArgs(args) {
51
- for (let i = 0; i < args.length; i++) {
52
- if ((args[i] === '--model' || args[i] === '-m') && i + 1 < args.length) return args[i + 1];
53
- if (args[i].startsWith('--model=')) return args[i].substring('--model='.length);
54
- }
55
- return null;
56
- }
57
-
58
51
  function validateFixModel(args) {
59
52
  const model = getModelFromArgs(args);
60
53
  if (!model) return null;
@@ -1,6 +1,7 @@
1
1
  import { buildUserMention } from './buildUserMention.lib.mjs';
2
2
  import { validateModelName } from './models/index.mjs';
3
3
  import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
4
+ import { getModelFromArgs } from './model-args.lib.mjs';
4
5
  import { createYargsConfig as createTaskYargsConfig } from './task.config.lib.mjs';
5
6
  import { createCiCdIssue } from './fix.ci-cd-issue.lib.mjs';
6
7
  import { parseFixRepository } from './fix.ci-cd.lib.mjs';
@@ -47,14 +48,6 @@ export function getTaskToolFromArgs(args) {
47
48
  return 'claude';
48
49
  }
49
50
 
50
- function getModelFromArgs(args) {
51
- for (let i = 0; i < args.length; i++) {
52
- if ((args[i] === '--model' || args[i] === '-m') && i + 1 < args.length) return args[i + 1];
53
- if (args[i].startsWith('--model=')) return args[i].substring('--model='.length);
54
- }
55
- return null;
56
- }
57
-
58
51
  function validateTaskModel(args) {
59
52
  const model = getModelFromArgs(args);
60
53
  if (!model) return null;
@@ -49,6 +49,52 @@ export const redactWorkspacePaths = text => {
49
49
  return text.replace(WORKSPACE_PATH_PATTERN, WORKSPACE_PATH_PLACEHOLDER);
50
50
  };
51
51
 
52
+ /**
53
+ * Fence structured text emitted after a Markdown lead-in ending in `:`.
54
+ * Formal AI plan events use a root record followed by space/tab-indented Lino;
55
+ * GitHub otherwise collapses that layout into ordinary prose. Existing fences
56
+ * and ordinary Markdown are preserved, making this safe to apply at the final
57
+ * GitHub-comment boundary.
58
+ */
59
+ export const formatWorkingSessionSummaryMarkdown = text => {
60
+ if (typeof text !== 'string' || !text) return text;
61
+
62
+ const lines = text.split('\n');
63
+ const output = [];
64
+ let inFence = false;
65
+ let previousNonEmpty = '';
66
+
67
+ for (let index = 0; index < lines.length; ) {
68
+ const line = lines[index];
69
+ if (/^\s*(```|~~~)/.test(line)) {
70
+ inFence = !inFence;
71
+ output.push(line);
72
+ if (line.trim()) previousNonEmpty = line;
73
+ index += 1;
74
+ continue;
75
+ }
76
+
77
+ if (!inFence && line.trim() && previousNonEmpty.trimEnd().endsWith(':')) {
78
+ let end = index;
79
+ while (end < lines.length && lines[end].trim()) end += 1;
80
+ const block = lines.slice(index, end);
81
+ const looksStructured = block.length >= 2 && block.some(blockLine => /^(?:\t| {2,})/.test(blockLine));
82
+ if (looksStructured) {
83
+ output.push('```text', ...block, '```');
84
+ previousNonEmpty = '```';
85
+ index = end;
86
+ continue;
87
+ }
88
+ }
89
+
90
+ output.push(line);
91
+ if (line.trim()) previousNonEmpty = line;
92
+ index += 1;
93
+ }
94
+
95
+ return output.join('\n');
96
+ };
97
+
52
98
  /**
53
99
  * The line appended when the pull request still has an empty diff.
54
100
  *
@@ -62,4 +108,4 @@ export const buildNoChangesNotice = changeStats => {
62
108
  return '> ⚠️ This pull request still contains no changes - nothing was implemented yet.';
63
109
  };
64
110
 
65
- export default { buildNoChangesNotice, redactWorkspacePaths, WORKSPACE_PATH_PLACEHOLDER };
111
+ export default { buildNoChangesNotice, formatWorkingSessionSummaryMarkdown, redactWorkspacePaths, WORKSPACE_PATH_PLACEHOLDER };