@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,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand Formal AI sidecar lifecycle (issue #2146, PR #2147 review).
|
|
3
|
+
*
|
|
4
|
+
* The maintainer's review asked for a container that exists only while Formal
|
|
5
|
+
* AI work exists:
|
|
6
|
+
*
|
|
7
|
+
* 1. start the Formal AI container and connect it to the task's container
|
|
8
|
+
* over an *internal* Docker network only, and only while tasks run;
|
|
9
|
+
* 2. stop it when no Formal AI task is running;
|
|
10
|
+
* 3. update it to the newest published image while it is stopped/idle;
|
|
11
|
+
* 4. preserve memory between tasks and across container replacement.
|
|
12
|
+
*
|
|
13
|
+
* This module owns (1), (2) and (4); `./formal-ai-updater.lib.mjs` owns (3) and
|
|
14
|
+
* reuses the same durable state and the same exclusive lock so an update can
|
|
15
|
+
* never interleave with a task launch.
|
|
16
|
+
*
|
|
17
|
+
* Design notes that are easy to get wrong and therefore stated explicitly:
|
|
18
|
+
*
|
|
19
|
+
* - **Leases, not a boolean.** Concurrent `/solve --model formal-ai` runs share
|
|
20
|
+
* one sidecar. Each task holds a named lease; the sidecar is stopped only
|
|
21
|
+
* after the last lease is released. A crashed bot cannot leak a lease
|
|
22
|
+
* forever, because every reconcile re-derives liveness from Docker itself.
|
|
23
|
+
* - **Truth comes from Docker.** The JSON store is a cache. `reconcile()` drops
|
|
24
|
+
* leases whose task container is gone and adopts a sidecar that is running
|
|
25
|
+
* without a store entry, so a bot restart converges instead of orphaning.
|
|
26
|
+
* - **The memory volume is never removed.** Stopping the sidecar, replacing its
|
|
27
|
+
* image, or rolling an update back all leave `hive-mind-formal-ai-memory`
|
|
28
|
+
* in place; that named volume is the persisted memory the review requires.
|
|
29
|
+
* - **`docker network connect`, not `docker run --network`.** A single
|
|
30
|
+
* `docker run --network` *replaces* the container's default bridge, so an
|
|
31
|
+
* `--internal` network passed that way would also cut the task off from
|
|
32
|
+
* GitHub and the package registries. start-command 0.32.0+ (start#156 →
|
|
33
|
+
* start PR #157) can express both networks at launch by repeating
|
|
34
|
+
* `--network`, implemented upstream as the same create → connect → start
|
|
35
|
+
* sequence; Hive Mind keeps issuing the additive `docker network connect`
|
|
36
|
+
* itself while the start gate still holds the task command back, because
|
|
37
|
+
* that stays fail-closed on any installed start-command version instead of
|
|
38
|
+
* silently collapsing to one network on pre-0.32.0 parsers.
|
|
39
|
+
*
|
|
40
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
41
|
+
* @see https://github.com/link-assistant/hive-mind/pull/2147
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { execFile } from 'node:child_process';
|
|
45
|
+
import fs from 'node:fs';
|
|
46
|
+
import path from 'node:path';
|
|
47
|
+
import { promisify } from 'node:util';
|
|
48
|
+
|
|
49
|
+
import { FORMAL_AI_BOOTSTRAP_VERSION } from './formal-ai-version.lib.mjs';
|
|
50
|
+
import { isFormalAiModel } from './formal-ai-model.lib.mjs';
|
|
51
|
+
import { getModelFromArgs } from './model-args.lib.mjs';
|
|
52
|
+
import { resolveBotStateDir } from './session-store.lib.mjs';
|
|
53
|
+
import { withStateLock } from './state-lock.lib.mjs';
|
|
54
|
+
|
|
55
|
+
const execFileAsync = promisify(execFile);
|
|
56
|
+
|
|
57
|
+
/** Container, network, volume and alias names. Stable so reconciliation works across restarts. */
|
|
58
|
+
export const FORMAL_AI_SIDECAR_CONTAINER_NAME = 'hive-mind-formal-ai';
|
|
59
|
+
export const FORMAL_AI_SIDECAR_NETWORK_NAME = 'hive-mind-formal-ai';
|
|
60
|
+
/**
|
|
61
|
+
* The DNS alias task containers resolve. Deliberately unchanged from the
|
|
62
|
+
* Compose deployment so `HIVE_MIND_FORMAL_AI_BASE_URL` keeps its value and
|
|
63
|
+
* existing operator configuration keeps working.
|
|
64
|
+
*/
|
|
65
|
+
export const FORMAL_AI_SIDECAR_NETWORK_ALIAS = 'link-assistant-formal-ai';
|
|
66
|
+
export const FORMAL_AI_MEMORY_VOLUME_NAME = 'hive-mind-formal-ai-memory';
|
|
67
|
+
export const FORMAL_AI_SIDECAR_PORT = 8080;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Where the memory volume is mounted inside the sidecar. The published image's
|
|
71
|
+
* DinD entrypoint runs application commands as `box`, so upstream's own
|
|
72
|
+
* released-to-candidate upgrade fixture uses `/home/box/.formal-ai` rather than
|
|
73
|
+
* the image's `/root/.formal-ai` default.
|
|
74
|
+
*
|
|
75
|
+
* @see https://github.com/link-assistant/formal-ai/blob/main/experiments/issue_982_memory_upgrade/run_container_upgrade.sh
|
|
76
|
+
*/
|
|
77
|
+
export const FORMAL_AI_MEMORY_MOUNT = '/home/box/.formal-ai';
|
|
78
|
+
export const FORMAL_AI_MEMORY_PATH = `${FORMAL_AI_MEMORY_MOUNT}/memory.lino`;
|
|
79
|
+
|
|
80
|
+
/** Image published by every Formal AI release (`:latest` plus the bare version). */
|
|
81
|
+
export const FORMAL_AI_IMAGE_REPOSITORY = 'ghcr.io/link-assistant/formal-ai';
|
|
82
|
+
|
|
83
|
+
/** Applied to the sidecar, its network and its volume so reconciliation can find them. */
|
|
84
|
+
export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
|
|
85
|
+
|
|
86
|
+
const STATE_FILE_NAME = 'formal-ai-sidecar.json';
|
|
87
|
+
const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
|
|
88
|
+
const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
|
|
89
|
+
const DEFAULT_HEALTH_ATTEMPTS = 60;
|
|
90
|
+
const DEFAULT_HEALTH_DELAY_MS = 1000;
|
|
91
|
+
|
|
92
|
+
const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null });
|
|
93
|
+
|
|
94
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when a task will be driven by Formal AI.
|
|
98
|
+
*
|
|
99
|
+
* Issue #2146 requires the lifecycle to key off the *model*, never the CLI
|
|
100
|
+
* tool: `--tool claude --model formal-ai` is a Formal AI task, and
|
|
101
|
+
* `--tool claude --model opus` is not.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} params
|
|
104
|
+
* @param {string[]} [params.args] - The task's argument vector.
|
|
105
|
+
* @param {string} [params.model] - An already-resolved model, when known.
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export const isFormalAiTask = ({ args = [], model = null } = {}) => isFormalAiModel(model || getModelFromArgs(args));
|
|
109
|
+
|
|
110
|
+
/** Resolve the image the sidecar boots with. Operators may pin their own build. */
|
|
111
|
+
export const resolveFormalAiSidecarImage = (env = process.env) => String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim() || `${FORMAL_AI_IMAGE_REPOSITORY}:${FORMAL_AI_BOOTSTRAP_VERSION}`;
|
|
112
|
+
|
|
113
|
+
/** Build the endpoint origin for a host name or address. */
|
|
114
|
+
export const buildFormalAiSidecarBaseUrl = (host = FORMAL_AI_SIDECAR_NETWORK_ALIAS) => `http://${host}:${FORMAL_AI_SIDECAR_PORT}`;
|
|
115
|
+
|
|
116
|
+
/** The DNS form of the endpoint, used when the sidecar's address is unknown. */
|
|
117
|
+
export const resolveFormalAiSidecarBaseUrl = () => buildFormalAiSidecarBaseUrl(FORMAL_AI_SIDECAR_NETWORK_ALIAS);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The published image boots a Docker-in-Docker entrypoint. The sidecar only
|
|
121
|
+
* serves HTTP, so the inner daemon is skipped; `--privileged` then buys
|
|
122
|
+
* nothing and is opt-in.
|
|
123
|
+
*/
|
|
124
|
+
export const shouldRunPrivilegedFormalAiSidecar = (env = process.env) => {
|
|
125
|
+
const raw = String(env.HIVE_MIND_FORMAL_AI_PRIVILEGED ?? '')
|
|
126
|
+
.trim()
|
|
127
|
+
.toLowerCase();
|
|
128
|
+
if (!raw) return false;
|
|
129
|
+
return !['0', 'false', 'no', 'off'].includes(raw);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* On-demand lifecycle is the default. `HIVE_MIND_FORMAL_AI_SIDECAR=0` opts a
|
|
134
|
+
* deployment out — for example one that still runs a permanently-up Formal AI
|
|
135
|
+
* service from Compose and reaches it through `HIVE_MIND_FORMAL_AI_BASE_URL`.
|
|
136
|
+
*/
|
|
137
|
+
export const isFormalAiSidecarEnabled = (env = process.env) => {
|
|
138
|
+
const raw = String(env.HIVE_MIND_FORMAL_AI_SIDECAR ?? '')
|
|
139
|
+
.trim()
|
|
140
|
+
.toLowerCase();
|
|
141
|
+
if (!raw) return true;
|
|
142
|
+
return !['0', 'false', 'no', 'off'].includes(raw);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const resolveFormalAiSidecarStatePath = (env = process.env) => path.join(resolveBotStateDir(env), STATE_FILE_NAME);
|
|
146
|
+
|
|
147
|
+
/** Read the durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
|
|
148
|
+
export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(fsImpl.readFileSync(resolveFormalAiSidecarStatePath(env), 'utf8'));
|
|
151
|
+
return { ...EMPTY_STATE, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
|
|
152
|
+
} catch {
|
|
153
|
+
return { ...EMPTY_STATE, leases: [] };
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Persist the sidecar record atomically so a crash mid-write cannot corrupt it. */
|
|
158
|
+
export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => {
|
|
159
|
+
const target = resolveFormalAiSidecarStatePath(env);
|
|
160
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
161
|
+
const temporary = `${target}.tmp`;
|
|
162
|
+
fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
163
|
+
fsImpl.renameSync(temporary, target);
|
|
164
|
+
return state;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Serialize every sidecar mutation — task launches, task releases and image
|
|
169
|
+
* updates — behind one exclusive lock.
|
|
170
|
+
*
|
|
171
|
+
* Invariant 7 of the case study: a pull, CLI refresh or memory migration must
|
|
172
|
+
* never begin while a task launch is in flight, and vice versa.
|
|
173
|
+
*/
|
|
174
|
+
export const withFormalAiSidecarLock = (fn, options = {}) => withStateLock(SIDECAR_LOCK_NAME, fn, options);
|
|
175
|
+
|
|
176
|
+
const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
|
|
177
|
+
const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
|
|
178
|
+
return String(result?.stdout ?? '').trim();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const dockerOk = async (run, args, options) => {
|
|
182
|
+
try {
|
|
183
|
+
await dockerText(run, args, options);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Inspect a container without treating "absent" as an error.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
|
|
194
|
+
*/
|
|
195
|
+
export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
|
|
196
|
+
try {
|
|
197
|
+
const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
|
|
198
|
+
const [running, image, imageDigest] = raw.split('|');
|
|
199
|
+
return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
|
|
200
|
+
} catch {
|
|
201
|
+
return { exists: false, running: false, image: null, imageDigest: null };
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The sidecar's IPv4 address on the internal network.
|
|
207
|
+
*
|
|
208
|
+
* Task containers are given this address rather than the DNS alias. They are
|
|
209
|
+
* created on the default bridge and attached to the internal network only
|
|
210
|
+
* afterwards, so relying on Docker's embedded DNS being wired up
|
|
211
|
+
* post-attachment would be a needless gamble; the address cannot change during
|
|
212
|
+
* a lease, because an image replacement requires zero leases.
|
|
213
|
+
*/
|
|
214
|
+
export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => {
|
|
215
|
+
try {
|
|
216
|
+
return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Resolve the local content digest of an image reference, or null when it is absent. */
|
|
223
|
+
export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
|
|
224
|
+
try {
|
|
225
|
+
return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Create the private network the sidecar and its tasks share.
|
|
233
|
+
*
|
|
234
|
+
* `--internal` is the security requirement from the review: the Formal AI
|
|
235
|
+
* endpoint must not be published to the host and must not be reachable from
|
|
236
|
+
* any other network. An existing network that is *not* internal is a stale
|
|
237
|
+
* artifact from the Compose deployment and is replaced when nothing is
|
|
238
|
+
* attached to it.
|
|
239
|
+
*/
|
|
240
|
+
export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
241
|
+
// `null` means "absent", which is different from "present but not internal".
|
|
242
|
+
let internal = null;
|
|
243
|
+
let containers = 0;
|
|
244
|
+
try {
|
|
245
|
+
const raw = await dockerText(run, ['network', 'inspect', FORMAL_AI_SIDECAR_NETWORK_NAME, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
|
|
246
|
+
const [internalFlag, containerCount] = raw.split('|');
|
|
247
|
+
internal = internalFlag === 'true';
|
|
248
|
+
containers = Number(containerCount) || 0;
|
|
249
|
+
} catch {
|
|
250
|
+
// Absent; fall through to creation.
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (internal === true) return { created: false, internal: true };
|
|
254
|
+
|
|
255
|
+
if (internal === false) {
|
|
256
|
+
if (containers > 0) {
|
|
257
|
+
if (log) await log(`⚠️ Formal AI network '${FORMAL_AI_SIDECAR_NETWORK_NAME}' is not internal but still has ${containers} attached container(s); leaving it in place`);
|
|
258
|
+
return { created: false, internal: false };
|
|
259
|
+
}
|
|
260
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: replacing non-internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
261
|
+
await dockerOk(run, ['network', 'rm', FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await dockerText(run, ['network', 'create', '--internal', '--label', `${FORMAL_AI_SIDECAR_LABEL}=network`, FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
265
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
266
|
+
return { created: true, internal: true };
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create the persisted-memory volume if it is missing and hand it to the
|
|
271
|
+
* container's `box` user.
|
|
272
|
+
*
|
|
273
|
+
* Never removed anywhere in this module: it is the memory that must survive
|
|
274
|
+
* task boundaries, sidecar stops, image replacement and rollback.
|
|
275
|
+
*/
|
|
276
|
+
export const ensureFormalAiMemoryVolume = async ({ image, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
277
|
+
if (await dockerOk(run, ['volume', 'inspect', FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs })) {
|
|
278
|
+
return { created: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await dockerText(run, ['volume', 'create', '--label', `${FORMAL_AI_SIDECAR_LABEL}=memory`, FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs });
|
|
282
|
+
// A fresh named volume is root-owned; the image runs application commands as
|
|
283
|
+
// `box`, so seed the ownership exactly as upstream's own upgrade fixture does.
|
|
284
|
+
await dockerOk(run, ['run', '--rm', '--volume', `${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`, '--entrypoint', 'chown', image, '-R', 'box:box', FORMAL_AI_MEMORY_MOUNT], { timeoutMs });
|
|
285
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created memory volume '${FORMAL_AI_MEMORY_VOLUME_NAME}'`);
|
|
286
|
+
return { created: true };
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** Build the `docker run` argv for the sidecar. Exported so tests can assert the contract. */
|
|
290
|
+
export const buildFormalAiSidecarRunArgs = ({ image, env = process.env, containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME } = {}) => {
|
|
291
|
+
const args = ['run', '--detach', '--name', containerName, '--label', `${FORMAL_AI_SIDECAR_LABEL}=sidecar`, '--network', FORMAL_AI_SIDECAR_NETWORK_NAME, '--network-alias', FORMAL_AI_SIDECAR_NETWORK_ALIAS, '--restart', 'no'];
|
|
292
|
+
|
|
293
|
+
if (shouldRunPrivilegedFormalAiSidecar(env)) args.push('--privileged');
|
|
294
|
+
|
|
295
|
+
args.push(
|
|
296
|
+
// The sidecar serves HTTP only; the image's inner Docker daemon is dead
|
|
297
|
+
// weight and would demand --privileged.
|
|
298
|
+
'--env',
|
|
299
|
+
'DIND_SKIP_DAEMON=1',
|
|
300
|
+
'--env',
|
|
301
|
+
`FORMAL_AI_MEMORY_PATH=${FORMAL_AI_MEMORY_PATH}`,
|
|
302
|
+
'--volume',
|
|
303
|
+
`${FORMAL_AI_MEMORY_VOLUME_NAME}:${FORMAL_AI_MEMORY_MOUNT}`,
|
|
304
|
+
image,
|
|
305
|
+
'formal-ai',
|
|
306
|
+
'serve',
|
|
307
|
+
'--agent-mode',
|
|
308
|
+
'--host',
|
|
309
|
+
'0.0.0.0',
|
|
310
|
+
'--port',
|
|
311
|
+
String(FORMAL_AI_SIDECAR_PORT)
|
|
312
|
+
);
|
|
313
|
+
// No `-p`: the endpoint is reachable only from the internal network.
|
|
314
|
+
return args;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read `/health` from inside the sidecar.
|
|
319
|
+
*
|
|
320
|
+
* `docker exec` is used instead of an HTTP client on the host precisely
|
|
321
|
+
* *because* the network is internal — there is no host-visible port, which is
|
|
322
|
+
* the property the review asked for.
|
|
323
|
+
*
|
|
324
|
+
* @returns {Promise<{healthy: boolean, health: object|null, error: string|null}>}
|
|
325
|
+
*/
|
|
326
|
+
export const checkFormalAiSidecarHealth = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
|
|
327
|
+
try {
|
|
328
|
+
const raw = await dockerText(run, ['exec', containerName, 'curl', '-fsS', `http://127.0.0.1:${FORMAL_AI_SIDECAR_PORT}/health`], { timeoutMs });
|
|
329
|
+
const health = JSON.parse(raw);
|
|
330
|
+
// 0.336.0+ reports memory compatibility here; refuse a container that can
|
|
331
|
+
// read the endpoint but not the persisted memory it was given.
|
|
332
|
+
const memoryCompatible = health?.memory?.compatible !== false;
|
|
333
|
+
return { healthy: memoryCompatible, health, error: memoryCompatible ? null : `Formal AI reports incompatible memory (migration_state=${health?.memory?.migration_state ?? 'unknown'})` };
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return { healthy: false, health: null, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Poll `/health` until the sidecar answers or the attempt budget runs out. */
|
|
340
|
+
export const waitForFormalAiSidecarHealth = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false } = {}) => {
|
|
341
|
+
let last = { healthy: false, health: null, error: 'not probed' };
|
|
342
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
343
|
+
last = await checkFormalAiSidecarHealth({ containerName, run });
|
|
344
|
+
if (last.healthy) {
|
|
345
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: '${containerName}' healthy after ${attempt} probe(s) (version=${last.health?.version ?? 'unknown'}, memory schema=${last.health?.memory?.schema_version ?? 'unknown'})`);
|
|
346
|
+
return last;
|
|
347
|
+
}
|
|
348
|
+
if (attempt < attempts) await sleepImpl(delayMs);
|
|
349
|
+
}
|
|
350
|
+
return last;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Drop leases whose task container no longer runs, so a crashed run cannot pin
|
|
355
|
+
* the sidecar.
|
|
356
|
+
*
|
|
357
|
+
* A lease is taken *before* start-command creates the task container, because
|
|
358
|
+
* the endpoint has to be known when the task's environment is built. During
|
|
359
|
+
* that window the container legitimately does not exist yet — and creating it
|
|
360
|
+
* can take a long time when the isolation image still has to be pulled. A lease
|
|
361
|
+
* that has never been seen running is therefore kept until
|
|
362
|
+
* `LEASE_START_GRACE_MS` elapses; afterwards, and always once the container has
|
|
363
|
+
* been observed, liveness is Docker's answer alone.
|
|
364
|
+
*/
|
|
365
|
+
const LEASE_START_GRACE_MS = 60 * 60 * 1000;
|
|
366
|
+
|
|
367
|
+
const reconcileLeases = async (leases, { run, timeoutMs, log, verbose, now = () => Date.now() }) => {
|
|
368
|
+
const live = [];
|
|
369
|
+
for (const lease of leases) {
|
|
370
|
+
if (!lease?.sessionId) continue;
|
|
371
|
+
const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
|
|
372
|
+
if (container.exists && container.running) {
|
|
373
|
+
live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (!lease.containerSeen) {
|
|
377
|
+
const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
|
|
378
|
+
if (age < LEASE_START_GRACE_MS) {
|
|
379
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: keeping lease '${lease.sessionId}' whose container has not appeared yet (${Math.round(age / 1000)}s into the ${Math.round(LEASE_START_GRACE_MS / 1000)}s launch grace)`);
|
|
380
|
+
live.push(lease);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
|
|
385
|
+
}
|
|
386
|
+
return live;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Re-derive the sidecar record from Docker.
|
|
391
|
+
*
|
|
392
|
+
* Invariant 5 of the case study: truth comes from container and network state
|
|
393
|
+
* as well as the durable store, so a bot restart converges instead of leaving
|
|
394
|
+
* an orphaned container running with nothing to serve.
|
|
395
|
+
*/
|
|
396
|
+
export const reconcileFormalAiSidecar = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
397
|
+
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
398
|
+
const leases = await reconcileLeases(state.leases, { run, timeoutMs, log, verbose });
|
|
399
|
+
const container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
400
|
+
const next = {
|
|
401
|
+
...state,
|
|
402
|
+
leases,
|
|
403
|
+
image: container.exists ? container.image : state.image,
|
|
404
|
+
imageDigest: container.exists ? container.imageDigest : state.imageDigest,
|
|
405
|
+
startedAt: container.running ? state.startedAt : null,
|
|
406
|
+
};
|
|
407
|
+
writeFormalAiSidecarState(next, { env, fsImpl });
|
|
408
|
+
return { state: next, container, leaseCount: leases.length };
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Stop and remove the sidecar and its network. The memory volume is left
|
|
413
|
+
* untouched — see invariant 6.
|
|
414
|
+
*/
|
|
415
|
+
export const stopFormalAiSidecar = async ({ env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, reason = 'idle' } = {}) => {
|
|
416
|
+
const container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
417
|
+
if (container.exists) {
|
|
418
|
+
await dockerOk(run, ['stop', FORMAL_AI_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
419
|
+
await dockerOk(run, ['rm', '--force', FORMAL_AI_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
420
|
+
}
|
|
421
|
+
await dockerOk(run, ['network', 'rm', FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
|
|
422
|
+
|
|
423
|
+
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
424
|
+
writeFormalAiSidecarState({ ...state, startedAt: null, leases: [] }, { env, fsImpl });
|
|
425
|
+
if (log) await log(`🛑 Formal AI sidecar stopped (${reason}); memory volume '${FORMAL_AI_MEMORY_VOLUME_NAME}' preserved`);
|
|
426
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: removed container=${container.exists} network='${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
427
|
+
return { stopped: container.exists };
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Ensure a healthy sidecar exists and record a lease for `sessionId`.
|
|
432
|
+
*
|
|
433
|
+
* Must be called *before* the task container's command is allowed to run.
|
|
434
|
+
* Returns the endpoint the task should be pointed at.
|
|
435
|
+
*/
|
|
436
|
+
export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = null, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, now = () => new Date(), healthAttempts, healthDelayMs, sleepImpl = sleep, lockOptions = {} } = {}) => {
|
|
437
|
+
if (!sessionId) throw new Error('acquireFormalAiSidecar requires a sessionId');
|
|
438
|
+
|
|
439
|
+
return withFormalAiSidecarLock(
|
|
440
|
+
async () => {
|
|
441
|
+
const image = resolveFormalAiSidecarImage(env);
|
|
442
|
+
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
443
|
+
const leases = await reconcileLeases(state.leases, { run, timeoutMs, log, verbose });
|
|
444
|
+
|
|
445
|
+
await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
|
|
446
|
+
await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
|
|
447
|
+
|
|
448
|
+
let container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
449
|
+
if (container.exists && !container.running) {
|
|
450
|
+
// A stopped container may predate an image change; recreate instead of
|
|
451
|
+
// resurrecting an unknown revision.
|
|
452
|
+
await dockerOk(run, ['rm', '--force', FORMAL_AI_SIDECAR_CONTAINER_NAME], { timeoutMs });
|
|
453
|
+
container = { exists: false, running: false, image: null, imageDigest: null };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!container.exists) {
|
|
457
|
+
if (log) await log(`🧠 Starting the Formal AI sidecar (${image}) on the internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
458
|
+
await dockerText(run, buildFormalAiSidecarRunArgs({ image, env }), { timeoutMs });
|
|
459
|
+
container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const health = await waitForFormalAiSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
|
|
463
|
+
if (!health.healthy) {
|
|
464
|
+
// Fail closed: issue #2146 requires a Formal AI run to stop rather than
|
|
465
|
+
// silently fall back to another model.
|
|
466
|
+
throw new Error(`Formal AI sidecar '${FORMAL_AI_SIDECAR_CONTAINER_NAME}' did not become healthy: ${health.error}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const address = await readFormalAiSidecarAddress({ run, timeoutMs });
|
|
470
|
+
const acquiredAt = now().toISOString();
|
|
471
|
+
const nextLeases = [...leases.filter(lease => lease.sessionId !== sessionId), { sessionId, tool, model, acquiredAt }];
|
|
472
|
+
writeFormalAiSidecarState({ ...state, image: container.image || image, imageDigest: container.imageDigest, startedAt: state.startedAt || acquiredAt, leases: nextLeases }, { env, fsImpl });
|
|
473
|
+
|
|
474
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' acquired (${nextLeases.length} active), image=${container.image || image}, digest=${container.imageDigest ?? 'unknown'}, address=${address ?? 'unknown'}, formal-ai=${health.health?.version ?? 'unknown'}, memory schema=${health.health?.memory?.schema_version ?? 'unknown'}`);
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
address,
|
|
478
|
+
baseUrl: address ? buildFormalAiSidecarBaseUrl(address) : resolveFormalAiSidecarBaseUrl(),
|
|
479
|
+
dnsBaseUrl: resolveFormalAiSidecarBaseUrl(),
|
|
480
|
+
network: FORMAL_AI_SIDECAR_NETWORK_NAME,
|
|
481
|
+
networkAlias: FORMAL_AI_SIDECAR_NETWORK_ALIAS,
|
|
482
|
+
containerName: FORMAL_AI_SIDECAR_CONTAINER_NAME,
|
|
483
|
+
memoryVolume: FORMAL_AI_MEMORY_VOLUME_NAME,
|
|
484
|
+
image: container.image || image,
|
|
485
|
+
imageDigest: container.imageDigest,
|
|
486
|
+
health: health.health,
|
|
487
|
+
leaseCount: nextLeases.length,
|
|
488
|
+
};
|
|
489
|
+
},
|
|
490
|
+
{ env, fsImpl, sleepImpl, log, ...lockOptions }
|
|
491
|
+
);
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Attach a launched task container to the internal Formal AI network.
|
|
496
|
+
*
|
|
497
|
+
* Called while the Docker start gate still holds the task command back, so the
|
|
498
|
+
* endpoint is resolvable before the first request is made.
|
|
499
|
+
*/
|
|
500
|
+
export const attachTaskToFormalAiNetwork = async ({ sessionId, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
|
|
501
|
+
if (!sessionId) return { attached: false, error: 'no sessionId' };
|
|
502
|
+
try {
|
|
503
|
+
await dockerText(run, ['network', 'connect', FORMAL_AI_SIDECAR_NETWORK_NAME, sessionId], { timeoutMs });
|
|
504
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: attached task container '${sessionId}' to '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
505
|
+
return { attached: true, error: null };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
508
|
+
// Docker reports an already-attached container as an error; that is success.
|
|
509
|
+
if (/already exists in network/i.test(message)) return { attached: true, error: null };
|
|
510
|
+
if (log) await log(`⚠️ Could not attach task container '${sessionId}' to the Formal AI network: ${message}`);
|
|
511
|
+
return { attached: false, error: message };
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Release `sessionId`'s lease and, when it was the last one, stop the sidecar.
|
|
517
|
+
*
|
|
518
|
+
* @returns {Promise<{leaseCount: number, stopped: boolean}>}
|
|
519
|
+
*/
|
|
520
|
+
export const releaseFormalAiSidecar = async ({ sessionId, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, sleepImpl = sleep, lockOptions = {} } = {}) => {
|
|
521
|
+
return withFormalAiSidecarLock(
|
|
522
|
+
async () => {
|
|
523
|
+
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
524
|
+
const remaining = await reconcileLeases(
|
|
525
|
+
state.leases.filter(lease => lease.sessionId !== sessionId),
|
|
526
|
+
{ run, timeoutMs, log, verbose }
|
|
527
|
+
);
|
|
528
|
+
writeFormalAiSidecarState({ ...state, leases: remaining }, { env, fsImpl });
|
|
529
|
+
|
|
530
|
+
if (remaining.length > 0) {
|
|
531
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' released, ${remaining.length} still active; sidecar stays up`);
|
|
532
|
+
return { leaseCount: remaining.length, stopped: false };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const { stopped } = await stopFormalAiSidecar({ env, fsImpl, run, timeoutMs, log, verbose, reason: 'no Formal AI tasks running' });
|
|
536
|
+
return { leaseCount: 0, stopped };
|
|
537
|
+
},
|
|
538
|
+
{ env, fsImpl, sleepImpl, log, ...lockOptions }
|
|
539
|
+
);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
export default {
|
|
543
|
+
acquireFormalAiSidecar,
|
|
544
|
+
attachTaskToFormalAiNetwork,
|
|
545
|
+
buildFormalAiSidecarBaseUrl,
|
|
546
|
+
buildFormalAiSidecarRunArgs,
|
|
547
|
+
checkFormalAiSidecarHealth,
|
|
548
|
+
ensureFormalAiMemoryVolume,
|
|
549
|
+
ensureFormalAiNetwork,
|
|
550
|
+
inspectDockerContainer,
|
|
551
|
+
isFormalAiSidecarEnabled,
|
|
552
|
+
isFormalAiTask,
|
|
553
|
+
readDockerImageDigest,
|
|
554
|
+
readFormalAiSidecarAddress,
|
|
555
|
+
readFormalAiSidecarState,
|
|
556
|
+
reconcileFormalAiSidecar,
|
|
557
|
+
releaseFormalAiSidecar,
|
|
558
|
+
resolveFormalAiSidecarBaseUrl,
|
|
559
|
+
resolveFormalAiSidecarImage,
|
|
560
|
+
resolveFormalAiSidecarStatePath,
|
|
561
|
+
stopFormalAiSidecar,
|
|
562
|
+
waitForFormalAiSidecarHealth,
|
|
563
|
+
withFormalAiSidecarLock,
|
|
564
|
+
writeFormalAiSidecarState,
|
|
565
|
+
};
|