@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,106 @@
1
+ /**
2
+ * Background maintenance for the Formal AI sidecar and the agentic CLIs
3
+ * (issue #2146, PR #2147 review).
4
+ *
5
+ * One periodic tick performs the three idle-only duties the review asked for,
6
+ * in the only order that is safe:
7
+ *
8
+ * 1. **Reconcile and stop.** Drop leases whose task container is gone and, if
9
+ * nothing is left, stop the sidecar. Must run first — the update and the
10
+ * CLI refresh both require an idle host, and a crashed task would
11
+ * otherwise keep the sidecar alive forever.
12
+ * 2. **Update the Formal AI image**, including the non-destructive memory
13
+ * migration, while the sidecar is stopped.
14
+ * 3. **Refresh the agentic CLIs**, which is throttled independently because
15
+ * it queries the npm registry.
16
+ *
17
+ * Every step is best-effort: maintenance must never take the bot down, and a
18
+ * failure is reported and retried on the next tick rather than thrown.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
21
+ */
22
+
23
+ import { updateAgenticClisWhenIdle } from './agentic-cli-updater.lib.mjs';
24
+ import { reconcileFormalAiSidecar, stopFormalAiSidecar, withFormalAiSidecarLock } from './formal-ai-sidecar.lib.mjs';
25
+ import { updateFormalAiSidecarWhenIdle } from './formal-ai-updater.lib.mjs';
26
+
27
+ /** Default gap between maintenance ticks. */
28
+ export const DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS = 5 * 60 * 1000;
29
+
30
+ /**
31
+ * Stop the sidecar when no Formal AI task holds a lease any more.
32
+ *
33
+ * This is what turns "the task finished" into "the container is gone" without
34
+ * having to hook every completion path: a lease is only live while its task
35
+ * container is, so a finished, killed or crashed task all converge here.
36
+ *
37
+ * @returns {Promise<{leaseCount: number, stopped: boolean}>}
38
+ */
39
+ export const stopIdleFormalAiSidecar = async ({ env = process.env, run, log = null, verbose = false, lockOptions = {} } = {}) =>
40
+ withFormalAiSidecarLock(
41
+ async () => {
42
+ const { leaseCount, container } = await reconcileFormalAiSidecar({ env, run, log, verbose });
43
+ if (leaseCount > 0 || !container.exists) return { leaseCount, stopped: false };
44
+ const { stopped } = await stopFormalAiSidecar({ env, run, log, verbose, reason: 'no Formal AI tasks running' });
45
+ return { leaseCount: 0, stopped };
46
+ },
47
+ { env, log, ...lockOptions }
48
+ );
49
+
50
+ /**
51
+ * Run one maintenance tick.
52
+ *
53
+ * @returns {Promise<{idle: object, formalAi: object|null, agenticClis: object|null, errors: object[]}>}
54
+ */
55
+ export const runFormalAiMaintenanceTick = async ({ env = process.env, run, log = null, verbose = false, updateFormalAi = updateFormalAiSidecarWhenIdle, updateClis = updateAgenticClisWhenIdle, stopIdle = stopIdleFormalAiSidecar } = {}) => {
56
+ const errors = [];
57
+
58
+ let idle = { leaseCount: null, stopped: false };
59
+ try {
60
+ idle = await stopIdle({ env, run, log, verbose });
61
+ } catch (error) {
62
+ errors.push({ stage: 'stop-idle', error: error?.message || String(error) });
63
+ }
64
+
65
+ let formalAi = null;
66
+ try {
67
+ formalAi = await updateFormalAi({ env, run, log, verbose });
68
+ } catch (error) {
69
+ errors.push({ stage: 'formal-ai-update', error: error?.message || String(error) });
70
+ }
71
+
72
+ let agenticClis = null;
73
+ try {
74
+ agenticClis = await updateClis({ env, run, log, verbose });
75
+ } catch (error) {
76
+ errors.push({ stage: 'agentic-cli-update', error: error?.message || String(error) });
77
+ }
78
+
79
+ if (log && errors.length > 0) await log(`⚠️ Formal AI maintenance tick had ${errors.length} problem(s): ${errors.map(entry => `${entry.stage}: ${entry.error}`).join('; ')}`);
80
+ if (verbose && log) await log(`[VERBOSE] formal-ai-maintenance: leases=${idle.leaseCount ?? 'unknown'} stopped=${idle.stopped} update=${formalAi?.status ?? 'skipped'} clis=${agenticClis?.status ?? 'skipped'}`);
81
+ return { idle, formalAi, agenticClis, errors };
82
+ };
83
+
84
+ /**
85
+ * Start the periodic maintenance timer.
86
+ *
87
+ * The timer is unref'd so it never keeps the process alive on shutdown.
88
+ *
89
+ * @returns {{stop: () => void}}
90
+ */
91
+ export const startFormalAiMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runFormalAiMaintenanceTick } = {}) => {
92
+ const tick = () => {
93
+ runTick({ env, log, verbose }).catch(error => {
94
+ console.error(`[formal-ai-maintenance] tick failed: ${error?.message || error}`);
95
+ });
96
+ };
97
+
98
+ const timer = setIntervalImpl(tick, intervalMs);
99
+ timer?.unref?.();
100
+ tick();
101
+ return {
102
+ stop: () => clearIntervalImpl(timer),
103
+ };
104
+ };
105
+
106
+ export default { DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, runFormalAiMaintenanceTick, startFormalAiMaintenance, stopIdleFormalAiSidecar };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Formal AI model identity — the smallest module that can answer "is this task
3
+ * driven by Formal AI?".
4
+ *
5
+ * These three symbols used to live in `src/models/index.mjs`, which bootstraps
6
+ * `use-m` at import time and therefore reaches the network. The Formal AI
7
+ * sidecar lifecycle (issue #2146) is imported by the isolation runner, whose
8
+ * own regression test asserts that importing it performs no network fetch, so
9
+ * the identity check has to be reachable without dragging the whole model
10
+ * catalogue in. `src/models/index.mjs` re-exports these so there is still a
11
+ * single definition.
12
+ *
13
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
14
+ */
15
+
16
+ /** The alias users type: `--model formal-ai`. */
17
+ export const FORMAL_AI_MODEL_ALIAS = 'formal-ai';
18
+
19
+ /** The provider-qualified id used when a tool routes through a provider. */
20
+ export const FORMAL_AI_PROVIDER_MODEL_ID = 'formalai/formal-ai';
21
+
22
+ /** True when a model string names Formal AI in either spelling. */
23
+ export const isFormalAiModel = model => model === FORMAL_AI_MODEL_ALIAS || model === FORMAL_AI_PROVIDER_MODEL_ID;
24
+
25
+ export default { FORMAL_AI_MODEL_ALIAS, FORMAL_AI_PROVIDER_MODEL_ID, isFormalAiModel };
@@ -53,6 +53,8 @@ import { homedir } from 'node:os';
53
53
  import { dirname, join } from 'node:path';
54
54
  import { promisify } from 'node:util';
55
55
 
56
+ import { assertSupportedFormalAiVersion, FORMAL_AI_MINIMUM_VERSION, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
57
+
56
58
  const execFileAsync = promisify(execFile);
57
59
 
58
60
  export const FORMAL_AI_DEFAULT_API_KEY = 'formal-ai';
@@ -359,6 +361,13 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
359
361
 
360
362
  installExitHook();
361
363
 
364
+ // Issue #2146: `--no-tool-check` skipped the only version probe, allowing an
365
+ // old Formal AI build to return the same unexecuted plan through all five
366
+ // Claude/Codex restarts. Runtime safety cannot depend on preflight options.
367
+ const formalAiVersion = await (deps.readVersionImpl || readFormalAiBinaryVersion)({ formalAiPath: resolvedFormalAiPath, env });
368
+ assertSupportedFormalAiVersion(formalAiVersion);
369
+ await log(`🧠 Formal AI: version ${formalAiVersion} (minimum ${FORMAL_AI_MINIMUM_VERSION})`);
370
+
362
371
  const apiKey = resolveFormalAiApiKey(env);
363
372
  const externalBaseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL?.trim() || null;
364
373
  const homeRoot = resolveFormalAiHomeRoot(env);
@@ -420,6 +429,7 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
420
429
  client,
421
430
  notes,
422
431
  serverStarted: !!server,
432
+ formalAiVersion,
423
433
  stop: async () => {
424
434
  runtimeCache.delete(cacheKey);
425
435
  await server?.stop?.();