@link-assistant/hive-mind 2.13.5 → 2.15.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,276 @@
1
+ /**
2
+ * Mechanics shared by every on-demand Docker sidecar Hive Mind runs.
3
+ *
4
+ * The Formal AI sidecar (issue #2146) and the router sidecar (issue #2164) are
5
+ * different services with different reasons to exist, but their *mechanics* are
6
+ * the same problem solved twice: one container shared by concurrent tasks, a
7
+ * lease per task, a durable JSON record that is only ever a cache of what
8
+ * Docker actually reports, and an exclusive lock so a launch and an update can
9
+ * never interleave.
10
+ *
11
+ * Those mechanics live here so the two lifecycles stay in step — a fix to lease
12
+ * reconciliation or to the network guard applies to both — and so each sidecar
13
+ * module is left holding only the part that is genuinely its own: which image,
14
+ * which mounts, which readiness check, and what the task is handed.
15
+ *
16
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
17
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
18
+ */
19
+
20
+ import { execFile } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { promisify } from 'node:util';
24
+
25
+ import { resolveBotStateDir } from './session-store.lib.mjs';
26
+
27
+ const execFileAsync = promisify(execFile);
28
+
29
+ export const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
30
+ // Pulling a sidecar image is the one Docker call that legitimately takes many
31
+ // minutes, so it gets its own budget instead of the general command timeout.
32
+ export const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
33
+
34
+ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
35
+
36
+ /** Run `docker …` and return trimmed stdout. Throws on a non-zero exit. */
37
+ export const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
38
+ const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
39
+ return String(result?.stdout ?? '').trim();
40
+ };
41
+
42
+ /** Run `docker …` for its effect only, reporting success as a boolean. */
43
+ export const dockerOk = async (run, args, options) => {
44
+ try {
45
+ await dockerText(run, args, options);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ /** The message a failed `docker` invocation should be reported with. */
53
+ export const dockerErrorMessage = error => error?.stderr?.toString?.().trim() || error?.message || String(error);
54
+
55
+ /**
56
+ * Inspect a container without treating "absent" as an error.
57
+ *
58
+ * @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
59
+ */
60
+ export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
61
+ try {
62
+ const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
63
+ const [running, image, imageDigest] = raw.split('|');
64
+ return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
65
+ } catch {
66
+ return { exists: false, running: false, image: null, imageDigest: null };
67
+ }
68
+ };
69
+
70
+ /** Resolve the local content digest of an image reference, or null when it is absent. */
71
+ export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
72
+ try {
73
+ return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ /** A container's IPv4 address on one named network, or null when it has none. */
80
+ export const readDockerContainerAddress = async (containerName, network, { run = execFileAsync, timeoutMs } = {}) => {
81
+ try {
82
+ return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Create the private network a sidecar and its tasks share.
90
+ *
91
+ * `--internal` is the security requirement: the sidecar endpoint must not be
92
+ * published to the host and must not be reachable from any other network. An
93
+ * existing network that is *not* internal is a stale artifact from an older
94
+ * deployment and is replaced — but only while nothing is attached to it, since
95
+ * removing a network out from under a running container would break it.
96
+ */
97
+ export const ensureInternalDockerNetwork = async ({ name, label, run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
98
+ // `null` means "absent", which is different from "present but not internal".
99
+ let internal = null;
100
+ let containers = 0;
101
+ try {
102
+ const raw = await dockerText(run, ['network', 'inspect', name, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
103
+ const [internalFlag, containerCount] = raw.split('|');
104
+ internal = internalFlag === 'true';
105
+ containers = Number(containerCount) || 0;
106
+ } catch {
107
+ // Absent; fall through to creation.
108
+ }
109
+
110
+ if (internal === true) return { created: false, internal: true };
111
+
112
+ if (internal === false) {
113
+ if (containers > 0) {
114
+ if (log) await log(`⚠️ Network '${name}' is not internal but still has ${containers} attached container(s); leaving it in place`);
115
+ return { created: false, internal: false };
116
+ }
117
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: replacing non-internal network '${name}'`);
118
+ await dockerOk(run, ['network', 'rm', name], { timeoutMs });
119
+ }
120
+
121
+ await dockerText(run, ['network', 'create', '--internal', '--label', `${label}=network`, name], { timeoutMs });
122
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: created internal network '${name}'`);
123
+ return { created: true, internal: true };
124
+ };
125
+
126
+ /**
127
+ * Add a network to an already-created container.
128
+ *
129
+ * A single `docker run --network` *replaces* the container's default bridge, so
130
+ * an `--internal` network passed that way would also cut the container off from
131
+ * GitHub and the package registries. Attaching afterwards is additive, which is
132
+ * what both sidecars need — for the task container, and for the router sidecar
133
+ * itself, which must keep its outbound route to the vendor APIs.
134
+ */
135
+ export const attachDockerNetwork = async ({ network, container, alias = null, run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
136
+ if (!container) return { attached: false, error: 'no container' };
137
+ const args = ['network', 'connect'];
138
+ if (alias) args.push('--alias', alias);
139
+ args.push(network, container);
140
+ try {
141
+ await dockerText(run, args, { timeoutMs });
142
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: attached '${container}' to '${network}'${alias ? ` as '${alias}'` : ''}`);
143
+ return { attached: true, error: null };
144
+ } catch (error) {
145
+ const message = dockerErrorMessage(error);
146
+ // Docker reports an already-attached container as an error; that is success.
147
+ if (/already exists in network/i.test(message)) return { attached: true, error: null };
148
+ if (log) await log(`⚠️ Could not attach '${container}' to network '${network}': ${message}`);
149
+ return { attached: false, error: message };
150
+ }
151
+ };
152
+
153
+ /** Create a named volume if it is missing. Never removed by any caller: it holds the data the sidecar exists to keep. */
154
+ export const ensureDockerVolume = async ({ name, label, role = 'data', run = execFileAsync, timeoutMs, log = null, verbose = false, logPrefix = 'docker-sidecar' } = {}) => {
155
+ if (await dockerOk(run, ['volume', 'inspect', name], { timeoutMs })) return { created: false };
156
+ await dockerText(run, ['volume', 'create', '--label', `${label}=${role}`, name], { timeoutMs });
157
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: created volume '${name}'`);
158
+ return { created: true };
159
+ };
160
+
161
+ /** Path of a sidecar's durable record inside the bot state directory. */
162
+ export const resolveSidecarStatePath = (fileName, env = process.env) => path.join(resolveBotStateDir(env), fileName);
163
+
164
+ /** Read a durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
165
+ export const readSidecarState = ({ fileName, emptyState, env = process.env, fsImpl = fs } = {}) => {
166
+ try {
167
+ const parsed = JSON.parse(fsImpl.readFileSync(resolveSidecarStatePath(fileName, env), 'utf8'));
168
+ return { ...emptyState, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
169
+ } catch {
170
+ return { ...emptyState, leases: [] };
171
+ }
172
+ };
173
+
174
+ /**
175
+ * Persist a sidecar record atomically so a crash mid-write cannot corrupt it.
176
+ *
177
+ * `mode` exists because the router's record holds its JWT signing secret, which
178
+ * mints subscription access: that file must not be world-readable (issue #2164).
179
+ */
180
+ export const writeSidecarState = (state, { fileName, env = process.env, fsImpl = fs, mode = 0o600 } = {}) => {
181
+ const target = resolveSidecarStatePath(fileName, env);
182
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
183
+ const temporary = `${target}.tmp`;
184
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode });
185
+ fsImpl.renameSync(temporary, target);
186
+ return state;
187
+ };
188
+
189
+ /**
190
+ * How long a lease whose container has never been seen running is kept.
191
+ *
192
+ * A lease is taken *before* start-command creates the task container, because
193
+ * the endpoint and the token have to be known when the task's environment is
194
+ * built. During that window the container legitimately does not exist yet — and
195
+ * creating it can take a long time when the isolation image still has to be
196
+ * pulled.
197
+ */
198
+ export const LEASE_START_GRACE_MS = 60 * 60 * 1000;
199
+
200
+ /**
201
+ * Drop leases whose task container no longer runs, so a crashed run cannot pin
202
+ * a sidecar up forever.
203
+ *
204
+ * Liveness is re-derived from Docker on every call rather than trusted from the
205
+ * store, which is what lets a restarted bot converge instead of orphaning.
206
+ *
207
+ * @param {Array<object>} leases
208
+ * @param {{onDropped?: (lease: object) => Promise<void>|void}} options
209
+ * `onDropped` lets a sidecar clean up what the lease owned — the router
210
+ * revokes the task's token there.
211
+ */
212
+ export const reconcileSidecarLeases = async (leases, { run = execFileAsync, timeoutMs, log = null, verbose = false, now = () => Date.now(), onDropped = null, logPrefix = 'docker-sidecar' } = {}) => {
213
+ const live = [];
214
+ for (const lease of leases) {
215
+ if (!lease?.sessionId) continue;
216
+ const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
217
+ if (container.exists && container.running) {
218
+ live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
219
+ continue;
220
+ }
221
+ if (!lease.containerSeen) {
222
+ const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
223
+ if (age < LEASE_START_GRACE_MS) {
224
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: 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)`);
225
+ live.push(lease);
226
+ continue;
227
+ }
228
+ }
229
+ if (verbose && log) await log(`[VERBOSE] ${logPrefix}: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
230
+ if (onDropped) await onDropped(lease);
231
+ }
232
+ return live;
233
+ };
234
+
235
+ /**
236
+ * Periodic best-effort maintenance timer shared by the sidecar lifecycles.
237
+ *
238
+ * Both the Formal AI and the router sidecar need the same thing: a tick that
239
+ * runs immediately, repeats on an interval, never keeps the process alive on
240
+ * shutdown, and never rejects into the bot's event loop. Keeping one
241
+ * implementation means a fix to that shape applies to both.
242
+ *
243
+ * @returns {{stop: () => void}}
244
+ */
245
+ export const startSidecarMaintenance = ({ runTick, logPrefix = 'sidecar-maintenance', env = process.env, log = null, verbose = false, intervalMs, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) => {
246
+ const tick = () => {
247
+ runTick({ env, log, verbose }).catch(error => {
248
+ console.error(`[${logPrefix}] tick failed: ${error?.message || error}`);
249
+ });
250
+ };
251
+
252
+ const timer = setIntervalImpl(tick, intervalMs);
253
+ timer?.unref?.();
254
+ tick();
255
+ return {
256
+ stop: () => clearIntervalImpl(timer),
257
+ };
258
+ };
259
+
260
+ export default {
261
+ attachDockerNetwork,
262
+ dockerErrorMessage,
263
+ dockerOk,
264
+ dockerText,
265
+ ensureDockerVolume,
266
+ ensureInternalDockerNetwork,
267
+ inspectDockerContainer,
268
+ readDockerContainerAddress,
269
+ readDockerImageDigest,
270
+ readSidecarState,
271
+ reconcileSidecarLeases,
272
+ resolveSidecarStatePath,
273
+ sleep,
274
+ startSidecarMaintenance,
275
+ writeSidecarState,
276
+ };
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { updateAgenticClisWhenIdle } from './agentic-cli-updater.lib.mjs';
24
+ import { startSidecarMaintenance } from './docker-sidecar.lib.mjs';
24
25
  import { reconcileFormalAiSidecar, stopFormalAiSidecar, withFormalAiSidecarLock } from './formal-ai-sidecar.lib.mjs';
25
26
  import { updateFormalAiSidecarWhenIdle } from './formal-ai-updater.lib.mjs';
26
27
 
@@ -88,19 +89,6 @@ export const runFormalAiMaintenanceTick = async ({ env = process.env, run, log =
88
89
  *
89
90
  * @returns {{stop: () => void}}
90
91
  */
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
- };
92
+ export const startFormalAiMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runFormalAiMaintenanceTick } = {}) => startSidecarMaintenance({ runTick, logPrefix: 'formal-ai-maintenance', env, log, verbose, intervalMs, setIntervalImpl, clearIntervalImpl });
105
93
 
106
94
  export default { DEFAULT_FORMAL_AI_MAINTENANCE_INTERVAL_MS, runFormalAiMaintenanceTick, startFormalAiMaintenance, stopIdleFormalAiSidecar };
@@ -43,18 +43,23 @@
43
43
 
44
44
  import { execFile } from 'node:child_process';
45
45
  import fs from 'node:fs';
46
- import path from 'node:path';
47
46
  import { promisify } from 'node:util';
48
47
 
49
48
  import { FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast } from './formal-ai-version.lib.mjs';
50
49
  import { ensureFormalAiSidecarImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
51
50
  import { isFormalAiModel } from './formal-ai-model.lib.mjs';
52
51
  import { getModelFromArgs } from './model-args.lib.mjs';
53
- import { resolveBotStateDir } from './session-store.lib.mjs';
54
52
  import { withStateLock } from './state-lock.lib.mjs';
53
+ import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerContainerAddress, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
55
54
 
56
55
  const execFileAsync = promisify(execFile);
57
56
 
57
+ // Re-exported because callers of this module have always imported them from
58
+ // here; the implementations now live in the shared sidecar module.
59
+ export { inspectDockerContainer, readDockerImageDigest };
60
+
61
+ const LOG_PREFIX = 'formal-ai-sidecar';
62
+
58
63
  /** Container, network, volume and alias names. Stable so reconciliation works across restarts. */
59
64
  export const FORMAL_AI_SIDECAR_CONTAINER_NAME = 'hive-mind-formal-ai';
60
65
  export const FORMAL_AI_SIDECAR_NETWORK_NAME = 'hive-mind-formal-ai';
@@ -92,17 +97,13 @@ export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
92
97
 
93
98
  const STATE_FILE_NAME = 'formal-ai-sidecar.json';
94
99
  const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
95
- const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
96
100
  // Pulling a sidecar image is the one Docker call that legitimately takes many
97
101
  // minutes, so it gets its own budget instead of the general command timeout.
98
- const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
99
102
  const DEFAULT_HEALTH_ATTEMPTS = 60;
100
103
  const DEFAULT_HEALTH_DELAY_MS = 1000;
101
104
 
102
105
  const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null });
103
106
 
104
- const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
105
-
106
107
  /**
107
108
  * True when a task will be driven by Formal AI.
108
109
  *
@@ -149,27 +150,13 @@ export const isFormalAiSidecarEnabled = (env = process.env) => {
149
150
  return !['0', 'false', 'no', 'off'].includes(raw);
150
151
  };
151
152
 
152
- export const resolveFormalAiSidecarStatePath = (env = process.env) => path.join(resolveBotStateDir(env), STATE_FILE_NAME);
153
+ export const resolveFormalAiSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
153
154
 
154
155
  /** Read the durable sidecar record. A missing or corrupt file is an empty record, never a throw. */
155
- export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => {
156
- try {
157
- const parsed = JSON.parse(fsImpl.readFileSync(resolveFormalAiSidecarStatePath(env), 'utf8'));
158
- return { ...EMPTY_STATE, ...parsed, leases: Array.isArray(parsed?.leases) ? parsed.leases : [] };
159
- } catch {
160
- return { ...EMPTY_STATE, leases: [] };
161
- }
162
- };
156
+ export const readFormalAiSidecarState = ({ env = process.env, fsImpl = fs } = {}) => readSidecarState({ fileName: STATE_FILE_NAME, emptyState: EMPTY_STATE, env, fsImpl });
163
157
 
164
158
  /** Persist the sidecar record atomically so a crash mid-write cannot corrupt it. */
165
- export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => {
166
- const target = resolveFormalAiSidecarStatePath(env);
167
- fsImpl.mkdirSync(path.dirname(target), { recursive: true });
168
- const temporary = `${target}.tmp`;
169
- fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
170
- fsImpl.renameSync(temporary, target);
171
- return state;
172
- };
159
+ export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = fs } = {}) => writeSidecarState(state, { fileName: STATE_FILE_NAME, env, fsImpl });
173
160
 
174
161
  /**
175
162
  * Serialize every sidecar mutation — task launches, task releases and image
@@ -180,35 +167,6 @@ export const writeFormalAiSidecarState = (state, { env = process.env, fsImpl = f
180
167
  */
181
168
  export const withFormalAiSidecarLock = (fn, options = {}) => withStateLock(SIDECAR_LOCK_NAME, fn, options);
182
169
 
183
- const dockerText = async (run, args, { timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS } = {}) => {
184
- const result = await run('docker', args, { encoding: 'utf8', timeout: timeoutMs });
185
- return String(result?.stdout ?? '').trim();
186
- };
187
-
188
- const dockerOk = async (run, args, options) => {
189
- try {
190
- await dockerText(run, args, options);
191
- return true;
192
- } catch {
193
- return false;
194
- }
195
- };
196
-
197
- /**
198
- * Inspect a container without treating "absent" as an error.
199
- *
200
- * @returns {Promise<{exists: boolean, running: boolean, image: string|null, imageDigest: string|null}>}
201
- */
202
- export const inspectDockerContainer = async (name, { run = execFileAsync, timeoutMs } = {}) => {
203
- try {
204
- const raw = await dockerText(run, ['inspect', name, '--format', '{{.State.Running}}|{{.Config.Image}}|{{.Image}}'], { timeoutMs });
205
- const [running, image, imageDigest] = raw.split('|');
206
- return { exists: true, running: running === 'true', image: image || null, imageDigest: imageDigest || null };
207
- } catch {
208
- return { exists: false, running: false, image: null, imageDigest: null };
209
- }
210
- };
211
-
212
170
  /**
213
171
  * The sidecar's IPv4 address on the internal network.
214
172
  *
@@ -218,22 +176,7 @@ export const inspectDockerContainer = async (name, { run = execFileAsync, timeou
218
176
  * post-attachment would be a needless gamble; the address cannot change during
219
177
  * a lease, because an image replacement requires zero leases.
220
178
  */
221
- export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => {
222
- try {
223
- return (await dockerText(run, ['inspect', containerName, '--format', `{{with index .NetworkSettings.Networks "${network}"}}{{.IPAddress}}{{end}}`], { timeoutMs })) || null;
224
- } catch {
225
- return null;
226
- }
227
- };
228
-
229
- /** Resolve the local content digest of an image reference, or null when it is absent. */
230
- export const readDockerImageDigest = async (image, { run = execFileAsync, timeoutMs } = {}) => {
231
- try {
232
- return (await dockerText(run, ['image', 'inspect', image, '--format', '{{.Id}}'], { timeoutMs })) || null;
233
- } catch {
234
- return null;
235
- }
236
- };
179
+ export const readFormalAiSidecarAddress = async ({ containerName = FORMAL_AI_SIDECAR_CONTAINER_NAME, network = FORMAL_AI_SIDECAR_NETWORK_NAME, run = execFileAsync, timeoutMs } = {}) => readDockerContainerAddress(containerName, network, { run, timeoutMs });
237
180
 
238
181
  /**
239
182
  * Create the private network the sidecar and its tasks share.
@@ -244,34 +187,7 @@ export const readDockerImageDigest = async (image, { run = execFileAsync, timeou
244
187
  * artifact from the Compose deployment and is replaced when nothing is
245
188
  * attached to it.
246
189
  */
247
- export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
248
- // `null` means "absent", which is different from "present but not internal".
249
- let internal = null;
250
- let containers = 0;
251
- try {
252
- const raw = await dockerText(run, ['network', 'inspect', FORMAL_AI_SIDECAR_NETWORK_NAME, '--format', '{{.Internal}}|{{len .Containers}}'], { timeoutMs });
253
- const [internalFlag, containerCount] = raw.split('|');
254
- internal = internalFlag === 'true';
255
- containers = Number(containerCount) || 0;
256
- } catch {
257
- // Absent; fall through to creation.
258
- }
259
-
260
- if (internal === true) return { created: false, internal: true };
261
-
262
- if (internal === false) {
263
- if (containers > 0) {
264
- 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`);
265
- return { created: false, internal: false };
266
- }
267
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: replacing non-internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
268
- await dockerOk(run, ['network', 'rm', FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
269
- }
270
-
271
- await dockerText(run, ['network', 'create', '--internal', '--label', `${FORMAL_AI_SIDECAR_LABEL}=network`, FORMAL_AI_SIDECAR_NETWORK_NAME], { timeoutMs });
272
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
273
- return { created: true, internal: true };
274
- };
190
+ export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => ensureInternalDockerNetwork({ name: FORMAL_AI_SIDECAR_NETWORK_NAME, label: FORMAL_AI_SIDECAR_LABEL, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
275
191
 
276
192
  /**
277
193
  * Create the persisted-memory volume if it is missing and hand it to the
@@ -281,16 +197,12 @@ export const ensureFormalAiNetwork = async ({ run = execFileAsync, timeoutMs, lo
281
197
  * task boundaries, sidecar stops, image replacement and rollback.
282
198
  */
283
199
  export const ensureFormalAiMemoryVolume = async ({ image, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
284
- if (await dockerOk(run, ['volume', 'inspect', FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs })) {
285
- return { created: false };
286
- }
287
-
288
- await dockerText(run, ['volume', 'create', '--label', `${FORMAL_AI_SIDECAR_LABEL}=memory`, FORMAL_AI_MEMORY_VOLUME_NAME], { timeoutMs });
200
+ const result = await ensureDockerVolume({ name: FORMAL_AI_MEMORY_VOLUME_NAME, label: FORMAL_AI_SIDECAR_LABEL, role: 'memory', run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
201
+ if (!result.created) return result;
289
202
  // A fresh named volume is root-owned; the image runs application commands as
290
203
  // `box`, so seed the ownership exactly as upstream's own upgrade fixture does.
291
204
  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 });
292
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: created memory volume '${FORMAL_AI_MEMORY_VOLUME_NAME}'`);
293
- return { created: true };
205
+ return result;
294
206
  };
295
207
 
296
208
  /** Build the `docker run` argv for the sidecar. Exported so tests can assert the contract. */
@@ -369,29 +281,7 @@ export const waitForFormalAiSidecarHealth = async ({ containerName = FORMAL_AI_S
369
281
  * `LEASE_START_GRACE_MS` elapses; afterwards, and always once the container has
370
282
  * been observed, liveness is Docker's answer alone.
371
283
  */
372
- const LEASE_START_GRACE_MS = 60 * 60 * 1000;
373
-
374
- const reconcileLeases = async (leases, { run, timeoutMs, log, verbose, now = () => Date.now() }) => {
375
- const live = [];
376
- for (const lease of leases) {
377
- if (!lease?.sessionId) continue;
378
- const container = await inspectDockerContainer(lease.sessionId, { run, timeoutMs });
379
- if (container.exists && container.running) {
380
- live.push(lease.containerSeen ? lease : { ...lease, containerSeen: true });
381
- continue;
382
- }
383
- if (!lease.containerSeen) {
384
- const age = now() - (Date.parse(lease.acquiredAt ?? '') || 0);
385
- if (age < LEASE_START_GRACE_MS) {
386
- 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)`);
387
- live.push(lease);
388
- continue;
389
- }
390
- }
391
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: dropping stale lease '${lease.sessionId}' (container exists=${container.exists} running=${container.running})`);
392
- }
393
- return live;
394
- };
284
+ const reconcileLeases = async (leases, options) => reconcileSidecarLeases(leases, { ...options, logPrefix: LOG_PREFIX });
395
285
 
396
286
  /**
397
287
  * Re-derive the sidecar record from Docker.
@@ -522,17 +412,7 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
522
412
  */
523
413
  export const attachTaskToFormalAiNetwork = async ({ sessionId, run = execFileAsync, timeoutMs, log = null, verbose = false } = {}) => {
524
414
  if (!sessionId) return { attached: false, error: 'no sessionId' };
525
- try {
526
- await dockerText(run, ['network', 'connect', FORMAL_AI_SIDECAR_NETWORK_NAME, sessionId], { timeoutMs });
527
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: attached task container '${sessionId}' to '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
528
- return { attached: true, error: null };
529
- } catch (error) {
530
- const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
531
- // Docker reports an already-attached container as an error; that is success.
532
- if (/already exists in network/i.test(message)) return { attached: true, error: null };
533
- if (log) await log(`⚠️ Could not attach task container '${sessionId}' to the Formal AI network: ${message}`);
534
- return { attached: false, error: message };
535
- }
415
+ return attachDockerNetwork({ network: FORMAL_AI_SIDECAR_NETWORK_NAME, container: sessionId, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
536
416
  };
537
417
 
538
418
  /**
@@ -31,6 +31,7 @@ import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.li
31
31
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
32
32
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
33
33
  import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
34
+ import { ensureGeminiFamilyMemoryDisabled, isAgentMemoryDisabled } from './agent-memory-policy.lib.mjs'; // Issue #2178
34
35
 
35
36
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
36
37
 
@@ -421,6 +422,10 @@ export const executeGeminiCommand = async params => {
421
422
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
422
423
 
423
424
  const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
425
+ // Issue #2178: the repository is the only memory a hive-mind task keeps, so
426
+ // `save_memory` and the background auto-memory extractor are switched off
427
+ // before the CLI reads its settings.
428
+ if (isAgentMemoryDisabled(argv)) await ensureGeminiFamilyMemoryDisabled({ tool: 'gemini', log });
424
429
  // Issue #2130: Formal AI runs the native CLI against a local Formal AI server (no argv wrapper).
425
430
  const toolInvocation = await resolveFormalAiToolExecution({ tool: 'gemini', model: argv.model || defaultModels.gemini, toolPath: geminiPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv) });
426
431
  const geminiEnv = { ...process.env, ...toolInvocation.env };