@link-assistant/hive-mind 2.11.13 → 2.12.1
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 +18 -0
- package/package.json +4 -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/claude.connection.lib.mjs +209 -0
- package/src/claude.lib.mjs +6 -202
- package/src/codex.lib.mjs +0 -128
- 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/github-rate-limit.lib.mjs +3 -0
- package/src/github-url-parser.lib.mjs +255 -0
- package/src/github.lib.mjs +22 -343
- package/src/hive.mjs +0 -152
- package/src/interactive-mode.lib.mjs +0 -43
- package/src/isolation-runner.lib.mjs +44 -173
- package/src/limits.lib.mjs +0 -89
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -19
- package/src/session-monitor.lib.mjs +14 -172
- package/src/solve.auto-merge.lib.mjs +70 -164
- package/src/solve.mjs +31 -193
- package/src/solve.repository.lib.mjs +0 -83
- package/src/solve.results.lib.mjs +2 -92
- package/src/solve.session.lib.mjs +52 -19
- package/src/solve.tool-uncommitted.lib.mjs +22 -0
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +17 -65
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-merge-queue.lib.mjs +3 -155
- package/src/telegram-solve-queue.lib.mjs +9 -168
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/use-m-bootstrap.lib.mjs +6 -5
- package/src/use-with-retry.lib.mjs +128 -2
- package/src/working-session-summary.lib.mjs +47 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle-only Formal AI image updates with non-destructive memory migration
|
|
3
|
+
* (issue #2146, PR #2147 review).
|
|
4
|
+
*
|
|
5
|
+
* The maintainer asked for three things this module owns:
|
|
6
|
+
*
|
|
7
|
+
* - "Auto update formal-ai docker container when no formal-ai tasks running."
|
|
8
|
+
* - "When stopped and new version available update to latest."
|
|
9
|
+
* - "In hive mind docker only initial version of formal-ai should be pinned."
|
|
10
|
+
*
|
|
11
|
+
* The safety property that makes an unattended update acceptable is Formal AI
|
|
12
|
+
* 0.336.0's persisted-memory upgrade contract (formal-ai#982). Every update
|
|
13
|
+
* follows the same sequence upstream validates in its own container fixture:
|
|
14
|
+
*
|
|
15
|
+
* pull → compare digests → preflight (`memory upgrade-status`, side-effect
|
|
16
|
+
* free) → `memory migrate --backup --receipt` → boot the new image and
|
|
17
|
+
* assert `/health` reports compatible memory → stop again.
|
|
18
|
+
*
|
|
19
|
+
* If any step after the migration fails, the byte-exact backup named in the
|
|
20
|
+
* receipt is restored, its SHA-256 is compared against `original_sha256`, and
|
|
21
|
+
* the previous image stays in service. An update therefore either completes or
|
|
22
|
+
* leaves memory exactly as it was; it can never half-migrate.
|
|
23
|
+
*
|
|
24
|
+
* The sidecar is left *stopped* on success, because "stop the container when no
|
|
25
|
+
* formal-ai tasks are running" still applies — the boot is a verification step,
|
|
26
|
+
* not a deployment.
|
|
27
|
+
*
|
|
28
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
29
|
+
* @see https://github.com/link-assistant/formal-ai/issues/982
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFile } from 'node:child_process';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import { promisify } from 'node:util';
|
|
35
|
+
|
|
36
|
+
import { FORMAL_AI_IMAGE_REPOSITORY, FORMAL_AI_MEMORY_MOUNT, FORMAL_AI_MEMORY_PATH, FORMAL_AI_MEMORY_VOLUME_NAME, FORMAL_AI_SIDECAR_CONTAINER_NAME, buildFormalAiSidecarRunArgs, ensureFormalAiMemoryVolume, ensureFormalAiNetwork, inspectDockerContainer, readDockerImageDigest, readFormalAiSidecarState, reconcileFormalAiSidecar, stopFormalAiSidecar, waitForFormalAiSidecarHealth, withFormalAiSidecarLock, writeFormalAiSidecarState } from './formal-ai-sidecar.lib.mjs';
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
|
|
41
|
+
const DEFAULT_PULL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** The moving tag every Formal AI release publishes alongside its bare version. */
|
|
44
|
+
export const FORMAL_AI_UPDATE_TAG = 'latest';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the image an update pulls.
|
|
48
|
+
*
|
|
49
|
+
* `HIVE_MIND_FORMAL_AI_IMAGE` is an operator pin — a deliberate choice of a
|
|
50
|
+
* specific build — so it disables auto-update rather than being silently
|
|
51
|
+
* overwritten.
|
|
52
|
+
*/
|
|
53
|
+
export const resolveFormalAiUpdateImage = (env = process.env) => `${FORMAL_AI_IMAGE_REPOSITORY}:${String(env.HIVE_MIND_FORMAL_AI_UPDATE_TAG || '').trim() || FORMAL_AI_UPDATE_TAG}`;
|
|
54
|
+
|
|
55
|
+
/** Auto-update is on by default and off when the operator pinned an image or opted out. */
|
|
56
|
+
export const isFormalAiAutoUpdateEnabled = (env = process.env) => {
|
|
57
|
+
if (String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim()) return false;
|
|
58
|
+
const raw = String(env.HIVE_MIND_FORMAL_AI_AUTO_UPDATE ?? '')
|
|
59
|
+
.trim()
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
if (!raw) return true;
|
|
62
|
+
return !['0', 'false', 'no', 'off'].includes(raw);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract the first JSON object in a command's output.
|
|
67
|
+
*
|
|
68
|
+
* The published image's DinD entrypoint can print its own banner before the
|
|
69
|
+
* command runs, so the payload is not always the whole of stdout.
|
|
70
|
+
*/
|
|
71
|
+
export const parseJsonBlock = text => {
|
|
72
|
+
const raw = String(text ?? '');
|
|
73
|
+
const start = raw.indexOf('{');
|
|
74
|
+
const end = raw.lastIndexOf('}');
|
|
75
|
+
if (start < 0 || end <= start) throw new Error(`Expected a JSON object in Formal AI output but found none: ${raw.slice(0, 400)}`);
|
|
76
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** `parseJsonBlock` for output that is allowed to contain no JSON at all. */
|
|
80
|
+
export const tryParseJsonBlock = text => {
|
|
81
|
+
try {
|
|
82
|
+
return parseJsonBlock(text);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Human-readable one-liner for the two refusal shapes the memory CLI prints:
|
|
90
|
+
* an upgrade status with `refusal_code`/`refusal_reason`, and a migration
|
|
91
|
+
* error object with `code`/`message`.
|
|
92
|
+
*/
|
|
93
|
+
export const describeMemoryRefusal = payload => {
|
|
94
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
95
|
+
if (payload.error?.code || payload.error?.message) return `${payload.error.code ?? 'error'}: ${payload.error.message ?? 'no message given'}`;
|
|
96
|
+
if (payload.refusal_code || payload.refusal_reason) return `${payload.refusal_code ?? 'incompatible'}: ${payload.refusal_reason ?? 'no reason given'}`;
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
|
|
101
|
+
const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });
|
|
102
|
+
return String(result?.stdout ?? '').trim();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run one `formal-ai memory …` subcommand against the persisted-memory volume
|
|
107
|
+
* using a throwaway container of the given image.
|
|
108
|
+
*
|
|
109
|
+
* The inner Docker daemon is skipped (`DIND_SKIP_DAEMON=1`) and the image's own
|
|
110
|
+
* entrypoint is kept, so the command runs as `box` and can read the volume.
|
|
111
|
+
*/
|
|
112
|
+
export const runFormalAiMemoryCommand = async (image, memoryArgs, { run = execFileAsync, timeoutMs } = {}) => {
|
|
113
|
+
const args = ['run', '--rm', '--env', 'DIND_SKIP_DAEMON=1', '--env', `FORMAL_AI_MEMORY_PATH=${FORMAL_AI_MEMORY_PATH}`, '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, 'formal-ai', 'memory', ...memoryArgs];
|
|
114
|
+
try {
|
|
115
|
+
return parseJsonBlock(await dockerText(run, args, { timeoutMs }));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// A refusal is *also* a documented answer: `memory upgrade-status` and
|
|
118
|
+
// `memory migrate` print their JSON on stdout and only then exit nonzero.
|
|
119
|
+
// Re-throwing the bare process error would discard the refusal code the
|
|
120
|
+
// operator needs, so the payload travels with the failure.
|
|
121
|
+
const payload = tryParseJsonBlock(error?.stdout);
|
|
122
|
+
if (!payload) throw error;
|
|
123
|
+
const failure = new Error(describeMemoryRefusal(payload) || error?.message || 'Formal AI refused the persisted memory');
|
|
124
|
+
failure.payload = payload;
|
|
125
|
+
throw failure;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Byte-exact SHA-256 of a file inside the memory volume, or null when it is absent. */
|
|
130
|
+
export const readMemoryFileSha256 = async (image, filePath, { run = execFileAsync, timeoutMs } = {}) => {
|
|
131
|
+
try {
|
|
132
|
+
const raw = await dockerText(run, ['run', '--rm', '--entrypoint', 'sha256sum', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, filePath], { timeoutMs });
|
|
133
|
+
return raw.split(/\s+/)[0] || null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Restore the byte-exact backup recorded in a migration receipt.
|
|
141
|
+
*
|
|
142
|
+
* `cp` + `sync` is the rollback strategy the upgrade contract documents
|
|
143
|
+
* (`rollback_strategy: "restore_backup_path"`). Success is only claimed when
|
|
144
|
+
* the restored file hashes to the receipt's `original_sha256`.
|
|
145
|
+
*/
|
|
146
|
+
export const rollbackFormalAiMemory = async ({ image, receipt, run = execFileAsync, timeoutMs, log = null } = {}) => {
|
|
147
|
+
const backupPath = receipt?.backup_path;
|
|
148
|
+
if (!backupPath) return { restored: false, verified: false, error: 'the migration receipt named no backup path' };
|
|
149
|
+
|
|
150
|
+
const memoryPath = receipt.memory_path || FORMAL_AI_MEMORY_PATH;
|
|
151
|
+
try {
|
|
152
|
+
await dockerText(run, ['run', '--rm', '--entrypoint', 'sh', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, image, '-c', `cp -- '${backupPath}' '${memoryPath}' && sync`], { timeoutMs });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { restored: false, verified: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const restoredSha = await readMemoryFileSha256(image, memoryPath, { run, timeoutMs });
|
|
158
|
+
const verified = Boolean(receipt.original_sha256) && restoredSha === receipt.original_sha256;
|
|
159
|
+
if (log) await log(verified ? `↩️ Formal AI memory restored byte-exactly from ${backupPath} (sha256 ${restoredSha})` : `🚨 Formal AI memory rollback could not be verified: expected sha256 ${receipt.original_sha256}, found ${restoredSha}`);
|
|
160
|
+
return { restored: true, verified, sha256: restoredSha, error: verified ? null : 'restored file did not match the receipt SHA-256' };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Boot the sidecar on `image`, wait for `/health`, then stop it again.
|
|
165
|
+
*
|
|
166
|
+
* This is how an update proves the new binary can actually serve the migrated
|
|
167
|
+
* memory before the change is accepted.
|
|
168
|
+
*/
|
|
169
|
+
const verifyImageServesMemory = async ({ image, env, fsImpl, run, timeoutMs, log, verbose, sleepImpl, healthAttempts, healthDelayMs }) => {
|
|
170
|
+
await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
|
|
171
|
+
const existing = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
172
|
+
if (existing.exists) await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log: null, verbose, reason: 'verification restart' });
|
|
173
|
+
|
|
174
|
+
await dockerText(run, buildFormalAiSidecarRunArgs({ image, env }), { timeoutMs });
|
|
175
|
+
const health = await waitForFormalAiSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
|
|
176
|
+
const memory = health.health?.memory ?? null;
|
|
177
|
+
const ok = health.healthy && memory?.compatible !== false && memory?.migration_required !== true;
|
|
178
|
+
await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log: null, verbose, reason: 'verification complete' });
|
|
179
|
+
return { ok, health: health.health, error: ok ? null : health.error || `memory not ready after update (state=${memory?.migration_state ?? 'unknown'}, required=${memory?.migration_required ?? 'unknown'})` };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Update the Formal AI sidecar image, but only while no Formal AI task holds a
|
|
184
|
+
* lease.
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<{status: 'disabled'|'busy'|'up-to-date'|'updated'|'rolled-back'|'failed', ...}>}
|
|
187
|
+
*/
|
|
188
|
+
export const updateFormalAiSidecarWhenIdle = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, pullTimeoutMs = DEFAULT_PULL_TIMEOUT_MS, log = null, verbose = false, sleepImpl, healthAttempts, healthDelayMs, now = () => new Date(), lockOptions = {} } = {}) => {
|
|
189
|
+
if (!isFormalAiAutoUpdateEnabled(env)) {
|
|
190
|
+
if (verbose && log) await log('[VERBOSE] formal-ai-updater: auto-update disabled (operator pin or HIVE_MIND_FORMAL_AI_AUTO_UPDATE=0)');
|
|
191
|
+
return { status: 'disabled', reason: String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim() ? 'HIVE_MIND_FORMAL_AI_IMAGE pins a specific build' : 'HIVE_MIND_FORMAL_AI_AUTO_UPDATE is off' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return withFormalAiSidecarLock(
|
|
195
|
+
async () => {
|
|
196
|
+
const { leaseCount } = await reconcileFormalAiSidecar({ env, fsImpl, run, timeoutMs, log, verbose });
|
|
197
|
+
if (leaseCount > 0) {
|
|
198
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-updater: ${leaseCount} Formal AI task(s) running; deferring the update`);
|
|
199
|
+
return { status: 'busy', leaseCount };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const image = resolveFormalAiUpdateImage(env);
|
|
203
|
+
const previousDigest = await readDockerImageDigest(image, { run, timeoutMs });
|
|
204
|
+
|
|
205
|
+
if (log) await log(`⬇️ Checking for a newer Formal AI image (${image})`);
|
|
206
|
+
try {
|
|
207
|
+
await dockerText(run, ['pull', '--quiet', image], { timeoutMs: pullTimeoutMs });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
210
|
+
if (log) await log(`⚠️ Could not pull ${image}; keeping the current Formal AI image: ${message}`);
|
|
211
|
+
return { status: 'failed', stage: 'pull', image, error: message };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const pulledDigest = await readDockerImageDigest(image, { run, timeoutMs });
|
|
215
|
+
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
216
|
+
const runningDigest = state.imageDigest || previousDigest;
|
|
217
|
+
if (pulledDigest && pulledDigest === runningDigest) {
|
|
218
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-updater: ${image} already at ${pulledDigest}`);
|
|
219
|
+
return { status: 'up-to-date', image, digest: pulledDigest };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Memory must not be touched while a container has it open.
|
|
223
|
+
await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log, verbose, reason: 'updating the Formal AI image' });
|
|
224
|
+
await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
|
|
225
|
+
|
|
226
|
+
let preflight;
|
|
227
|
+
try {
|
|
228
|
+
preflight = await runFormalAiMemoryCommand(image, ['upgrade-status', '--path', FORMAL_AI_MEMORY_PATH, '--format', 'json'], { run, timeoutMs });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
const message = error?.payload ? error.message : error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
231
|
+
if (log) await log(`🚨 Formal AI ${image} refused the persisted memory during preflight; keeping ${runningDigest ?? 'the current image'}: ${message}`);
|
|
232
|
+
return { status: 'failed', stage: 'preflight', image, digest: pulledDigest, preflight: error?.payload ?? null, error: message };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (preflight.compatible === false) {
|
|
236
|
+
if (log) await log(`🚨 Formal AI ${image} reports the persisted memory as incompatible (${preflight.refusal_code ?? 'unknown'}: ${preflight.refusal_reason ?? 'no reason given'}); update abandoned`);
|
|
237
|
+
return { status: 'failed', stage: 'preflight', image, digest: pulledDigest, preflight, error: preflight.refusal_reason || 'incompatible memory' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let receipt = null;
|
|
241
|
+
if (preflight.migration_required && preflight.path_exists) {
|
|
242
|
+
const backupPath = `${FORMAL_AI_MEMORY_MOUNT}/memory.lino.schema-${preflight.detected_schema_version ?? 'unknown'}.bak`;
|
|
243
|
+
const receiptPath = `${FORMAL_AI_MEMORY_MOUNT}/memory-upgrade-receipt.json`;
|
|
244
|
+
if (log) await log(`🧬 Migrating Formal AI memory from schema ${preflight.detected_schema_version} to ${preflight.target_schema_version} (${preflight.migration_id ?? 'unnamed migration'})`);
|
|
245
|
+
try {
|
|
246
|
+
receipt = await runFormalAiMemoryCommand(image, ['migrate', '--path', FORMAL_AI_MEMORY_PATH, '--backup', backupPath, '--receipt', receiptPath, '--format', 'json'], { run, timeoutMs });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
const message = error?.payload ? error.message : error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
249
|
+
// The contract is refuse-or-succeed: a failed migrate leaves the file alone.
|
|
250
|
+
if (log) await log(`🚨 Formal AI memory migration refused to modify the file; keeping the current image: ${message}`);
|
|
251
|
+
return { status: 'failed', stage: 'migrate', image, digest: pulledDigest, preflight, refusal: error?.payload ?? null, error: message };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A preflight that mutated the file would break the contract's core
|
|
255
|
+
// promise, so prove it did not before trusting the rest.
|
|
256
|
+
if (preflight.source_sha256 && receipt.original_sha256 && preflight.source_sha256 !== receipt.original_sha256) {
|
|
257
|
+
if (log) await log(`🚨 Formal AI preflight changed the memory file (${preflight.source_sha256} → ${receipt.original_sha256}); rolling back`);
|
|
258
|
+
const rollback = await rollbackFormalAiMemory({ image, receipt, run, timeoutMs, log });
|
|
259
|
+
return { status: 'rolled-back', stage: 'preflight-side-effect', image, digest: pulledDigest, preflight, receipt, rollback };
|
|
260
|
+
}
|
|
261
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-updater: migration ${receipt.migration_id} ${receipt.from_schema_version}→${receipt.to_schema_version}, ${receipt.event_count} event(s), backup ${receipt.backup_path}, rollback=${receipt.rollback_supported}`);
|
|
262
|
+
} else if (verbose && log) {
|
|
263
|
+
await log(`[VERBOSE] formal-ai-updater: no memory migration required (schema ${preflight.detected_schema_version ?? 'none'}, state ${preflight.migration_state})`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const verification = await verifyImageServesMemory({ image, env, fsImpl, run, timeoutMs, log, verbose, sleepImpl, healthAttempts, healthDelayMs });
|
|
267
|
+
if (!verification.ok) {
|
|
268
|
+
if (log) await log(`🚨 Formal AI ${image} could not serve the persisted memory after the update: ${verification.error}`);
|
|
269
|
+
const rollback = receipt ? await rollbackFormalAiMemory({ image, receipt, run, timeoutMs, log }) : { restored: false, verified: true, error: null };
|
|
270
|
+
return { status: 'rolled-back', stage: 'verify', image, digest: pulledDigest, preflight, receipt, rollback, error: verification.error };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const updatedAt = now().toISOString();
|
|
274
|
+
writeFormalAiSidecarState({ ...readFormalAiSidecarState({ env, fsImpl }), image, imageDigest: pulledDigest, lastUpdate: { image, digest: pulledDigest, previousDigest: runningDigest, version: verification.health?.version ?? null, migrationId: receipt?.migration_id ?? null, memorySchemaVersion: verification.health?.memory?.schema_version ?? null, updatedAt } }, { env, fsImpl });
|
|
275
|
+
|
|
276
|
+
if (log) await log(`✅ Formal AI updated to ${verification.health?.version ?? pulledDigest}${receipt ? ` (memory migrated ${receipt.from_schema_version}→${receipt.to_schema_version}, backup ${receipt.backup_path})` : ''}; the sidecar stays stopped until a Formal AI task needs it`);
|
|
277
|
+
return { status: 'updated', image, digest: pulledDigest, previousDigest: runningDigest, preflight, receipt, health: verification.health, updatedAt };
|
|
278
|
+
},
|
|
279
|
+
{ env, fsImpl, sleepImpl, log, ...lockOptions }
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export default {
|
|
284
|
+
FORMAL_AI_UPDATE_TAG,
|
|
285
|
+
describeMemoryRefusal,
|
|
286
|
+
isFormalAiAutoUpdateEnabled,
|
|
287
|
+
parseJsonBlock,
|
|
288
|
+
tryParseJsonBlock,
|
|
289
|
+
readMemoryFileSha256,
|
|
290
|
+
resolveFormalAiUpdateImage,
|
|
291
|
+
rollbackFormalAiMemory,
|
|
292
|
+
runFormalAiMemoryCommand,
|
|
293
|
+
updateFormalAiSidecarWhenIdle,
|
|
294
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Formal AI 0.326.1 fixed plans that were recorded but never executed and
|
|
10
|
+
* 0.333.2 added the tool-result evidence fixes required by issues #2119/#2130.
|
|
11
|
+
* 0.336.0 is the first release that answers formal-ai#982: `memory
|
|
12
|
+
* upgrade-status`, `memory migrate --backup --receipt`, and the `/health`
|
|
13
|
+
* memory compatibility block. Hive Mind now replaces the Formal AI container
|
|
14
|
+
* while it is idle, so an unattended non-destructive memory upgrade is part of
|
|
15
|
+
* the baseline rather than an optional extra.
|
|
16
|
+
*/
|
|
17
|
+
export const FORMAL_AI_MINIMUM_VERSION = '0.336.0';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The first release exposing the persisted-memory upgrade contract. Kept
|
|
21
|
+
* separate from {@link FORMAL_AI_MINIMUM_VERSION} so the container updater can
|
|
22
|
+
* state precisely why a candidate image is refused even if the run-time floor
|
|
23
|
+
* later moves for an unrelated reason.
|
|
24
|
+
*/
|
|
25
|
+
export const FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION = '0.336.0';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The version baked into Hive Mind's images. Per the maintainer's review on
|
|
29
|
+
* PR #2147 this is the *initial* pin only: once the container is running,
|
|
30
|
+
* `src/formal-ai-updater.lib.mjs` replaces it with the newest published image
|
|
31
|
+
* while no Formal AI task holds a lease. 0.339.0 restored `cargo install
|
|
32
|
+
* formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routes
|
|
33
|
+
* command execution through the published command-stream component; the
|
|
34
|
+
* memory-contract sources (`src/cli_memory.rs`, `src/server.rs`,
|
|
35
|
+
* `src/shared_memory.rs`) are byte-identical to 0.337.0 and
|
|
36
|
+
* `src/memory/upgrade.rs` only adds an explicit advisory-lock release.
|
|
37
|
+
*/
|
|
38
|
+
export const FORMAL_AI_BOOTSTRAP_VERSION = '0.339.1';
|
|
39
|
+
|
|
40
|
+
export const parseFormalAiVersion = stdout => {
|
|
41
|
+
const line = String(stdout || '')
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map(entry => entry.trim())
|
|
44
|
+
.find(Boolean);
|
|
45
|
+
if (!line) return null;
|
|
46
|
+
return line.replace(/^formal-ai\s+/i, '').trim() || null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Read a Formal AI binary's version without allowing the probe to throw. */
|
|
50
|
+
export const readFormalAiBinaryVersion = async ({ formalAiPath = 'formal-ai', env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
51
|
+
try {
|
|
52
|
+
const result = await run(formalAiPath, ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
|
|
53
|
+
return parseFormalAiVersion(result?.stdout ?? result);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const parseComparableVersion = version => {
|
|
60
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version || ''));
|
|
61
|
+
if (!match) return null;
|
|
62
|
+
return { core: match.slice(1, 4).map(Number), prerelease: match[4] || null };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const isFormalAiVersionAtLeast = (version, minimumVersion) => {
|
|
66
|
+
const candidate = parseComparableVersion(version);
|
|
67
|
+
const minimum = parseComparableVersion(minimumVersion);
|
|
68
|
+
if (!candidate || !minimum) return false;
|
|
69
|
+
for (let index = 0; index < candidate.core.length; index += 1) {
|
|
70
|
+
if (candidate.core[index] !== minimum.core[index]) return candidate.core[index] > minimum.core[index];
|
|
71
|
+
}
|
|
72
|
+
// A prerelease is lower than the stable release with the same numeric core.
|
|
73
|
+
if (candidate.prerelease && !minimum.prerelease) return false;
|
|
74
|
+
if (!candidate.prerelease && minimum.prerelease) return true;
|
|
75
|
+
return !candidate.prerelease || candidate.prerelease >= minimum.prerelease;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Reject unknown and stale binaries before a model server or native CLI starts. */
|
|
79
|
+
export const assertSupportedFormalAiVersion = (version, minimumVersion = FORMAL_AI_MINIMUM_VERSION) => {
|
|
80
|
+
if (!version) {
|
|
81
|
+
throw new Error(`Could not determine the Formal AI version; Hive Mind requires Formal AI >= ${minimumVersion}. Check HIVE_MIND_FORMAL_AI_PATH and upgrade Formal AI.`);
|
|
82
|
+
}
|
|
83
|
+
if (!parseComparableVersion(version)) {
|
|
84
|
+
throw new Error(`Hive Mind requires Formal AI >= ${minimumVersion}, but formal-ai --version returned an invalid version: ${version}`);
|
|
85
|
+
}
|
|
86
|
+
if (!isFormalAiVersionAtLeast(version, minimumVersion)) {
|
|
87
|
+
throw new Error(`Hive Mind requires Formal AI >= ${minimumVersion}, found ${version}. Upgrade Formal AI before retrying.`);
|
|
88
|
+
}
|
|
89
|
+
return version;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export default {
|
|
93
|
+
assertSupportedFormalAiVersion,
|
|
94
|
+
FORMAL_AI_BOOTSTRAP_VERSION,
|
|
95
|
+
FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION,
|
|
96
|
+
FORMAL_AI_MINIMUM_VERSION,
|
|
97
|
+
isFormalAiVersionAtLeast,
|
|
98
|
+
parseFormalAiVersion,
|
|
99
|
+
readFormalAiBinaryVersion,
|
|
100
|
+
};
|
package/src/formal-ai.lib.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { execFile } from 'node:child_process';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
|
|
6
6
|
import { findFormalAiClient, loadFormalAiClientRegistry, prepareFormalAiRuntime } from './formal-ai-runtime.lib.mjs';
|
|
7
|
+
import { assertSupportedFormalAiVersion, parseFormalAiVersion, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
|
|
7
8
|
import { isFormalAiModel } from './models/index.mjs';
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
@@ -112,28 +113,16 @@ export const buildFormalAiEnvExports = env =>
|
|
|
112
113
|
* `formal-ai --version` prints a line such as "formal-ai 0.317.0"; keep only the
|
|
113
114
|
* version so the log records a value that can be compared against a release.
|
|
114
115
|
*/
|
|
115
|
-
export
|
|
116
|
-
const line = String(stdout || '')
|
|
117
|
-
.split('\n')
|
|
118
|
-
.map(entry => entry.trim())
|
|
119
|
-
.find(Boolean);
|
|
120
|
-
if (!line) return null;
|
|
121
|
-
return line.replace(/^formal-ai\s+/i, '').trim() || null;
|
|
122
|
-
};
|
|
116
|
+
export { parseFormalAiVersion };
|
|
123
117
|
|
|
124
118
|
/**
|
|
125
119
|
* The wrapper's behaviour changes between releases — issue #2130's round-2
|
|
126
120
|
* failures could not be pinned to a mechanism because no log recorded which
|
|
127
|
-
* wrapper produced them.
|
|
128
|
-
*
|
|
121
|
+
* wrapper produced them. This shared reader remains non-throwing so connection
|
|
122
|
+
* validation and runtime preparation can apply one actionable support policy.
|
|
129
123
|
*/
|
|
130
124
|
export const readFormalAiVersion = async ({ env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
131
|
-
|
|
132
|
-
const result = await run(resolveFormalAiPath(env), ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
|
|
133
|
-
return parseFormalAiVersion(result?.stdout);
|
|
134
|
-
} catch {
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
125
|
+
return readFormalAiBinaryVersion({ formalAiPath: resolveFormalAiPath(env), env, run, timeoutMs });
|
|
137
126
|
};
|
|
138
127
|
|
|
139
128
|
/**
|
|
@@ -145,6 +134,12 @@ export const validateFormalAiToolConnection = async (tool, { env = process.env,
|
|
|
145
134
|
const args = ['clients', '--format', 'json'];
|
|
146
135
|
const formalAiVersion = await readFormalAiVersion({ env, run, timeoutMs });
|
|
147
136
|
|
|
137
|
+
try {
|
|
138
|
+
assertSupportedFormalAiVersion(formalAiVersion);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { valid: false, command, args, formalAiVersion, error: error.message };
|
|
141
|
+
}
|
|
142
|
+
|
|
148
143
|
let clients;
|
|
149
144
|
try {
|
|
150
145
|
clients = await loadFormalAiClientRegistry({ formalAiPath: command, run, env, timeoutMs });
|
|
@@ -462,6 +462,9 @@ export const execGhWithRetry = async (command, options = {}) => {
|
|
|
462
462
|
* @returns {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>}
|
|
463
463
|
*/
|
|
464
464
|
export const wrapDollarWithGhRetry = (dollar, options = {}) => {
|
|
465
|
+
if (typeof dollar !== 'function') {
|
|
466
|
+
throw new TypeError(`Expected command-stream's $ export to be a function, received ${typeof dollar}. Enable HIVE_MIND_USE_M_DEBUG=1 for loader diagnostics.`);
|
|
467
|
+
}
|
|
465
468
|
const wrapped = (strings, ...values) => {
|
|
466
469
|
// Options-call form: `$({ mirror: false })` returns a new tag bound to
|
|
467
470
|
// those options. Template literals always arrive as an array of quasis, so
|