@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,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
+ };
@@ -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 const parseFormalAiVersion = stdout => {
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. Ask the wrapper for its version, but never let that
128
- * question decide whether the run may proceed.
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
- try {
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 });
@@ -21,6 +21,7 @@ import fs from 'node:fs';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
24
+ import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseFormalAiSidecarForTask } from './formal-ai-isolation.lib.mjs';
24
25
 
25
26
  let commandStreamDollarPromise = null;
26
27
 
@@ -670,11 +671,21 @@ export async function executeWithIsolation(command, args, options = {}) {
670
671
  console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
671
672
  }
672
673
 
674
+ // Issue #2146 / PR #2147 review: a Formal AI task gets its own sidecar,
675
+ // started on demand and reachable only over an internal Docker network. The
676
+ // lease is taken before the container is launched so the endpoint is known
677
+ // when the task's environment is built, and released again if the launch
678
+ // fails. Fail closed — a Formal AI task must never start without Formal AI.
679
+ const hostEnv = options.env || process.env;
680
+ const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
681
+ if (sidecarError) return { success: false, sessionId, output: '', error: sidecarError };
682
+
683
+ const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
673
684
  const effectiveOptions =
674
685
  backend === 'docker'
675
686
  ? {
676
687
  ...options,
677
- env: await resolveFormalAiIsolationEnv(options.env || process.env),
688
+ env: await resolveFormalAiIsolationEnv(taskEnv),
678
689
  }
679
690
  : options;
680
691
  const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
@@ -704,14 +715,34 @@ export async function executeWithIsolation(command, args, options = {}) {
704
715
  }
705
716
 
706
717
  let containerFilesystemStartBytes = null;
718
+ let formalAiAttachError = null;
707
719
  if (result.success && backend === 'docker') {
708
720
  try {
709
721
  containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
722
+ // The task command is still held by the start gate — the only safe
723
+ // moment to add a second network. `docker network connect` is additive;
724
+ // a single `docker run --network` would replace the default bridge and
725
+ // cut the task off from GitHub (issue #2146). start-command 0.32.0+
726
+ // could attach both networks at launch (repeatable `--network`,
727
+ // start#156), but it implements that with this very create → connect →
728
+ // start sequence, and doing it here keeps the attach fail-closed on any
729
+ // installed version instead of silently one-network on older parsers.
730
+ formalAiAttachError = await attachFormalAiTaskContainer({ sidecar, sessionId, verbose });
710
731
  } finally {
711
732
  await releaseDockerContainerStartGate(sessionId, verbose);
712
733
  }
713
734
  }
714
735
 
736
+ if (sidecar && (!result.success || formalAiAttachError)) {
737
+ // Fail closed: without the internal network the task cannot reach Formal
738
+ // AI, and issue #2146 forbids falling back to another model.
739
+ if (formalAiAttachError) await removeDockerContainer(sessionId, verbose);
740
+ await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
741
+ if (formalAiAttachError) {
742
+ return { success: false, sessionId, output: result.output, error: `Formal AI task container could not be attached to the internal Formal AI network, so the task was stopped instead of falling back to another model (issue #2146): ${formalAiAttachError}` };
743
+ }
744
+ }
745
+
715
746
  // Issue #1939: capture the freshly-launched docker session's reported status
716
747
  // and the live container state together, so the next iteration has the data to
717
748
  // diagnose a premature "executed/-1" status (problem #1) or a surprise image
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Shared `--model` extraction for command argument vectors.
3
+ *
4
+ * Issue #2146 requires the Formal AI container lifecycle to be driven by the
5
+ * *model* a task will actually run with, not by the CLI tool it happens to use
6
+ * (`--tool claude --model formal-ai` is a Formal AI task; `--tool claude
7
+ * --model opus` is not). Three call sites already needed this parse, so it
8
+ * lives in one place instead of being copied a fourth time.
9
+ *
10
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
11
+ */
12
+
13
+ /**
14
+ * Read the model requested by an argument vector.
15
+ *
16
+ * Accepts every spelling Hive Mind's commands accept: `--model <value>`,
17
+ * `-m <value>`, and `--model=<value>`.
18
+ *
19
+ * @param {string[]} args - Raw argument vector.
20
+ * @returns {string|null} The requested model, or null when none was given.
21
+ */
22
+ export const getModelFromArgs = args => {
23
+ const list = Array.isArray(args) ? args : [];
24
+ for (let index = 0; index < list.length; index += 1) {
25
+ const arg = String(list[index] ?? '');
26
+ if ((arg === '--model' || arg === '-m') && index + 1 < list.length) return list[index + 1];
27
+ if (arg.startsWith('--model=')) return arg.substring('--model='.length);
28
+ }
29
+ return null;
30
+ };
31
+
32
+ export default { getModelFromArgs };
@@ -23,13 +23,16 @@ if (typeof globalThis.use === 'undefined') {
23
23
  }
