@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.
- package/CHANGELOG.md +10 -0
- package/package.json +1 -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/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/isolation-runner.lib.mjs +32 -1
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -4
- package/src/solve.results.lib.mjs +2 -2
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/working-session-summary.lib.mjs +47 -1
|
@@ -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 };
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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 };
|