@link-assistant/hive-mind 2.11.13 → 2.12.1
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 +18 -0
- package/package.json +4 -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/claude.connection.lib.mjs +209 -0
- package/src/claude.lib.mjs +6 -202
- package/src/codex.lib.mjs +0 -128
- 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/github-rate-limit.lib.mjs +3 -0
- package/src/github-url-parser.lib.mjs +255 -0
- package/src/github.lib.mjs +22 -343
- package/src/hive.mjs +0 -152
- package/src/interactive-mode.lib.mjs +0 -43
- package/src/isolation-runner.lib.mjs +44 -173
- package/src/limits.lib.mjs +0 -89
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -19
- package/src/session-monitor.lib.mjs +14 -172
- package/src/solve.auto-merge.lib.mjs +70 -164
- package/src/solve.mjs +31 -193
- package/src/solve.repository.lib.mjs +0 -83
- package/src/solve.results.lib.mjs +2 -92
- package/src/solve.session.lib.mjs +52 -19
- package/src/solve.tool-uncommitted.lib.mjs +22 -0
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +17 -65
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-merge-queue.lib.mjs +3 -155
- package/src/telegram-solve-queue.lib.mjs +9 -168
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/use-m-bootstrap.lib.mjs +6 -5
- package/src/use-with-retry.lib.mjs +128 -2
- package/src/working-session-summary.lib.mjs +47 -1
|
@@ -12,7 +12,6 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
|
12
12
|
* @see https://github.com/link-foundation/start
|
|
13
13
|
* @see https://github.com/link-assistant/hive-mind/issues/380
|
|
14
14
|
*/
|
|
15
|
-
|
|
16
15
|
import crypto from 'crypto';
|
|
17
16
|
import { spawn } from 'node:child_process';
|
|
18
17
|
import { describeChildExit } from './child-exit.lib.mjs';
|
|
@@ -21,9 +20,8 @@ import fs from 'node:fs';
|
|
|
21
20
|
import os from 'node:os';
|
|
22
21
|
import path from 'node:path';
|
|
23
22
|
import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
|
|
24
|
-
|
|
23
|
+
import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseFormalAiSidecarForTask } from './formal-ai-isolation.lib.mjs';
|
|
25
24
|
let commandStreamDollarPromise = null;
|
|
26
|
-
|
|
27
25
|
async function getCommandStreamDollar() {
|
|
28
26
|
if (!commandStreamDollarPromise) {
|
|
29
27
|
commandStreamDollarPromise = (async () => {
|
|
@@ -34,7 +32,6 @@ async function getCommandStreamDollar() {
|
|
|
34
32
|
return $;
|
|
35
33
|
})();
|
|
36
34
|
}
|
|
37
|
-
|
|
38
35
|
try {
|
|
39
36
|
return await commandStreamDollarPromise;
|
|
40
37
|
} catch (error) {
|
|
@@ -42,13 +39,8 @@ async function getCommandStreamDollar() {
|
|
|
42
39
|
throw error;
|
|
43
40
|
}
|
|
44
41
|
}
|
|
45
|
-
|
|
46
|
-
// Re-export the shared status predicates so existing callers that reach them via
|
|
47
|
-
// the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`)
|
|
48
|
-
// keep working. The canonical definitions live in session-status.lib.mjs so the
|
|
49
|
-
// killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
|
|
42
|
+
// Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
|
|
50
43
|
export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
|
|
51
|
-
|
|
52
44
|
// Valid isolation backends
|
|
53
45
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
54
46
|
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
@@ -56,41 +48,16 @@ const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
|
56
48
|
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
57
49
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
58
50
|
const FORMAL_AI_COMPOSE_HOSTNAME = 'link-assistant-formal-ai';
|
|
59
|
-
// Default path where the host Docker socket is bind-mounted inside a DinD
|
|
60
|
-
// container so box's host-image passthrough can copy host images into the
|
|
61
|
-
// nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy
|
|
62
|
-
// must mount it (`-v /var/run/docker.sock:/var/run/host-docker.sock:ro`) or the
|
|
63
|
-
// nested daemon starts empty and the first isolated task pulls the full,
|
|
64
|
-
// multi-gigabyte image. See issue #1914.
|
|
51
|
+
// Default path where the host Docker socket is bind-mounted inside a DinD container so box's host-image passthrough can copy host images into the nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy must mount it (`-v /var/run/docker.sock:/var/run/host-docker.sock:ro`) or the nested daemon starts empty and the first isolated task pulls the full, multi-gigabyte image. See issue #1914.
|
|
65
52
|
const DEFAULT_HOST_DOCKER_SOCK = '/var/run/host-docker.sock';
|
|
66
|
-
// Force a POSIX shell for the inner command of Docker-isolated tasks. solve/
|
|
67
|
-
// hive/task live on the image's baked-in PATH, so `sh -c` resolves them without
|
|
68
|
-
// needing a login shell. Forcing the shell (instead of start's 'auto') also
|
|
69
|
-
// skips start's shell-detection probe, which would otherwise `docker run` a
|
|
70
|
-
// throwaway container — booting the dind image's dockerd entrypoint — purely to
|
|
71
|
-
// check whether bash exists. See issue #1914.
|
|
53
|
+
// Force a POSIX shell for the inner command of Docker-isolated tasks. solve/ hive/task live on the image's baked-in PATH, so `sh -c` resolves them without needing a login shell. Forcing the shell (instead of start's 'auto') also skips start's shell-detection probe, which would otherwise `docker run` a throwaway container — booting the dind image's dockerd entrypoint — purely to check whether bash exists. See issue #1914.
|
|
72
54
|
const DOCKER_ISOLATION_SHELL = 'sh';
|
|
73
|
-
// Free-space floor (GiB) below which the preflight warns that an impending
|
|
74
|
-
// isolation-image pull may fail with `no space left on device`. The Hive Mind
|
|
75
|
-
// isolation images are well over 30 GB extracted, so a host/nested daemon with
|
|
76
|
-
// less headroom than this cannot safely pull one. Diagnostic only — never
|
|
77
|
-
// blocks startup. See issue #1914.
|
|
55
|
+
// Free-space floor (GiB) below which the preflight warns that an impending isolation-image pull may fail with `no space left on device`. The Hive Mind isolation images are well over 30 GB extracted, so a host/nested daemon with less headroom than this cannot safely pull one. Diagnostic only — never blocks startup. See issue #1914.
|
|
78
56
|
const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
|
|
79
|
-
// Docker-only start gate used to capture the container writable-layer baseline
|
|
80
|
-
// before the task command begins cloning or generating files. The parent
|
|
81
|
-
// releases the gate immediately after `docker inspect --size`; the fallback
|
|
82
|
-
// keeps the task from hanging forever if the parent exits at the wrong time.
|
|
57
|
+
// Docker-only start gate used to capture the container writable-layer baseline before the task command begins cloning or generating files. The parent releases the gate immediately after `docker inspect --size`; the fallback keeps the task from hanging forever if the parent exits at the wrong time.
|
|
83
58
|
const DOCKER_START_GATE_WAIT_TENTHS = 300;
|
|
84
|
-
// Sentinel start-command's detached docker logger records when it cannot capture
|
|
85
|
-
// the container's real exit code. A terminal `$ --status` carrying this value is
|
|
86
|
-
// ambiguous — the container may still be running — so we cross-check it against
|
|
87
|
-
// a live `docker inspect` before concluding the session finished. See #1939.
|
|
88
|
-
// The upstream emission of this premature sentinel was fixed in
|
|
89
|
-
// start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images
|
|
90
|
-
// now pin; this cross-check is retained as defense-in-depth so an older `$` on
|
|
91
|
-
// an operator's PATH cannot resurrect the bug.
|
|
59
|
+
// Sentinel start-command's detached docker logger records when it cannot capture the container's real exit code. A terminal `$ --status` carrying this value is ambiguous — the container may still be running — so we cross-check it against a live `docker inspect` before concluding the session finished. See #1939. The upstream emission of this premature sentinel was fixed in start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images now pin; this cross-check is retained as defense-in-depth so an older `$` on an operator's PATH cannot resurrect the bug.
|
|
92
60
|
const DOCKER_UNKNOWN_EXIT_CODE = -1;
|
|
93
|
-
|
|
94
61
|
function normalizeProcessIds(value) {
|
|
95
62
|
if (!value || typeof value !== 'object') return {};
|
|
96
63
|
const out = {};
|
|
@@ -100,43 +67,35 @@ function normalizeProcessIds(value) {
|
|
|
100
67
|
}
|
|
101
68
|
return out;
|
|
102
69
|
}
|
|
103
|
-
|
|
104
70
|
function normalizeTool(tool) {
|
|
105
71
|
return String(tool || 'claude')
|
|
106
72
|
.trim()
|
|
107
73
|
.toLowerCase();
|
|
108
74
|
}
|
|
109
|
-
|
|
110
75
|
function shellQuote(value) {
|
|
111
76
|
const stringValue = String(value);
|
|
112
77
|
if (stringValue === '') return "''";
|
|
113
78
|
return `'${stringValue.replaceAll("'", "'\\''")}'`;
|
|
114
79
|
}
|
|
115
|
-
|
|
116
80
|
function buildShellCommand(command, args = []) {
|
|
117
81
|
return [command, ...args].map(shellQuote).join(' ');
|
|
118
82
|
}
|
|
119
|
-
|
|
120
83
|
function buildDockerStartGatePath(sessionId) {
|
|
121
84
|
return sessionId ? `/tmp/hive-mind-disk-baseline-${sessionId}` : null;
|
|
122
85
|
}
|
|
123
|
-
|
|
124
86
|
function buildDockerStartGatedCommand(taskCommand, sessionId) {
|
|
125
87
|
const gatePath = buildDockerStartGatePath(sessionId);
|
|
126
88
|
if (!gatePath) return taskCommand;
|
|
127
89
|
return `gate=${shellQuote(gatePath)}; i=0; while [ ! -e "$gate" ] && [ "$i" -lt ${DOCKER_START_GATE_WAIT_TENTHS} ]; do i=$((i+1)); sleep 0.1; done; rm -f "$gate"; exec ${taskCommand}`;
|
|
128
90
|
}
|
|
129
|
-
|
|
130
91
|
function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
|
|
131
92
|
return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
|
|
132
93
|
}
|
|
133
|
-
|
|
134
94
|
function maybeAddMount(mounts, source, target, existsSync) {
|
|
135
95
|
if (!source) return;
|
|
136
96
|
if (!existsSync(source)) return;
|
|
137
97
|
mounts.push({ source, target });
|
|
138
98
|
}
|
|
139
|
-
|
|
140
99
|
/**
|
|
141
100
|
* Resolve the tag used for the Docker isolation image.
|
|
142
101
|
*
|
|
@@ -152,7 +111,6 @@ export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
|
|
|
152
111
|
const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
|
|
153
112
|
return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
|
|
154
113
|
}
|
|
155
|
-
|
|
156
114
|
/**
|
|
157
115
|
* Pick the Docker image used for `--isolation docker`.
|
|
158
116
|
*
|
|
@@ -168,7 +126,6 @@ export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
|
168
126
|
const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
|
|
169
127
|
return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
|
|
170
128
|
}
|
|
171
|
-
|
|
172
129
|
/**
|
|
173
130
|
* Resolve the path where the host Docker socket is expected to be mounted inside
|
|
174
131
|
* a DinD container. box's entrypoint reads this socket to copy host images into
|
|
@@ -180,7 +137,6 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
|
180
137
|
const explicit = String(env.DIND_HOST_DOCKER_SOCK || '').trim();
|
|
181
138
|
return explicit || DEFAULT_HOST_DOCKER_SOCK;
|
|
182
139
|
}
|
|
183
|
-
|
|
184
140
|
/**
|
|
185
141
|
* Build host auth mounts for a Docker-isolated task.
|
|
186
142
|
*
|
|
@@ -196,31 +152,20 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
|
196
152
|
export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
|
|
197
153
|
const mounts = [];
|
|
198
154
|
const normalizedTool = normalizeTool(tool);
|
|
199
|
-
|
|
200
155
|
maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
|
|
201
|
-
|
|
202
|
-
// Git identity (tool-agnostic, required for commits). Honor the same env vars
|
|
203
|
-
// git itself reads for an alternate global config location (GIT_CONFIG_GLOBAL)
|
|
204
|
-
// and the XDG base dir, falling back to the conventional `~/.gitconfig` and
|
|
205
|
-
// `~/.config/git`. Missing host paths are skipped, so a container image that
|
|
206
|
-
// already bakes a git identity is left untouched. See issue #1939.
|
|
156
|
+
// Git identity (tool-agnostic, required for commits). Honor the same env vars git itself reads for an alternate global config location (GIT_CONFIG_GLOBAL) and the XDG base dir, falling back to the conventional `~/.gitconfig` and `~/.config/git`. Missing host paths are skipped, so a container image that already bakes a git identity is left untouched. See issue #1939.
|
|
207
157
|
maybeAddMount(mounts, env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), path.join(DOCKER_CONTAINER_HOME, '.gitconfig'), existsSync);
|
|
208
158
|
maybeAddMount(mounts, env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, 'git') : path.join(homeDir, '.config', 'git'), path.join(DOCKER_CONTAINER_HOME, '.config', 'git'), existsSync);
|
|
209
|
-
|
|
210
159
|
if (normalizedTool === 'codex') {
|
|
211
160
|
maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
|
|
212
|
-
// Issue #2074: Codex also discovers persistent user Agent Skills from
|
|
213
|
-
// ~/.agents/skills. Propagate that standard location alongside .codex so
|
|
214
|
-
// direct and Docker-isolated solver sessions expose the same capabilities.
|
|
161
|
+
// Issue #2074: Codex also discovers persistent user Agent Skills from ~/.agents/skills. Propagate that standard location alongside .codex so direct and Docker-isolated solver sessions expose the same capabilities.
|
|
215
162
|
maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
|
|
216
163
|
} else if (normalizedTool === 'claude') {
|
|
217
164
|
maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
|
|
218
165
|
maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
|
|
219
166
|
}
|
|
220
|
-
|
|
221
167
|
return mounts;
|
|
222
168
|
}
|
|
223
|
-
|
|
224
169
|
/**
|
|
225
170
|
* Resolve the image-variant marker recorded inside the isolated container.
|
|
226
171
|
* A `hive-mind-dind` image is always the dind variant; otherwise fall back to
|
|
@@ -229,7 +174,6 @@ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.en
|
|
|
229
174
|
function resolveImageVariant(image, env = process.env) {
|
|
230
175
|
return image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
|
|
231
176
|
}
|
|
232
|
-
|
|
233
177
|
/**
|
|
234
178
|
* Resolve an outer Compose HTTP service before handing its origin to a nested
|
|
235
179
|
* Docker daemon. The nested daemon has its own DNS namespace, but it can route
|
|
@@ -240,23 +184,19 @@ function resolveImageVariant(image, env = process.env) {
|
|
|
240
184
|
export async function resolveFormalAiIsolationEnv(env = process.env, { lookup = lookupHost } = {}) {
|
|
241
185
|
const baseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL;
|
|
242
186
|
if (!baseUrl) return env;
|
|
243
|
-
|
|
244
187
|
let parsed;
|
|
245
188
|
try {
|
|
246
189
|
parsed = new URL(baseUrl);
|
|
247
190
|
} catch {
|
|
248
191
|
return env;
|
|
249
192
|
}
|
|
250
|
-
|
|
251
193
|
if (parsed.protocol !== 'http:' || parsed.hostname !== FORMAL_AI_COMPOSE_HOSTNAME) {
|
|
252
194
|
return env;
|
|
253
195
|
}
|
|
254
|
-
|
|
255
196
|
try {
|
|
256
197
|
const addresses = await lookup(parsed.hostname, { all: true, verbatim: true });
|
|
257
198
|
const selected = addresses.find(candidate => candidate.family === 4) || addresses[0];
|
|
258
199
|
if (!selected?.address) return env;
|
|
259
|
-
|
|
260
200
|
const host = selected.family === 6 ? `[${selected.address}]` : selected.address;
|
|
261
201
|
return {
|
|
262
202
|
...env,
|
|
@@ -267,7 +207,6 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
|
|
|
267
207
|
return env;
|
|
268
208
|
}
|
|
269
209
|
}
|
|
270
|
-
|
|
271
210
|
/**
|
|
272
211
|
* Build the `$` (start-command) arguments that launch a Docker-isolated task
|
|
273
212
|
* using start-command's NATIVE Docker backend (`$ --isolated docker`).
|
|
@@ -288,39 +227,25 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
|
|
|
288
227
|
export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
|
|
289
228
|
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
290
229
|
const image = getDockerIsolationImage({ env });
|
|
291
|
-
|
|
292
230
|
const startArgs = ['--isolated', 'docker', '--image', image];
|
|
293
|
-
|
|
294
231
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
295
232
|
startArgs.push('--privileged');
|
|
296
233
|
}
|
|
297
|
-
|
|
298
|
-
// Force the inner shell so start-command does not probe the image to detect
|
|
299
|
-
// one (see DOCKER_ISOLATION_SHELL).
|
|
234
|
+
// Force the inner shell so start-command does not probe the image to detect one (see DOCKER_ISOLATION_SHELL).
|
|
300
235
|
startArgs.push('--shell', DOCKER_ISOLATION_SHELL);
|
|
301
|
-
|
|
302
|
-
// The image already sets HOME=/home/box and WORKDIR /home/box; pass HOME
|
|
303
|
-
// explicitly anyway so the credential mounts under /home/box resolve even if
|
|
304
|
-
// a future image forgets to. start-command has no --workdir flag, so the
|
|
305
|
-
// working directory comes from the image's WORKDIR.
|
|
236
|
+
// The image already sets HOME=/home/box and WORKDIR /home/box; pass HOME explicitly anyway so the credential mounts under /home/box resolve even if a future image forgets to. start-command has no --workdir flag, so the working directory comes from the image's WORKDIR.
|
|
306
237
|
startArgs.push('-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`, '-e', `HIVE_MIND_IMAGE_VARIANT=${resolveImageVariant(image, env)}`);
|
|
307
|
-
|
|
308
|
-
// A persistent Formal AI server normally runs beside the Telegram/root
|
|
309
|
-
// container. Docker-isolated `/solve` jobs must receive the same endpoint;
|
|
310
|
-
// otherwise the wrapper starts a per-job server and loses shared memory.
|
|
238
|
+
// A persistent Formal AI server normally runs beside the Telegram/root container. Docker-isolated `/solve` jobs must receive the same endpoint; otherwise the wrapper starts a per-job server and loses shared memory.
|
|
311
239
|
if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
|
|
312
240
|
startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
|
|
313
241
|
}
|
|
314
|
-
|
|
315
242
|
for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
|
|
316
243
|
startArgs.push('--volume', `${mount.source}:${mount.target}`);
|
|
317
244
|
}
|
|
318
|
-
|
|
319
245
|
const taskCommand = buildShellCommand(command, args);
|
|
320
246
|
startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
|
|
321
247
|
return startArgs;
|
|
322
248
|
}
|
|
323
|
-
|
|
324
249
|
export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
325
250
|
const { backend, sessionId } = options;
|
|
326
251
|
if (backend === 'docker') {
|
|
@@ -328,17 +253,14 @@ export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
|
328
253
|
}
|
|
329
254
|
return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
|
|
330
255
|
}
|
|
331
|
-
|
|
332
256
|
async function runStartCommand(binPath, startCommandArgs) {
|
|
333
257
|
return await new Promise(resolve => {
|
|
334
258
|
const child = spawn(binPath, startCommandArgs, {
|
|
335
259
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
336
260
|
env: process.env,
|
|
337
261
|
});
|
|
338
|
-
|
|
339
262
|
let stdout = '';
|
|
340
263
|
let stderr = '';
|
|
341
|
-
|
|
342
264
|
child.stdout.on('data', data => {
|
|
343
265
|
stdout += data.toString();
|
|
344
266
|
});
|
|
@@ -352,8 +274,7 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
352
274
|
error: error.message,
|
|
353
275
|
});
|
|
354
276
|
});
|
|
355
|
-
// Issue #2135: keep `signal` - the captured session's child was killed by
|
|
356
|
-
// one, and `code` alone was null.
|
|
277
|
+
// Issue #2135: keep `signal` - the captured session's child was killed by one, and `code` alone was null.
|
|
357
278
|
child.on('close', (code, signal) => {
|
|
358
279
|
const output = (stdout + (stderr ? `\n${stderr}` : '')).trim();
|
|
359
280
|
if (code === 0) {
|
|
@@ -368,7 +289,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
368
289
|
});
|
|
369
290
|
});
|
|
370
291
|
}
|
|
371
|
-
|
|
372
292
|
/**
|
|
373
293
|
* Generate a UUID v4 for unique session identification
|
|
374
294
|
* @returns {string} UUID v4 string
|
|
@@ -376,7 +296,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
376
296
|
export function generateSessionId() {
|
|
377
297
|
return crypto.randomUUID();
|
|
378
298
|
}
|
|
379
|
-
|
|
380
299
|
/**
|
|
381
300
|
* Parse output from `$ --status <session>`.
|
|
382
301
|
*
|
|
@@ -392,7 +311,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
392
311
|
if (!raw) {
|
|
393
312
|
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
|
|
394
313
|
}
|
|
395
|
-
|
|
396
314
|
const normalizeBooleanField = value => {
|
|
397
315
|
if (typeof value === 'boolean') return value;
|
|
398
316
|
if (value === null || value === undefined) return null;
|
|
@@ -401,15 +319,10 @@ export function parseSessionStatusOutput(output) {
|
|
|
401
319
|
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
402
320
|
return null;
|
|
403
321
|
};
|
|
404
|
-
|
|
405
322
|
try {
|
|
406
323
|
const parsed = JSON.parse(raw);
|
|
407
324
|
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
408
|
-
// start-command (link-foundation/start) reports the isolation backend at
|
|
409
|
-
// `options.isolated` in both JSON and links-notation output. Older
|
|
410
|
-
// hypothetical layouts used `options.isolation` or a top-level `isolation`
|
|
411
|
-
// field — keep accepting all three so we are tolerant of future renames.
|
|
412
|
-
// See https://github.com/link-assistant/hive-mind/issues/1700.
|
|
325
|
+
// start-command (link-foundation/start) reports the isolation backend at `options.isolated` in both JSON and links-notation output. Older hypothetical layouts used `options.isolation` or a top-level `isolation` field — keep accepting all three so we are tolerant of future renames. See https://github.com/link-assistant/hive-mind/issues/1700.
|
|
413
326
|
const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
|
|
414
327
|
const topPid = Number(data?.pid);
|
|
415
328
|
const processIds = normalizeProcessIds(data?.processIds);
|
|
@@ -434,7 +347,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
434
347
|
} catch {
|
|
435
348
|
// Fall through to text parsing.
|
|
436
349
|
}
|
|
437
|
-
|
|
438
350
|
const firstLine =
|
|
439
351
|
raw
|
|
440
352
|
.split('\n')
|
|
@@ -445,13 +357,9 @@ export function parseSessionStatusOutput(output) {
|
|
|
445
357
|
return match ? match[1].trim() : null;
|
|
446
358
|
};
|
|
447
359
|
const readBooleanField = name => normalizeBooleanField(readField(name));
|
|
448
|
-
|
|
449
360
|
const status = readField('status')?.toLowerCase() || null;
|
|
450
361
|
const exitCodeText = readField('exitCode');
|
|
451
|
-
// `start-command` links-notation output nests the isolation backend under
|
|
452
|
-
// `options` as `isolated <backend>` (not `isolation`). The leading indent
|
|
453
|
-
// varies by depth, but `readField` is anchored with `^\s*` which already
|
|
454
|
-
// matches indented lines. Older code only looked for `isolation`, which
|
|
362
|
+
// `start-command` links-notation output nests the isolation backend under `options` as `isolated <backend>` (not `isolation`). The leading indent varies by depth, but `readField` is anchored with `^\s*` which already matches indented lines. Older code only looked for `isolation`, which
|
|
455
363
|
// returned null for every real session and made /log + /terminal_watch
|
|
456
364
|
// reject screen/tmux/docker sessions. See issue #1700.
|
|
457
365
|
const isolationText = readField('isolated') || readField('isolation');
|
|
@@ -461,7 +369,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
461
369
|
const number = Number(value);
|
|
462
370
|
if (Number.isInteger(number) && number > 0) processIds[name] = number;
|
|
463
371
|
}
|
|
464
|
-
|
|
465
372
|
return {
|
|
466
373
|
exists: Boolean(status || firstLine),
|
|
467
374
|
uuid: readField('uuid') || firstLine,
|
|
@@ -480,7 +387,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
480
387
|
raw,
|
|
481
388
|
};
|
|
482
389
|
}
|
|
483
|
-
|
|
484
390
|
/**
|
|
485
391
|
* Decide whether a detached-docker exit code is "unknown" (not a real result).
|
|
486
392
|
*
|
|
@@ -498,11 +404,9 @@ export function parseSessionStatusOutput(output) {
|
|
|
498
404
|
export function isUnknownDockerExitCode(exitCode) {
|
|
499
405
|
return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
|
|
500
406
|
}
|
|
501
|
-
|
|
502
407
|
export function shouldFallbackToScreenStatus(statusResult) {
|
|
503
408
|
return !statusResult?.exists || !statusResult?.status;
|
|
504
409
|
}
|
|
505
|
-
|
|
506
410
|
/**
|
|
507
411
|
* Parse the footer start-command appends to every execution log when the wrapped
|
|
508
412
|
* command exits. The footer is authoritative about the terminal exit code even
|
|
@@ -536,7 +440,6 @@ export function parseSessionExitFooter(text) {
|
|
|
536
440
|
if (!last) return { finished: false, exitCode: null, endTime: null };
|
|
537
441
|
return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
|
|
538
442
|
}
|
|
539
|
-
|
|
540
443
|
/**
|
|
541
444
|
* Read the terminal exit code from the tail of a start-command execution log.
|
|
542
445
|
*
|
|
@@ -578,7 +481,6 @@ export function readSessionExitFromLog(logPath, options = {}) {
|
|
|
578
481
|
return { finished: false, exitCode: null, endTime: null };
|
|
579
482
|
}
|
|
580
483
|
}
|
|
581
|
-
|
|
582
484
|
/**
|
|
583
485
|
* Find the `$` CLI binary path
|
|
584
486
|
* @returns {Promise<string|null>} Path to `$` binary or null
|
|
@@ -593,7 +495,6 @@ async function findStartCommandBinary() {
|
|
|
593
495
|
return null;
|
|
594
496
|
}
|
|
595
497
|
}
|
|
596
|
-
|
|
597
498
|
/**
|
|
598
499
|
* Verbose post-launch diagnostics for a native docker-isolated session.
|
|
599
500
|
*
|
|
@@ -628,7 +529,6 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
628
529
|
// Diagnostics are best-effort; never let a probe failure affect the task.
|
|
629
530
|
}
|
|
630
531
|
}
|
|
631
|
-
|
|
632
532
|
/**
|
|
633
533
|
* Execute a command with isolation via `$` from start-command
|
|
634
534
|
*
|
|
@@ -644,7 +544,6 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
644
544
|
export async function executeWithIsolation(command, args, options = {}) {
|
|
645
545
|
const { backend, verbose = false } = options;
|
|
646
546
|
const sessionId = options.sessionId || generateSessionId();
|
|
647
|
-
|
|
648
547
|
if (!VALID_ISOLATION_BACKENDS.includes(backend)) {
|
|
649
548
|
return {
|
|
650
549
|
success: false,
|
|
@@ -653,7 +552,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
653
552
|
error: `Invalid isolation backend: '${backend}'. Must be one of: ${VALID_ISOLATION_BACKENDS.join(', ')}`,
|
|
654
553
|
};
|
|
655
554
|
}
|
|
656
|
-
|
|
657
555
|
const binPath = await findStartCommandBinary();
|
|
658
556
|
if (!binPath) {
|
|
659
557
|
return {
|
|
@@ -664,21 +562,27 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
664
562
|
error: 'start-command ($) not found',
|
|
665
563
|
};
|
|
666
564
|
}
|
|
667
|
-
|
|
668
565
|
if (verbose) {
|
|
669
566
|
console.log(`[VERBOSE] isolation-runner: Using $ binary at: ${binPath}`);
|
|
670
567
|
console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
|
|
671
568
|
}
|
|
672
|
-
|
|
569
|
+
// Issue #2146 / PR #2147 review: a Formal AI task gets its own sidecar,
|
|
570
|
+
// started on demand and reachable only over an internal Docker network. The
|
|
571
|
+
// lease is taken before the container is launched so the endpoint is known
|
|
572
|
+
// when the task's environment is built, and released again if the launch
|
|
573
|
+
// fails. Fail closed — a Formal AI task must never start without Formal AI.
|
|
574
|
+
const hostEnv = options.env || process.env;
|
|
575
|
+
const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
|
|
576
|
+
if (sidecarError) return { success: false, sessionId, output: '', error: sidecarError };
|
|
577
|
+
const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
|
|
673
578
|
const effectiveOptions =
|
|
674
579
|
backend === 'docker'
|
|
675
580
|
? {
|
|
676
581
|
...options,
|
|
677
|
-
env: await resolveFormalAiIsolationEnv(
|
|
582
|
+
env: await resolveFormalAiIsolationEnv(taskEnv),
|
|
678
583
|
}
|
|
679
584
|
: options;
|
|
680
585
|
const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
|
|
681
|
-
|
|
682
586
|
if (verbose) {
|
|
683
587
|
console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
|
|
684
588
|
if (backend === 'docker') {
|
|
@@ -694,24 +598,39 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
694
598
|
console.log(`[VERBOSE] isolation-runner: Docker isolation git identity propagated: ${gitIdentityMounted ? 'yes' : 'no (host ~/.gitconfig missing — child may fail with "Git identity not configured", issue #1939)'}`);
|
|
695
599
|
}
|
|
696
600
|
}
|
|
697
|
-
|
|
698
601
|
const result = await runStartCommand(binPath, startCommandArgs);
|
|
699
|
-
|
|
700
602
|
if (verbose) {
|
|
701
603
|
const stream = result.success ? console.log : console.error;
|
|
702
604
|
stream(`[VERBOSE] isolation-runner: Output: ${result.output.substring(0, 500)}`);
|
|
703
605
|
if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
|
|
704
606
|
}
|
|
705
|
-
|
|
706
607
|
let containerFilesystemStartBytes = null;
|
|
608
|
+
let formalAiAttachError = null;
|
|
707
609
|
if (result.success && backend === 'docker') {
|
|
708
610
|
try {
|
|
709
611
|
containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
|
|
612
|
+
// The task command is still held by the start gate — the only safe
|
|
613
|
+
// moment to add a second network. `docker network connect` is additive;
|
|
614
|
+
// a single `docker run --network` would replace the default bridge and
|
|
615
|
+
// cut the task off from GitHub (issue #2146). start-command 0.32.0+
|
|
616
|
+
// could attach both networks at launch (repeatable `--network`,
|
|
617
|
+
// start#156), but it implements that with this very create → connect →
|
|
618
|
+
// start sequence, and doing it here keeps the attach fail-closed on any
|
|
619
|
+
// installed version instead of silently one-network on older parsers.
|
|
620
|
+
formalAiAttachError = await attachFormalAiTaskContainer({ sidecar, sessionId, verbose });
|
|
710
621
|
} finally {
|
|
711
622
|
await releaseDockerContainerStartGate(sessionId, verbose);
|
|
712
623
|
}
|
|
713
624
|
}
|
|
714
|
-
|
|
625
|
+
if (sidecar && (!result.success || formalAiAttachError)) {
|
|
626
|
+
// Fail closed: without the internal network the task cannot reach Formal
|
|
627
|
+
// AI, and issue #2146 forbids falling back to another model.
|
|
628
|
+
if (formalAiAttachError) await removeDockerContainer(sessionId, verbose);
|
|
629
|
+
await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
|
|
630
|
+
if (formalAiAttachError) {
|
|
631
|
+
return { success: false, sessionId, output: result.output, error: `Formal AI task container could not be attached to the internal Formal AI network, so the task was stopped instead of falling back to another model (issue #2146): ${formalAiAttachError}` };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
715
634
|
// Issue #1939: capture the freshly-launched docker session's reported status
|
|
716
635
|
// and the live container state together, so the next iteration has the data to
|
|
717
636
|
// diagnose a premature "executed/-1" status (problem #1) or a surprise image
|
|
@@ -719,7 +638,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
719
638
|
if (verbose && backend === 'docker' && result.success) {
|
|
720
639
|
await logDockerIsolationPostLaunchDiagnostics(sessionId, options.env || process.env);
|
|
721
640
|
}
|
|
722
|
-
|
|
723
641
|
if (result.success) {
|
|
724
642
|
return {
|
|
725
643
|
success: true,
|
|
@@ -728,7 +646,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
728
646
|
containerFilesystemStartBytes,
|
|
729
647
|
};
|
|
730
648
|
}
|
|
731
|
-
|
|
732
649
|
return {
|
|
733
650
|
success: false,
|
|
734
651
|
sessionId,
|
|
@@ -736,7 +653,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
736
653
|
error: result.error,
|
|
737
654
|
};
|
|
738
655
|
}
|
|
739
|
-
|
|
740
656
|
/**
|
|
741
657
|
* Query the status of an isolated session via `$ --status <uuid>`
|
|
742
658
|
*
|
|
@@ -752,17 +668,13 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
752
668
|
}
|
|
753
669
|
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
|
|
754
670
|
}
|
|
755
|
-
|
|
756
671
|
try {
|
|
757
672
|
const $ = await getCommandStreamDollar();
|
|
758
673
|
const result = await $({ mirror: false })`${binPath} --status ${sessionId} --output-format json`;
|
|
759
|
-
|
|
760
674
|
const stdout = result.stdout?.toString().trim() || '';
|
|
761
|
-
|
|
762
675
|
if (verbose) {
|
|
763
676
|
console.log(`[VERBOSE] isolation-runner: Status query result: ${stdout.substring(0, 300)}`);
|
|
764
677
|
}
|
|
765
|
-
|
|
766
678
|
return parseSessionStatusOutput(stdout);
|
|
767
679
|
} catch (error) {
|
|
768
680
|
if (verbose) {
|
|
@@ -771,7 +683,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
771
683
|
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
|
|
772
684
|
}
|
|
773
685
|
}
|
|
774
|
-
|
|
775
686
|
/**
|
|
776
687
|
* Parse output from `$ --list --output-format json`.
|
|
777
688
|
*
|
|
@@ -793,7 +704,6 @@ export function parseSessionListOutput(output) {
|
|
|
793
704
|
return [];
|
|
794
705
|
}
|
|
795
706
|
const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
796
|
-
|
|
797
707
|
return records
|
|
798
708
|
.map(data => {
|
|
799
709
|
if (!data || typeof data !== 'object') return null;
|
|
@@ -812,7 +722,6 @@ export function parseSessionListOutput(output) {
|
|
|
812
722
|
})
|
|
813
723
|
.filter(Boolean);
|
|
814
724
|
}
|
|
815
|
-
|
|
816
725
|
/**
|
|
817
726
|
* List all executions known to start-command via `$ --list --output-format json`.
|
|
818
727
|
*
|
|
@@ -844,7 +753,6 @@ export async function listIsolationSessions(verbose = false) {
|
|
|
844
753
|
return [];
|
|
845
754
|
}
|
|
846
755
|
}
|
|
847
|
-
|
|
848
756
|
/**
|
|
849
757
|
* Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
|
|
850
758
|
*
|
|
@@ -868,7 +776,6 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
868
776
|
error: '`$` (start-command) binary not found on PATH. Install link-foundation/start to use /stop <UUID>.',
|
|
869
777
|
};
|
|
870
778
|
}
|
|
871
|
-
|
|
872
779
|
try {
|
|
873
780
|
const $ = await getCommandStreamDollar();
|
|
874
781
|
const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
|
|
@@ -894,7 +801,6 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
894
801
|
};
|
|
895
802
|
}
|
|
896
803
|
}
|
|
897
|
-
|
|
898
804
|
/**
|
|
899
805
|
* Check if a screen session exists via `screen -ls`.
|
|
900
806
|
* Used as a fallback when `$ --status` fails to find or correctly track
|
|
@@ -920,7 +826,6 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
|
920
826
|
return false;
|
|
921
827
|
}
|
|
922
828
|
}
|
|
923
|
-
|
|
924
829
|
/**
|
|
925
830
|
* Check whether the Docker container backing a native `$ --isolated docker`
|
|
926
831
|
* session is still running.
|
|
@@ -950,14 +855,12 @@ export async function checkDockerContainerRunning(containerName, verbose = false
|
|
|
950
855
|
return false;
|
|
951
856
|
}
|
|
952
857
|
}
|
|
953
|
-
|
|
954
858
|
export function parseDockerContainerWritableLayerSizeOutput(output) {
|
|
955
859
|
const text = String(output || '').trim();
|
|
956
860
|
if (!text) return null;
|
|
957
861
|
const bytes = Number.parseInt(text.split(/\s+/)[0], 10);
|
|
958
862
|
return Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
|
|
959
863
|
}
|
|
960
|
-
|
|
961
864
|
/**
|
|
962
865
|
* Best-effort size of a Docker task container's writable layer.
|
|
963
866
|
*
|
|
@@ -988,7 +891,6 @@ export async function getDockerContainerWritableLayerSize(containerName, verbose
|
|
|
988
891
|
return null;
|
|
989
892
|
}
|
|
990
893
|
}
|
|
991
|
-
|
|
992
894
|
/**
|
|
993
895
|
* Release the Docker-only start gate after the writable-layer baseline has been
|
|
994
896
|
* captured. Best-effort: the gated task also has a timeout fallback.
|
|
@@ -1002,7 +904,6 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
|
|
|
1002
904
|
if (!containerName || !gatePath) return false;
|
|
1003
905
|
const releaseCommand = `touch ${shellQuote(gatePath)}`;
|
|
1004
906
|
let lastError = null;
|
|
1005
|
-
|
|
1006
907
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
1007
908
|
try {
|
|
1008
909
|
const $ = await getCommandStreamDollar();
|
|
@@ -1016,14 +917,12 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
|
|
|
1016
917
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1017
918
|
}
|
|
1018
919
|
}
|
|
1019
|
-
|
|
1020
920
|
if (verbose) {
|
|
1021
921
|
const stderr = lastError?.stderr?.toString?.().trim();
|
|
1022
922
|
console.log(`[VERBOSE] isolation-runner: could not release docker start gate for '${containerName}': ${stderr || lastError?.message || lastError}`);
|
|
1023
923
|
}
|
|
1024
924
|
return false;
|
|
1025
925
|
}
|
|
1026
|
-
|
|
1027
926
|
/**
|
|
1028
927
|
* Best-effort removal for a Docker container backing a native
|
|
1029
928
|
* `$ --isolated docker` session.
|
|
@@ -1042,7 +941,6 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
1042
941
|
if (!containerName) {
|
|
1043
942
|
return { success: false, output: '', error: 'missing container name' };
|
|
1044
943
|
}
|
|
1045
|
-
|
|
1046
944
|
try {
|
|
1047
945
|
const $ = await getCommandStreamDollar();
|
|
1048
946
|
const result = await $({ mirror: false })`docker rm -f ${containerName}`;
|
|
@@ -1065,7 +963,6 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
1065
963
|
};
|
|
1066
964
|
}
|
|
1067
965
|
}
|
|
1068
|
-
|
|
1069
966
|
/**
|
|
1070
967
|
* Check whether a tmux session with the given name still exists.
|
|
1071
968
|
* `tmux has-session -t <name>` exits 0 when it exists and non-zero otherwise,
|
|
@@ -1086,7 +983,6 @@ export async function checkTmuxSessionRunning(sessionName, verbose = false) {
|
|
|
1086
983
|
return false;
|
|
1087
984
|
}
|
|
1088
985
|
}
|
|
1089
|
-
|
|
1090
986
|
/**
|
|
1091
987
|
* Directly probe whether the backend session/container is still alive, bypassing
|
|
1092
988
|
* `$ --status`. This is the cross-check used to detect a session that
|
|
@@ -1105,7 +1001,6 @@ export async function checkBackendSessionAlive(sessionId, backend, verbose = fal
|
|
|
1105
1001
|
if (backend === 'docker') return checkDockerContainerRunning(sessionId, verbose);
|
|
1106
1002
|
return null;
|
|
1107
1003
|
}
|
|
1108
|
-
|
|
1109
1004
|
/**
|
|
1110
1005
|
* Check whether an image is present in the local Docker daemon.
|
|
1111
1006
|
*
|
|
@@ -1130,7 +1025,6 @@ export async function checkDockerImagePresent(image, verbose = false) {
|
|
|
1130
1025
|
return false;
|
|
1131
1026
|
}
|
|
1132
1027
|
}
|
|
1133
|
-
|
|
1134
1028
|
/**
|
|
1135
1029
|
* Report the storage driver the (nested) Docker daemon is using.
|
|
1136
1030
|
*
|
|
@@ -1159,7 +1053,6 @@ export async function checkDockerStorageDriver(verbose = false) {
|
|
|
1159
1053
|
return null;
|
|
1160
1054
|
}
|
|
1161
1055
|
}
|
|
1162
|
-
|
|
1163
1056
|
/**
|
|
1164
1057
|
* Report the free space (in GiB) on the Docker daemon's data root.
|
|
1165
1058
|
*
|
|
@@ -1187,7 +1080,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1187
1080
|
// Daemon unreachable: fall back to the conventional data root. If df then
|
|
1188
1081
|
// fails on it (e.g. the path does not exist) we return null below.
|
|
1189
1082
|
}
|
|
1190
|
-
|
|
1191
1083
|
const $ = await getCommandStreamDollar();
|
|
1192
1084
|
const df = await $({ mirror: false })`df -Pk ${dataRoot}`;
|
|
1193
1085
|
// `df -P` guarantees one logical line per filesystem (no wrapping). The last
|
|
@@ -1207,7 +1099,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1207
1099
|
return null;
|
|
1208
1100
|
}
|
|
1209
1101
|
}
|
|
1210
|
-
|
|
1211
1102
|
/**
|
|
1212
1103
|
* Startup preflight for `--isolation docker`.
|
|
1213
1104
|
*
|
|
@@ -1246,7 +1137,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1246
1137
|
*/
|
|
1247
1138
|
export async function preflightDockerIsolation(options = {}) {
|
|
1248
1139
|
const { env = process.env, existsSync = fs.existsSync, verbose = false, logger = console, checkImagePresent = checkDockerImagePresent, checkStorageDriver = checkDockerStorageDriver, checkDiskSpace = checkDockerDiskSpace } = options;
|
|
1249
|
-
|
|
1250
1140
|
const image = getDockerIsolationImage({ env });
|
|
1251
1141
|
const sock = resolveHostDockerSock({ env });
|
|
1252
1142
|
const isDind = shouldRunPrivilegedDockerIsolation(image, env);
|
|
@@ -1258,13 +1148,10 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1258
1148
|
// Unknown driver (probe returned null) is treated as ok — we only flag the
|
|
1259
1149
|
// one driver known to overflow the disk, never block on missing information.
|
|
1260
1150
|
const storageDriverOk = storageDriver !== 'vfs';
|
|
1261
|
-
|
|
1262
1151
|
const result = { image, sock, socketMounted, imagePresent, isDind, storageDriver, storageDriverOk, diskAvailableGiB, ok: imagePresent, warnings: [] };
|
|
1263
1152
|
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
1264
1153
|
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
1265
|
-
|
|
1266
1154
|
const preload = `node scripts/preload-dind-isolation-image.mjs --image ${image}`;
|
|
1267
|
-
|
|
1268
1155
|
// Root Cause A of the issue #1914 reopen: a non-copy-on-write storage driver.
|
|
1269
1156
|
// `vfs` stores a full copy of every image layer, so the multi-GB images
|
|
1270
1157
|
// consume many times their size on disk and any layer write (pull, run,
|
|
@@ -1275,7 +1162,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1275
1162
|
if (storageDriver === 'vfs') {
|
|
1276
1163
|
result.warnings.push(`The Docker daemon backing '--isolation docker' is using the 'vfs' storage driver, which performs NO copy-on-write: ` + `it stores a full copy of every image layer, so the multi-GB Hive Mind images consume many times their size on disk and isolated tasks can fail with 'failed to register layer: no space left on device' (issue #1914). ` + `Switch to a copy-on-write driver: rebuild/redeploy with the current Dockerfile.dind (it defaults to 'fuse-overlayfs'), or for an already-running container add '-e DIND_STORAGE_DRIVER=fuse-overlayfs' to the bot container's 'docker run' and recreate it.`);
|
|
1277
1164
|
}
|
|
1278
|
-
|
|
1279
1165
|
if (!imagePresent) {
|
|
1280
1166
|
// Image absent: the first isolated task will pull the full image. Explain
|
|
1281
1167
|
// the most likely cause and the exact fix instead of letting the operator
|
|
@@ -1287,7 +1173,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1287
1173
|
} else {
|
|
1288
1174
|
result.warnings.push(`Docker isolation image '${image}' is not present locally; the first isolated task will pull it. ` + `If this host already has it under a different tag, pin HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG, or seed it with: ${preload}`);
|
|
1289
1175
|
}
|
|
1290
|
-
|
|
1291
1176
|
// Root Cause B of the issue #1914 reopen: too little disk for the pull. The
|
|
1292
1177
|
// image is well over 30 GB extracted; predict the `no space left on device`
|
|
1293
1178
|
// failure here rather than hitting it mid-pull.
|
|
@@ -1296,14 +1181,12 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1296
1181
|
result.warnings.push(`Only ~${diskAvailableGiB.toFixed(0)} GiB free on ${root} and the isolation image '${image}' is not present yet. ` + `The Hive Mind isolation image is well over 30 GB extracted, so the first isolated task's pull may fail with 'no space left on device' (issue #1914). ` + `Seed it via host passthrough (mount the host docker socket) or with '${preload}', and free space on the Docker data root.`);
|
|
1297
1182
|
}
|
|
1298
1183
|
}
|
|
1299
|
-
|
|
1300
1184
|
if (imagePresent) {
|
|
1301
1185
|
info(`✅ Docker isolation image '${image}' is already present locally — isolated tasks reuse it (no multi-GB pull). See issue #1914.`);
|
|
1302
1186
|
}
|
|
1303
1187
|
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
1304
1188
|
return result;
|
|
1305
1189
|
}
|
|
1306
|
-
|
|
1307
1190
|
/**
|
|
1308
1191
|
* Host paths that, when present, propagate a git identity into a docker-isolated
|
|
1309
1192
|
* container via getDockerIsolationAuthMounts. Honors the same env vars git reads
|
|
@@ -1313,7 +1196,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1313
1196
|
export function resolveHostGitIdentityPaths({ env = process.env, homeDir = os.homedir() } = {}) {
|
|
1314
1197
|
return [env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, 'git') : path.join(homeDir, '.config', 'git')];
|
|
1315
1198
|
}
|
|
1316
|
-
|
|
1317
1199
|
/**
|
|
1318
1200
|
* True when the host exposes a git identity that getDockerIsolationAuthMounts can
|
|
1319
1201
|
* mount into an isolated container. See issue #1939.
|
|
@@ -1321,7 +1203,6 @@ export function resolveHostGitIdentityPaths({ env = process.env, homeDir = os.ho
|
|
|
1321
1203
|
export function hostHasMountableGitIdentity({ env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
|
|
1322
1204
|
return resolveHostGitIdentityPaths({ env, homeDir }).some(p => Boolean(existsSync(p)));
|
|
1323
1205
|
}
|
|
1324
|
-
|
|
1325
1206
|
/**
|
|
1326
1207
|
* Startup git-identity preflight for `--isolation docker`.
|
|
1327
1208
|
*
|
|
@@ -1353,13 +1234,11 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
|
|
|
1353
1234
|
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
1354
1235
|
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
1355
1236
|
const result = { present: false, repaired: false, warnings: [] };
|
|
1356
|
-
|
|
1357
1237
|
if (hostHasMountableGitIdentity({ env, homeDir, existsSync })) {
|
|
1358
1238
|
result.present = true;
|
|
1359
1239
|
info('✅ Host git identity present — docker-isolated tasks inherit it via the mounted ~/.gitconfig (issue #1939).');
|
|
1360
1240
|
return result;
|
|
1361
1241
|
}
|
|
1362
|
-
|
|
1363
1242
|
// No mountable identity. Try to derive one from the authenticated gh account
|
|
1364
1243
|
// so the next isolated task does not fail with "Git identity not configured".
|
|
1365
1244
|
const repairFn =
|
|
@@ -1374,19 +1253,16 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
|
|
|
1374
1253
|
} catch (error) {
|
|
1375
1254
|
repairOutcome = { success: false, error: error?.message || String(error) };
|
|
1376
1255
|
}
|
|
1377
|
-
|
|
1378
1256
|
if (repairOutcome?.success && hostHasMountableGitIdentity({ env, homeDir, existsSync })) {
|
|
1379
1257
|
result.present = true;
|
|
1380
1258
|
result.repaired = true;
|
|
1381
1259
|
info('✅ Host git identity was missing; derived it from the authenticated gh account via gh-setup-git-identity so docker-isolated tasks can mount it (issue #1939).');
|
|
1382
1260
|
return result;
|
|
1383
1261
|
}
|
|
1384
|
-
|
|
1385
1262
|
result.warnings.push(`No host git identity (~/.gitconfig) to mount into docker-isolated containers, so isolated 'solve' tasks will fail with "Git identity not configured" even though gh is authenticated (issue #1939). ` + `Configure one on the bot host: run 'gh-setup-git-identity' (derives it from the authenticated gh account), set 'git config --global user.name/.email', or pass '--auto-gh-configuration-repair' to solve.` + (repairOutcome?.error ? ` Auto-repair attempt failed: ${repairOutcome.error}` : ''));
|
|
1386
1263
|
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
1387
1264
|
return result;
|
|
1388
1265
|
}
|
|
1389
|
-
|
|
1390
1266
|
/**
|
|
1391
1267
|
* Check if an isolated session is still running.
|
|
1392
1268
|
* Uses `$ --status` first, with a backend-specific fallback (screen -ls for
|
|
@@ -1403,7 +1279,6 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1403
1279
|
// Support legacy call signature: isSessionRunning(sessionId, verbose)
|
|
1404
1280
|
const opts = typeof options === 'boolean' ? { verbose: options } : options;
|
|
1405
1281
|
const { backend, verbose = false } = opts;
|
|
1406
|
-
|
|
1407
1282
|
const result = await querySessionStatus(sessionId, verbose);
|
|
1408
1283
|
if (result.exists && result.status) {
|
|
1409
1284
|
if (isExecutingSessionStatus(result.status)) {
|
|
@@ -1428,7 +1303,6 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1428
1303
|
return false;
|
|
1429
1304
|
}
|
|
1430
1305
|
}
|
|
1431
|
-
|
|
1432
1306
|
// Fallback used only when `$ --status` has no usable record. This works
|
|
1433
1307
|
// around older start-command bugs where `$ --status` can't resolve a session
|
|
1434
1308
|
// by its --session name (only by an internal UUID). See issue #1545.
|
|
@@ -1453,10 +1327,8 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1453
1327
|
return containerRunning;
|
|
1454
1328
|
}
|
|
1455
1329
|
}
|
|
1456
|
-
|
|
1457
1330
|
return false;
|
|
1458
1331
|
}
|
|
1459
|
-
|
|
1460
1332
|
/**
|
|
1461
1333
|
* Validate that an isolation backend value is valid
|
|
1462
1334
|
* @param {string} backend - Backend value to validate
|
|
@@ -1465,5 +1337,4 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1465
1337
|
export function isValidIsolationBackend(backend) {
|
|
1466
1338
|
return VALID_ISOLATION_BACKENDS.includes(backend);
|
|
1467
1339
|
}
|
|
1468
|
-
|
|
1469
1340
|
export { VALID_ISOLATION_BACKENDS };
|