24
24
 
25
25
  import { log } from '../lib.mjs';
26
+ import { FORMAL_AI_MODEL_ALIAS, FORMAL_AI_PROVIDER_MODEL_ID, isFormalAiModel } from '../formal-ai-model.lib.mjs';
26
27
 
27
28
  const execFileAsync = promisify(execFile);
28
29
 
29
30
  // ─── MODEL DATA ──────────────────────────────────────────────────────────────
30
31
 
31
- export const FORMAL_AI_MODEL_ALIAS = 'formal-ai';
32
- export const FORMAL_AI_PROVIDER_MODEL_ID = 'formalai/formal-ai';
32
+ // Defined in a leaf module so callers that only need the identity check (the
33
+ // Formal AI sidecar lifecycle, issue #2146) do not have to import this catalogue
34
+ // and its `use-m` bootstrap. Re-exported here so the public surface is unchanged.
35
+ export { FORMAL_AI_MODEL_ALIAS, FORMAL_AI_PROVIDER_MODEL_ID, isFormalAiModel } from '../formal-ai-model.lib.mjs';
33
36
 
34
37
  const formalAiNativeModelAliases = {
35
38
  [FORMAL_AI_MODEL_ALIAS]: FORMAL_AI_MODEL_ALIAS,
@@ -41,8 +44,6 @@ const formalAiProviderModelAliases = {
41
44
  [FORMAL_AI_PROVIDER_MODEL_ID]: FORMAL_AI_PROVIDER_MODEL_ID,
42
45
  };
43
46
 
44
- export const isFormalAiModel = model => model === FORMAL_AI_MODEL_ALIAS || model === FORMAL_AI_PROVIDER_MODEL_ID;
45
-
46
47
  // Claude models (Anthropic API)
47
48
  // Updated for Opus 4.5/4.6/4.7/4.8/5, Sonnet 4.6/5, and Fable 5 / Mythos 5 support
48
49
  // (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875, Issue #2003, Issue #2096)
@@ -68,7 +68,7 @@ const { buildIssueReference, ensureIssueLinkInPullRequestBody } = prIssueLinking
68
68
 
69
69
  // Issue #2119: the one place that decides whether a pull request changed anything.
70
70
  const { formatChangeSummary, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
71
- const { buildNoChangesNotice, redactWorkspacePaths } = await import('./working-session-summary.lib.mjs');
71
+ const { buildNoChangesNotice, formatWorkingSessionSummaryMarkdown, redactWorkspacePaths } = await import('./working-session-summary.lib.mjs');
72
72
 
73
73
  /**
74
74
  * Placeholder patterns used to detect auto-generated PR content that was not updated by the agent.
@@ -1309,7 +1309,7 @@ export const attachSolutionSummary = async ({ resultSummary, prNumber, issueNumb
1309
1309
  // summary said "The `pwd` command completed" and printed the solver's own
1310
1310
  // /tmp workspace, on a pull request that was still empty.
1311
1311
  const noChangesNotice = buildNoChangesNotice(changeStats);
1312
- const summaryBody = redactWorkspacePaths(resultSummary);
1312
+ const summaryBody = formatWorkingSessionSummaryMarkdown(redactWorkspacePaths(resultSummary));
1313
1313
 
1314
1314
  const comment = `${toolComments.WORKING_SESSION_SUMMARY_AUTOMATION_MARKER}
1315
1315
  ## ${toolComments.WORKING_SESSION_SUMMARY_MARKER}