@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.
- package/CHANGELOG.md +27 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +8 -0
- package/src/agent-memory-policy.lib.mjs +305 -0
- package/src/claude-quiet-config.lib.mjs +7 -2
- package/src/codex.lib.mjs +7 -0
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/gemini.lib.mjs +5 -0
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/isolation-runner.lib.mjs +90 -10
- package/src/qwen.lib.mjs +4 -0
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/solve.config.lib.mjs +16 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
|
@@ -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) =>
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -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 };
|