@link-assistant/hive-mind 2.12.0 → 2.12.2
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 +14 -0
- package/package.json +4 -1
- package/src/claude.connection.lib.mjs +209 -0
- package/src/claude.lib.mjs +6 -202
- package/src/codex.lib.mjs +0 -128
- 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 +14 -174
- package/src/limits.lib.mjs +0 -89
- package/src/models/index.mjs +0 -15
- 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 +0 -90
- package/src/solve.session.lib.mjs +52 -19
- package/src/solve.tool-uncommitted.lib.mjs +22 -0
- package/src/telegram-bot.mjs +0 -66
- package/src/telegram-merge-queue.lib.mjs +3 -155
- package/src/telegram-solve-queue.lib.mjs +9 -168
- package/src/use-m-bootstrap.lib.mjs +6 -5
- package/src/use-with-retry.lib.mjs +128 -2
|
@@ -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';
|
|
@@ -22,9 +21,7 @@ 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
|
-
|
|
26
24
|
let commandStreamDollarPromise = null;
|
|
27
|
-
|
|
28
25
|
async function getCommandStreamDollar() {
|
|
29
26
|
if (!commandStreamDollarPromise) {
|
|
30
27
|
commandStreamDollarPromise = (async () => {
|
|
@@ -35,7 +32,6 @@ async function getCommandStreamDollar() {
|
|
|
35
32
|
return $;
|
|
36
33
|
})();
|
|
37
34
|
}
|
|
38
|
-
|
|
39
35
|
try {
|
|
40
36
|
return await commandStreamDollarPromise;
|
|
41
37
|
} catch (error) {
|
|
@@ -43,13 +39,8 @@ async function getCommandStreamDollar() {
|
|
|
43
39
|
throw error;
|
|
44
40
|
}
|
|
45
41
|
}
|
|
46
|
-
|
|
47
|
-
// Re-export the shared status predicates so existing callers that reach them via
|
|
48
|
-
// the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`)
|
|
49
|
-
// keep working. The canonical definitions live in session-status.lib.mjs so the
|
|
50
|
-
// 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).
|
|
51
43
|
export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
|
|
52
|
-
|
|
53
44
|
// Valid isolation backends
|
|
54
45
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
55
46
|
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
@@ -57,41 +48,16 @@ const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
|
57
48
|
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
58
49
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
59
50
|
const FORMAL_AI_COMPOSE_HOSTNAME = 'link-assistant-formal-ai';
|
|
60
|
-
// Default path where the host Docker socket is bind-mounted inside a DinD
|
|
61
|
-
// container so box's host-image passthrough can copy host images into the
|
|
62
|
-
// nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy
|
|
63
|
-
// must mount it (`-v /var/run/docker.sock:/var/run/host-docker.sock:ro`) or the
|
|
64
|
-
// nested daemon starts empty and the first isolated task pulls the full,
|
|
65
|
-
// 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.
|
|
66
52
|
const DEFAULT_HOST_DOCKER_SOCK = '/var/run/host-docker.sock';
|
|
67
|
-
// Force a POSIX shell for the inner command of Docker-isolated tasks. solve/
|
|
68
|
-
// hive/task live on the image's baked-in PATH, so `sh -c` resolves them without
|
|
69
|
-
// needing a login shell. Forcing the shell (instead of start's 'auto') also
|
|
70
|
-
// skips start's shell-detection probe, which would otherwise `docker run` a
|
|
71
|
-
// throwaway container — booting the dind image's dockerd entrypoint — purely to
|
|
72
|
-
// 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.
|
|
73
54
|
const DOCKER_ISOLATION_SHELL = 'sh';
|
|
74
|
-
// Free-space floor (GiB) below which the preflight warns that an impending
|
|
75
|
-
// isolation-image pull may fail with `no space left on device`. The Hive Mind
|
|
76
|
-
// isolation images are well over 30 GB extracted, so a host/nested daemon with
|
|
77
|
-
// less headroom than this cannot safely pull one. Diagnostic only — never
|
|
78
|
-
// 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.
|
|
79
56
|
const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
|
|
80
|
-
// Docker-only start gate used to capture the container writable-layer baseline
|
|
81
|
-
// before the task command begins cloning or generating files. The parent
|
|
82
|
-
// releases the gate immediately after `docker inspect --size`; the fallback
|
|
83
|
-
// 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.
|
|
84
58
|
const DOCKER_START_GATE_WAIT_TENTHS = 300;
|
|
85
|
-
// Sentinel start-command's detached docker logger records when it cannot capture
|
|
86
|
-
// the container's real exit code. A terminal `$ --status` carrying this value is
|
|
87
|
-
// ambiguous — the container may still be running — so we cross-check it against
|
|
88
|
-
// a live `docker inspect` before concluding the session finished. See #1939.
|
|
89
|
-
// The upstream emission of this premature sentinel was fixed in
|
|
90
|
-
// start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images
|
|
91
|
-
// now pin; this cross-check is retained as defense-in-depth so an older `$` on
|
|
92
|
-
// 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.
|
|
93
60
|
const DOCKER_UNKNOWN_EXIT_CODE = -1;
|
|
94
|
-
|
|
95
61
|
function normalizeProcessIds(value) {
|
|
96
62
|
if (!value || typeof value !== 'object') return {};
|
|
97
63
|
const out = {};
|
|
@@ -101,43 +67,35 @@ function normalizeProcessIds(value) {
|
|
|
101
67
|
}
|
|
102
68
|
return out;
|
|
103
69
|
}
|
|
104
|
-
|
|
105
70
|
function normalizeTool(tool) {
|
|
106
71
|
return String(tool || 'claude')
|
|
107
72
|
.trim()
|
|
108
73
|
.toLowerCase();
|
|
109
74
|
}
|
|
110
|
-
|
|
111
75
|
function shellQuote(value) {
|
|
112
76
|
const stringValue = String(value);
|
|
113
77
|
if (stringValue === '') return "''";
|
|
114
78
|
return `'${stringValue.replaceAll("'", "'\\''")}'`;
|
|
115
79
|
}
|
|
116
|
-
|
|
117
80
|
function buildShellCommand(command, args = []) {
|
|
118
81
|
return [command, ...args].map(shellQuote).join(' ');
|
|
119
82
|
}
|
|
120
|
-
|
|
121
83
|
function buildDockerStartGatePath(sessionId) {
|
|
122
84
|
return sessionId ? `/tmp/hive-mind-disk-baseline-${sessionId}` : null;
|
|
123
85
|
}
|
|
124
|
-
|
|
125
86
|
function buildDockerStartGatedCommand(taskCommand, sessionId) {
|
|
126
87
|
const gatePath = buildDockerStartGatePath(sessionId);
|
|
127
88
|
if (!gatePath) return taskCommand;
|
|
128
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}`;
|
|
129
90
|
}
|
|
130
|
-
|
|
131
91
|
function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
|
|
132
92
|
return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
|
|
133
93
|
}
|
|
134
|
-
|
|
135
94
|
function maybeAddMount(mounts, source, target, existsSync) {
|
|
136
95
|
if (!source) return;
|
|
137
96
|
if (!existsSync(source)) return;
|
|
138
97
|
mounts.push({ source, target });
|
|
139
98
|
}
|
|
140
|
-
|
|
141
99
|
/**
|
|
142
100
|
* Resolve the tag used for the Docker isolation image.
|
|
143
101
|
*
|
|
@@ -153,7 +111,6 @@ export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
|
|
|
153
111
|
const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
|
|
154
112
|
return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
|
|
155
113
|
}
|
|
156
|
-
|
|
157
114
|
/**
|
|
158
115
|
* Pick the Docker image used for `--isolation docker`.
|
|
159
116
|
*
|
|
@@ -169,7 +126,6 @@ export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
|
169
126
|
const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
|
|
170
127
|
return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
|
|
171
128
|
}
|
|
172
|
-
|
|
173
129
|
/**
|
|
174
130
|
* Resolve the path where the host Docker socket is expected to be mounted inside
|
|
175
131
|
* a DinD container. box's entrypoint reads this socket to copy host images into
|
|
@@ -181,7 +137,6 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
|
181
137
|
const explicit = String(env.DIND_HOST_DOCKER_SOCK || '').trim();
|
|
182
138
|
return explicit || DEFAULT_HOST_DOCKER_SOCK;
|
|
183
139
|
}
|
|
184
|
-
|
|
185
140
|
/**
|
|
186
141
|
* Build host auth mounts for a Docker-isolated task.
|
|
187
142
|
*
|
|
@@ -197,31 +152,20 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
|
197
152
|
export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
|
|
198
153
|
const mounts = [];
|
|
199
154
|
const normalizedTool = normalizeTool(tool);
|
|
200
|
-
|
|
201
155
|
maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
|
|
202
|
-
|
|
203
|
-
// Git identity (tool-agnostic, required for commits). Honor the same env vars
|
|
204
|
-
// git itself reads for an alternate global config location (GIT_CONFIG_GLOBAL)
|
|
205
|
-
// and the XDG base dir, falling back to the conventional `~/.gitconfig` and
|
|
206
|
-
// `~/.config/git`. Missing host paths are skipped, so a container image that
|
|
207
|
-
// 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.
|
|
208
157
|
maybeAddMount(mounts, env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), path.join(DOCKER_CONTAINER_HOME, '.gitconfig'), existsSync);
|
|
209
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);
|
|
210
|
-
|
|
211
159
|
if (normalizedTool === 'codex') {
|
|
212
160
|
maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
|
|
213
|
-
// Issue #2074: Codex also discovers persistent user Agent Skills from
|
|
214
|
-
// ~/.agents/skills. Propagate that standard location alongside .codex so
|
|
215
|
-
// 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.
|
|
216
162
|
maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
|
|
217
163
|
} else if (normalizedTool === 'claude') {
|
|
218
164
|
maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
|
|
219
165
|
maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
|
|
220
166
|
}
|
|
221
|
-
|
|
222
167
|
return mounts;
|
|
223
168
|
}
|
|
224
|
-
|
|
225
169
|
/**
|
|
226
170
|
* Resolve the image-variant marker recorded inside the isolated container.
|
|
227
171
|
* A `hive-mind-dind` image is always the dind variant; otherwise fall back to
|
|
@@ -230,7 +174,6 @@ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.en
|
|
|
230
174
|
function resolveImageVariant(image, env = process.env) {
|
|
231
175
|
return image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
|
|
232
176
|
}
|
|
233
|
-
|
|
234
177
|
/**
|
|
235
178
|
* Resolve an outer Compose HTTP service before handing its origin to a nested
|
|
236
179
|
* Docker daemon. The nested daemon has its own DNS namespace, but it can route
|
|
@@ -241,23 +184,19 @@ function resolveImageVariant(image, env = process.env) {
|
|
|
241
184
|
export async function resolveFormalAiIsolationEnv(env = process.env, { lookup = lookupHost } = {}) {
|
|
242
185
|
const baseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL;
|
|
243
186
|
if (!baseUrl) return env;
|
|
244
|
-
|
|
245
187
|
let parsed;
|
|
246
188
|
try {
|
|
247
189
|
parsed = new URL(baseUrl);
|
|
248
190
|
} catch {
|
|
249
191
|
return env;
|
|
250
192
|
}
|
|
251
|
-
|
|
252
193
|
if (parsed.protocol !== 'http:' || parsed.hostname !== FORMAL_AI_COMPOSE_HOSTNAME) {
|
|
253
194
|
return env;
|
|
254
195
|
}
|
|
255
|
-
|
|
256
196
|
try {
|
|
257
197
|
const addresses = await lookup(parsed.hostname, { all: true, verbatim: true });
|
|
258
198
|
const selected = addresses.find(candidate => candidate.family === 4) || addresses[0];
|
|
259
199
|
if (!selected?.address) return env;
|
|
260
|
-
|
|
261
200
|
const host = selected.family === 6 ? `[${selected.address}]` : selected.address;
|
|
262
201
|
return {
|
|
263
202
|
...env,
|
|
@@ -268,7 +207,6 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
|
|
|
268
207
|
return env;
|
|
269
208
|
}
|
|
270
209
|
}
|
|
271
|
-
|
|
272
210
|
/**
|
|
273
211
|
* Build the `$` (start-command) arguments that launch a Docker-isolated task
|
|
274
212
|
* using start-command's NATIVE Docker backend (`$ --isolated docker`).
|
|
@@ -289,39 +227,25 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
|
|
|
289
227
|
export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
|
|
290
228
|
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
291
229
|
const image = getDockerIsolationImage({ env });
|
|
292
|
-
|
|
293
230
|
const startArgs = ['--isolated', 'docker', '--image', image];
|
|
294
|
-
|
|
295
231
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
296
232
|
startArgs.push('--privileged');
|
|
297
233
|
}
|
|
298
|
-
|
|
299
|
-
// Force the inner shell so start-command does not probe the image to detect
|
|
300
|
-
// 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).
|
|
301
235
|
startArgs.push('--shell', DOCKER_ISOLATION_SHELL);
|
|
302
|
-
|
|
303
|
-
// The image already sets HOME=/home/box and WORKDIR /home/box; pass HOME
|
|
304
|
-
// explicitly anyway so the credential mounts under /home/box resolve even if
|
|
305
|
-
// a future image forgets to. start-command has no --workdir flag, so the
|
|
306
|
-
// 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.
|
|
307
237
|
startArgs.push('-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`, '-e', `HIVE_MIND_IMAGE_VARIANT=${resolveImageVariant(image, env)}`);
|
|
308
|
-
|
|
309
|
-
// A persistent Formal AI server normally runs beside the Telegram/root
|
|
310
|
-
// container. Docker-isolated `/solve` jobs must receive the same endpoint;
|
|
311
|
-
// 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.
|
|
312
239
|
if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
|
|
313
240
|
startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
|
|
314
241
|
}
|
|
315
|
-
|
|
316
242
|
for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
|
|
317
243
|
startArgs.push('--volume', `${mount.source}:${mount.target}`);
|
|
318
244
|
}
|
|
319
|
-
|
|
320
245
|
const taskCommand = buildShellCommand(command, args);
|
|
321
246
|
startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
|
|
322
247
|
return startArgs;
|
|
323
248
|
}
|
|
324
|
-
|
|
325
249
|
export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
326
250
|
const { backend, sessionId } = options;
|
|
327
251
|
if (backend === 'docker') {
|
|
@@ -329,17 +253,14 @@ export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
|
329
253
|
}
|
|
330
254
|
return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
|
|
331
255
|
}
|
|
332
|
-
|
|
333
256
|
async function runStartCommand(binPath, startCommandArgs) {
|
|
334
257
|
return await new Promise(resolve => {
|
|
335
258
|
const child = spawn(binPath, startCommandArgs, {
|
|
336
259
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
337
260
|
env: process.env,
|
|
338
261
|
});
|
|
339
|
-
|
|
340
262
|
let stdout = '';
|
|
341
263
|
let stderr = '';
|
|
342
|
-
|
|
343
264
|
child.stdout.on('data', data => {
|
|
344
265
|
stdout += data.toString();
|
|
345
266
|
});
|
|
@@ -353,8 +274,7 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
353
274
|
error: error.message,
|
|
354
275
|
});
|
|
355
276
|
});
|
|
356
|
-
// Issue #2135: keep `signal` - the captured session's child was killed by
|
|
357
|
-
// 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.
|
|
358
278
|
child.on('close', (code, signal) => {
|
|
359
279
|
const output = (stdout + (stderr ? `\n${stderr}` : '')).trim();
|
|
360
280
|
if (code === 0) {
|
|
@@ -369,7 +289,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
369
289
|
});
|
|
370
290
|
});
|
|
371
291
|
}
|
|
372
|
-
|
|
373
292
|
/**
|
|
374
293
|
* Generate a UUID v4 for unique session identification
|
|
375
294
|
* @returns {string} UUID v4 string
|
|
@@ -377,7 +296,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
377
296
|
export function generateSessionId() {
|
|
378
297
|
return crypto.randomUUID();
|
|
379
298
|
}
|
|
380
|
-
|
|
381
299
|
/**
|
|
382
300
|
* Parse output from `$ --status <session>`.
|
|
383
301
|
*
|
|
@@ -393,7 +311,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
393
311
|
if (!raw) {
|
|
394
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: '' };
|
|
395
313
|
}
|
|
396
|
-
|
|
397
314
|
const normalizeBooleanField = value => {
|
|
398
315
|
if (typeof value === 'boolean') return value;
|
|
399
316
|
if (value === null || value === undefined) return null;
|
|
@@ -402,15 +319,10 @@ export function parseSessionStatusOutput(output) {
|
|
|
402
319
|
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
403
320
|
return null;
|
|
404
321
|
};
|
|
405
|
-
|
|
406
322
|
try {
|
|
407
323
|
const parsed = JSON.parse(raw);
|
|
408
324
|
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
409
|
-
// start-command (link-foundation/start) reports the isolation backend at
|
|
410
|
-
// `options.isolated` in both JSON and links-notation output. Older
|
|
411
|
-
// hypothetical layouts used `options.isolation` or a top-level `isolation`
|
|
412
|
-
// field — keep accepting all three so we are tolerant of future renames.
|
|
413
|
-
// 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.
|
|
414
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;
|
|
415
327
|
const topPid = Number(data?.pid);
|
|
416
328
|
const processIds = normalizeProcessIds(data?.processIds);
|
|
@@ -435,7 +347,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
435
347
|
} catch {
|
|
436
348
|
// Fall through to text parsing.
|
|
437
349
|
}
|
|
438
|
-
|
|
439
350
|
const firstLine =
|
|
440
351
|
raw
|
|
441
352
|
.split('\n')
|
|
@@ -446,13 +357,9 @@ export function parseSessionStatusOutput(output) {
|
|
|
446
357
|
return match ? match[1].trim() : null;
|
|
447
358
|
};
|
|
448
359
|
const readBooleanField = name => normalizeBooleanField(readField(name));
|
|
449
|
-
|
|
450
360
|
const status = readField('status')?.toLowerCase() || null;
|
|
451
361
|
const exitCodeText = readField('exitCode');
|
|
452
|
-
// `start-command` links-notation output nests the isolation backend under
|
|
453
|
-
// `options` as `isolated <backend>` (not `isolation`). The leading indent
|
|
454
|
-
// varies by depth, but `readField` is anchored with `^\s*` which already
|
|
455
|
-
// 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
|
|
456
363
|
// returned null for every real session and made /log + /terminal_watch
|
|
457
364
|
// reject screen/tmux/docker sessions. See issue #1700.
|
|
458
365
|
const isolationText = readField('isolated') || readField('isolation');
|
|
@@ -462,7 +369,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
462
369
|
const number = Number(value);
|
|
463
370
|
if (Number.isInteger(number) && number > 0) processIds[name] = number;
|
|
464
371
|
}
|
|
465
|
-
|
|
466
372
|
return {
|
|
467
373
|
exists: Boolean(status || firstLine),
|
|
468
374
|
uuid: readField('uuid') || firstLine,
|
|
@@ -481,7 +387,6 @@ export function parseSessionStatusOutput(output) {
|
|
|
481
387
|
raw,
|
|
482
388
|
};
|
|
483
389
|
}
|
|
484
|
-
|
|
485
390
|
/**
|
|
486
391
|
* Decide whether a detached-docker exit code is "unknown" (not a real result).
|
|
487
392
|
*
|
|
@@ -499,11 +404,9 @@ export function parseSessionStatusOutput(output) {
|
|
|
499
404
|
export function isUnknownDockerExitCode(exitCode) {
|
|
500
405
|
return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
|
|
501
406
|
}
|
|
502
|
-
|
|
503
407
|
export function shouldFallbackToScreenStatus(statusResult) {
|
|
504
408
|
return !statusResult?.exists || !statusResult?.status;
|
|
505
409
|
}
|
|
506
|
-
|
|
507
410
|
/**
|
|
508
411
|
* Parse the footer start-command appends to every execution log when the wrapped
|
|
509
412
|
* command exits. The footer is authoritative about the terminal exit code even
|
|
@@ -537,7 +440,6 @@ export function parseSessionExitFooter(text) {
|
|
|
537
440
|
if (!last) return { finished: false, exitCode: null, endTime: null };
|
|
538
441
|
return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
|
|
539
442
|
}
|
|
540
|
-
|
|
541
443
|
/**
|
|
542
444
|
* Read the terminal exit code from the tail of a start-command execution log.
|
|
543
445
|
*
|
|
@@ -579,7 +481,6 @@ export function readSessionExitFromLog(logPath, options = {}) {
|
|
|
579
481
|
return { finished: false, exitCode: null, endTime: null };
|
|
580
482
|
}
|
|
581
483
|
}
|
|
582
|
-
|
|
583
484
|
/**
|
|
584
485
|
* Find the `$` CLI binary path
|
|
585
486
|
* @returns {Promise<string|null>} Path to `$` binary or null
|
|
@@ -594,7 +495,6 @@ async function findStartCommandBinary() {
|
|
|
594
495
|
return null;
|
|
595
496
|
}
|
|
596
497
|
}
|
|
597
|
-
|
|
598
498
|
/**
|
|
599
499
|
* Verbose post-launch diagnostics for a native docker-isolated session.
|
|
600
500
|
*
|
|
@@ -629,7 +529,6 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
629
529
|
// Diagnostics are best-effort; never let a probe failure affect the task.
|
|
630
530
|
}
|
|
631
531
|
}
|
|
632
|
-
|
|
633
532
|
/**
|
|
634
533
|
* Execute a command with isolation via `$` from start-command
|
|
635
534
|
*
|
|
@@ -645,7 +544,6 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
645
544
|
export async function executeWithIsolation(command, args, options = {}) {
|
|
646
545
|
const { backend, verbose = false } = options;
|
|
647
546
|
const sessionId = options.sessionId || generateSessionId();
|
|
648
|
-
|
|
649
547
|
if (!VALID_ISOLATION_BACKENDS.includes(backend)) {
|
|
650
548
|
return {
|
|
651
549
|
success: false,
|
|
@@ -654,7 +552,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
654
552
|
error: `Invalid isolation backend: '${backend}'. Must be one of: ${VALID_ISOLATION_BACKENDS.join(', ')}`,
|
|
655
553
|
};
|
|
656
554
|
}
|
|
657
|
-
|
|
658
555
|
const binPath = await findStartCommandBinary();
|
|
659
556
|
if (!binPath) {
|
|
660
557
|
return {
|
|
@@ -665,12 +562,10 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
665
562
|
error: 'start-command ($) not found',
|
|
666
563
|
};
|
|
667
564
|
}
|
|
668
|
-
|
|
669
565
|
if (verbose) {
|
|
670
566
|
console.log(`[VERBOSE] isolation-runner: Using $ binary at: ${binPath}`);
|
|
671
567
|
console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
|
|
672
568
|
}
|
|
673
|
-
|
|
674
569
|
// Issue #2146 / PR #2147 review: a Formal AI task gets its own sidecar,
|
|
675
570
|
// started on demand and reachable only over an internal Docker network. The
|
|
676
571
|
// lease is taken before the container is launched so the endpoint is known
|
|
@@ -679,7 +574,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
679
574
|
const hostEnv = options.env || process.env;
|
|
680
575
|
const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
|
|
681
576
|
if (sidecarError) return { success: false, sessionId, output: '', error: sidecarError };
|
|
682
|
-
|
|
683
577
|
const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
|
|
684
578
|
const effectiveOptions =
|
|
685
579
|
backend === 'docker'
|
|
@@ -689,7 +583,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
689
583
|
}
|
|
690
584
|
: options;
|
|
691
585
|
const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
|
|
692
|
-
|
|
693
586
|
if (verbose) {
|
|
694
587
|
console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
|
|
695
588
|
if (backend === 'docker') {
|
|
@@ -705,15 +598,12 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
705
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)'}`);
|
|
706
599
|
}
|
|
707
600
|
}
|
|
708
|
-
|
|
709
601
|
const result = await runStartCommand(binPath, startCommandArgs);
|
|
710
|
-
|
|
711
602
|
if (verbose) {
|
|
712
603
|
const stream = result.success ? console.log : console.error;
|
|
713
604
|
stream(`[VERBOSE] isolation-runner: Output: ${result.output.substring(0, 500)}`);
|
|
714
605
|
if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
|
|
715
606
|
}
|
|
716
|
-
|
|
717
607
|
let containerFilesystemStartBytes = null;
|
|
718
608
|
let formalAiAttachError = null;
|
|
719
609
|
if (result.success && backend === 'docker') {
|
|
@@ -732,7 +622,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
732
622
|
await releaseDockerContainerStartGate(sessionId, verbose);
|
|
733
623
|
}
|
|
734
624
|
}
|
|
735
|
-
|
|
736
625
|
if (sidecar && (!result.success || formalAiAttachError)) {
|
|
737
626
|
// Fail closed: without the internal network the task cannot reach Formal
|
|
738
627
|
// AI, and issue #2146 forbids falling back to another model.
|
|
@@ -742,7 +631,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
742
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}` };
|
|
743
632
|
}
|
|
744
633
|
}
|
|
745
|
-
|
|
746
634
|
// Issue #1939: capture the freshly-launched docker session's reported status
|
|
747
635
|
// and the live container state together, so the next iteration has the data to
|
|
748
636
|
// diagnose a premature "executed/-1" status (problem #1) or a surprise image
|
|
@@ -750,7 +638,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
750
638
|
if (verbose && backend === 'docker' && result.success) {
|
|
751
639
|
await logDockerIsolationPostLaunchDiagnostics(sessionId, options.env || process.env);
|
|
752
640
|
}
|
|
753
|
-
|
|
754
641
|
if (result.success) {
|
|
755
642
|
return {
|
|
756
643
|
success: true,
|
|
@@ -759,7 +646,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
759
646
|
containerFilesystemStartBytes,
|
|
760
647
|
};
|
|
761
648
|
}
|
|
762
|
-
|
|
763
649
|
return {
|
|
764
650
|
success: false,
|
|
765
651
|
sessionId,
|
|
@@ -767,7 +653,6 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
767
653
|
error: result.error,
|
|
768
654
|
};
|
|
769
655
|
}
|
|
770
|
-
|
|
771
656
|
/**
|
|
772
657
|
* Query the status of an isolated session via `$ --status <uuid>`
|
|
773
658
|
*
|
|
@@ -783,17 +668,13 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
783
668
|
}
|
|
784
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: '' };
|
|
785
670
|
}
|
|
786
|
-
|
|
787
671
|
try {
|
|
788
672
|
const $ = await getCommandStreamDollar();
|
|
789
673
|
const result = await $({ mirror: false })`${binPath} --status ${sessionId} --output-format json`;
|
|
790
|
-
|
|
791
674
|
const stdout = result.stdout?.toString().trim() || '';
|
|
792
|
-
|
|
793
675
|
if (verbose) {
|
|
794
676
|
console.log(`[VERBOSE] isolation-runner: Status query result: ${stdout.substring(0, 300)}`);
|
|
795
677
|
}
|
|
796
|
-
|
|
797
678
|
return parseSessionStatusOutput(stdout);
|
|
798
679
|
} catch (error) {
|
|
799
680
|
if (verbose) {
|
|
@@ -802,7 +683,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
802
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: '' };
|
|
803
684
|
}
|
|
804
685
|
}
|
|
805
|
-
|
|
806
686
|
/**
|
|
807
687
|
* Parse output from `$ --list --output-format json`.
|
|
808
688
|
*
|
|
@@ -824,7 +704,6 @@ export function parseSessionListOutput(output) {
|
|
|
824
704
|
return [];
|
|
825
705
|
}
|
|
826
706
|
const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
827
|
-
|
|
828
707
|
return records
|
|
829
708
|
.map(data => {
|
|
830
709
|
if (!data || typeof data !== 'object') return null;
|
|
@@ -843,7 +722,6 @@ export function parseSessionListOutput(output) {
|
|
|
843
722
|
})
|
|
844
723
|
.filter(Boolean);
|
|
845
724
|
}
|
|
846
|
-
|
|
847
725
|
/**
|
|
848
726
|
* List all executions known to start-command via `$ --list --output-format json`.
|
|
849
727
|
*
|
|
@@ -875,7 +753,6 @@ export async function listIsolationSessions(verbose = false) {
|
|
|
875
753
|
return [];
|
|
876
754
|
}
|
|
877
755
|
}
|
|
878
|
-
|
|
879
756
|
/**
|
|
880
757
|
* Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
|
|
881
758
|
*
|
|
@@ -899,7 +776,6 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
899
776
|
error: '`$` (start-command) binary not found on PATH. Install link-foundation/start to use /stop <UUID>.',
|
|
900
777
|
};
|
|
901
778
|
}
|
|
902
|
-
|
|
903
779
|
try {
|
|
904
780
|
const $ = await getCommandStreamDollar();
|
|
905
781
|
const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
|
|
@@ -925,7 +801,6 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
|
925
801
|
};
|
|
926
802
|
}
|
|
927
803
|
}
|
|
928
|
-
|
|
929
804
|
/**
|
|
930
805
|
* Check if a screen session exists via `screen -ls`.
|
|
931
806
|
* Used as a fallback when `$ --status` fails to find or correctly track
|
|
@@ -951,7 +826,6 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
|
951
826
|
return false;
|
|
952
827
|
}
|
|
953
828
|
}
|
|
954
|
-
|
|
955
829
|
/**
|
|
956
830
|
* Check whether the Docker container backing a native `$ --isolated docker`
|
|
957
831
|
* session is still running.
|
|
@@ -981,14 +855,12 @@ export async function checkDockerContainerRunning(containerName, verbose = false
|
|
|
981
855
|
return false;
|
|
982
856
|
}
|
|
983
857
|
}
|
|
984
|
-
|
|
985
858
|
export function parseDockerContainerWritableLayerSizeOutput(output) {
|
|
986
859
|
const text = String(output || '').trim();
|
|
987
860
|
if (!text) return null;
|
|
988
861
|
const bytes = Number.parseInt(text.split(/\s+/)[0], 10);
|
|
989
862
|
return Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
|
|
990
863
|
}
|
|
991
|
-
|
|
992
864
|
/**
|
|
993
865
|
* Best-effort size of a Docker task container's writable layer.
|
|
994
866
|
*
|
|
@@ -1019,7 +891,6 @@ export async function getDockerContainerWritableLayerSize(containerName, verbose
|
|
|
1019
891
|
return null;
|
|
1020
892
|
}
|
|
1021
893
|
}
|
|
1022
|
-
|
|
1023
894
|
/**
|
|
1024
895
|
* Release the Docker-only start gate after the writable-layer baseline has been
|
|
1025
896
|
* captured. Best-effort: the gated task also has a timeout fallback.
|
|
@@ -1033,7 +904,6 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
|
|
|
1033
904
|
if (!containerName || !gatePath) return false;
|
|
1034
905
|
const releaseCommand = `touch ${shellQuote(gatePath)}`;
|
|
1035
906
|
let lastError = null;
|
|
1036
|
-
|
|
1037
907
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
1038
908
|
try {
|
|
1039
909
|
const $ = await getCommandStreamDollar();
|
|
@@ -1047,14 +917,12 @@ export async function releaseDockerContainerStartGate(containerName, verbose = f
|
|
|
1047
917
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1048
918
|
}
|
|
1049
919
|
}
|
|
1050
|
-
|
|
1051
920
|
if (verbose) {
|
|
1052
921
|
const stderr = lastError?.stderr?.toString?.().trim();
|
|
1053
922
|
console.log(`[VERBOSE] isolation-runner: could not release docker start gate for '${containerName}': ${stderr || lastError?.message || lastError}`);
|
|
1054
923
|
}
|
|
1055
924
|
return false;
|
|
1056
925
|
}
|
|
1057
|
-
|
|
1058
926
|
/**
|
|
1059
927
|
* Best-effort removal for a Docker container backing a native
|
|
1060
928
|
* `$ --isolated docker` session.
|
|
@@ -1073,7 +941,6 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
1073
941
|
if (!containerName) {
|
|
1074
942
|
return { success: false, output: '', error: 'missing container name' };
|
|
1075
943
|
}
|
|
1076
|
-
|
|
1077
944
|
try {
|
|
1078
945
|
const $ = await getCommandStreamDollar();
|
|
1079
946
|
const result = await $({ mirror: false })`docker rm -f ${containerName}`;
|
|
@@ -1096,7 +963,6 @@ export async function removeDockerContainer(containerName, verbose = false) {
|
|
|
1096
963
|
};
|
|
1097
964
|
}
|
|
1098
965
|
}
|
|
1099
|
-
|
|
1100
966
|
/**
|
|
1101
967
|
* Check whether a tmux session with the given name still exists.
|
|
1102
968
|
* `tmux has-session -t <name>` exits 0 when it exists and non-zero otherwise,
|
|
@@ -1117,7 +983,6 @@ export async function checkTmuxSessionRunning(sessionName, verbose = false) {
|
|
|
1117
983
|
return false;
|
|
1118
984
|
}
|
|
1119
985
|
}
|
|
1120
|
-
|
|
1121
986
|
/**
|
|
1122
987
|
* Directly probe whether the backend session/container is still alive, bypassing
|
|
1123
988
|
* `$ --status`. This is the cross-check used to detect a session that
|
|
@@ -1136,7 +1001,6 @@ export async function checkBackendSessionAlive(sessionId, backend, verbose = fal
|
|
|
1136
1001
|
if (backend === 'docker') return checkDockerContainerRunning(sessionId, verbose);
|
|
1137
1002
|
return null;
|
|
1138
1003
|
}
|
|
1139
|
-
|
|
1140
1004
|
/**
|
|
1141
1005
|
* Check whether an image is present in the local Docker daemon.
|
|
1142
1006
|
*
|
|
@@ -1161,7 +1025,6 @@ export async function checkDockerImagePresent(image, verbose = false) {
|
|
|
1161
1025
|
return false;
|
|
1162
1026
|
}
|
|
1163
1027
|
}
|
|
1164
|
-
|
|
1165
1028
|
/**
|
|
1166
1029
|
* Report the storage driver the (nested) Docker daemon is using.
|
|
1167
1030
|
*
|
|
@@ -1190,7 +1053,6 @@ export async function checkDockerStorageDriver(verbose = false) {
|
|
|
1190
1053
|
return null;
|
|
1191
1054
|
}
|
|
1192
1055
|
}
|
|
1193
|
-
|
|
1194
1056
|
/**
|
|
1195
1057
|
* Report the free space (in GiB) on the Docker daemon's data root.
|
|
1196
1058
|
*
|
|
@@ -1218,7 +1080,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1218
1080
|
// Daemon unreachable: fall back to the conventional data root. If df then
|
|
1219
1081
|
// fails on it (e.g. the path does not exist) we return null below.
|
|
1220
1082
|
}
|
|
1221
|
-
|
|
1222
1083
|
const $ = await getCommandStreamDollar();
|
|
1223
1084
|
const df = await $({ mirror: false })`df -Pk ${dataRoot}`;
|
|
1224
1085
|
// `df -P` guarantees one logical line per filesystem (no wrapping). The last
|
|
@@ -1238,7 +1099,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1238
1099
|
return null;
|
|
1239
1100
|
}
|
|
1240
1101
|
}
|
|
1241
|
-
|
|
1242
1102
|
/**
|
|
1243
1103
|
* Startup preflight for `--isolation docker`.
|
|
1244
1104
|
*
|
|
@@ -1277,7 +1137,6 @@ export async function checkDockerDiskSpace(verbose = false) {
|
|
|
1277
1137
|
*/
|
|
1278
1138
|
export async function preflightDockerIsolation(options = {}) {
|
|
1279
1139
|
const { env = process.env, existsSync = fs.existsSync, verbose = false, logger = console, checkImagePresent = checkDockerImagePresent, checkStorageDriver = checkDockerStorageDriver, checkDiskSpace = checkDockerDiskSpace } = options;
|
|
1280
|
-
|
|
1281
1140
|
const image = getDockerIsolationImage({ env });
|
|
1282
1141
|
const sock = resolveHostDockerSock({ env });
|
|
1283
1142
|
const isDind = shouldRunPrivilegedDockerIsolation(image, env);
|
|
@@ -1289,13 +1148,10 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1289
1148
|
// Unknown driver (probe returned null) is treated as ok — we only flag the
|
|
1290
1149
|
// one driver known to overflow the disk, never block on missing information.
|
|
1291
1150
|
const storageDriverOk = storageDriver !== 'vfs';
|
|
1292
|
-
|
|
1293
1151
|
const result = { image, sock, socketMounted, imagePresent, isDind, storageDriver, storageDriverOk, diskAvailableGiB, ok: imagePresent, warnings: [] };
|
|
1294
1152
|
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
1295
1153
|
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
1296
|
-
|
|
1297
1154
|
const preload = `node scripts/preload-dind-isolation-image.mjs --image ${image}`;
|
|
1298
|
-
|
|
1299
1155
|
// Root Cause A of the issue #1914 reopen: a non-copy-on-write storage driver.
|
|
1300
1156
|
// `vfs` stores a full copy of every image layer, so the multi-GB images
|
|
1301
1157
|
// consume many times their size on disk and any layer write (pull, run,
|
|
@@ -1306,7 +1162,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1306
1162
|
if (storageDriver === 'vfs') {
|
|
1307
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.`);
|
|
1308
1164
|
}
|
|
1309
|
-
|
|
1310
1165
|
if (!imagePresent) {
|
|
1311
1166
|
// Image absent: the first isolated task will pull the full image. Explain
|
|
1312
1167
|
// the most likely cause and the exact fix instead of letting the operator
|
|
@@ -1318,7 +1173,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1318
1173
|
} else {
|
|
1319
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}`);
|
|
1320
1175
|
}
|
|
1321
|
-
|
|
1322
1176
|
// Root Cause B of the issue #1914 reopen: too little disk for the pull. The
|
|
1323
1177
|
// image is well over 30 GB extracted; predict the `no space left on device`
|
|
1324
1178
|
// failure here rather than hitting it mid-pull.
|
|
@@ -1327,14 +1181,12 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1327
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.`);
|
|
1328
1182
|
}
|
|
1329
1183
|
}
|
|
1330
|
-
|
|
1331
1184
|
if (imagePresent) {
|
|
1332
1185
|
info(`✅ Docker isolation image '${image}' is already present locally — isolated tasks reuse it (no multi-GB pull). See issue #1914.`);
|
|
1333
1186
|
}
|
|
1334
1187
|
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
1335
1188
|
return result;
|
|
1336
1189
|
}
|
|
1337
|
-
|
|
1338
1190
|
/**
|
|
1339
1191
|
* Host paths that, when present, propagate a git identity into a docker-isolated
|
|
1340
1192
|
* container via getDockerIsolationAuthMounts. Honors the same env vars git reads
|
|
@@ -1344,7 +1196,6 @@ export async function preflightDockerIsolation(options = {}) {
|
|
|
1344
1196
|
export function resolveHostGitIdentityPaths({ env = process.env, homeDir = os.homedir() } = {}) {
|
|
1345
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')];
|
|
1346
1198
|
}
|
|
1347
|
-
|
|
1348
1199
|
/**
|
|
1349
1200
|
* True when the host exposes a git identity that getDockerIsolationAuthMounts can
|
|
1350
1201
|
* mount into an isolated container. See issue #1939.
|
|
@@ -1352,7 +1203,6 @@ export function resolveHostGitIdentityPaths({ env = process.env, homeDir = os.ho
|
|
|
1352
1203
|
export function hostHasMountableGitIdentity({ env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
|
|
1353
1204
|
return resolveHostGitIdentityPaths({ env, homeDir }).some(p => Boolean(existsSync(p)));
|
|
1354
1205
|
}
|
|
1355
|
-
|
|
1356
1206
|
/**
|
|
1357
1207
|
* Startup git-identity preflight for `--isolation docker`.
|
|
1358
1208
|
*
|
|
@@ -1384,13 +1234,11 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
|
|
|
1384
1234
|
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
1385
1235
|
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
1386
1236
|
const result = { present: false, repaired: false, warnings: [] };
|
|
1387
|
-
|
|
1388
1237
|
if (hostHasMountableGitIdentity({ env, homeDir, existsSync })) {
|
|
1389
1238
|
result.present = true;
|
|
1390
1239
|
info('✅ Host git identity present — docker-isolated tasks inherit it via the mounted ~/.gitconfig (issue #1939).');
|
|
1391
1240
|
return result;
|
|
1392
1241
|
}
|
|
1393
|
-
|
|
1394
1242
|
// No mountable identity. Try to derive one from the authenticated gh account
|
|
1395
1243
|
// so the next isolated task does not fail with "Git identity not configured".
|
|
1396
1244
|
const repairFn =
|
|
@@ -1405,19 +1253,16 @@ export async function ensureHostGitIdentityForIsolation(options = {}) {
|
|
|
1405
1253
|
} catch (error) {
|
|
1406
1254
|
repairOutcome = { success: false, error: error?.message || String(error) };
|
|
1407
1255
|
}
|
|
1408
|
-
|
|
1409
1256
|
if (repairOutcome?.success && hostHasMountableGitIdentity({ env, homeDir, existsSync })) {
|
|
1410
1257
|
result.present = true;
|
|
1411
1258
|
result.repaired = true;
|
|
1412
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).');
|
|
1413
1260
|
return result;
|
|
1414
1261
|
}
|
|
1415
|
-
|
|
1416
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}` : ''));
|
|
1417
1263
|
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
1418
1264
|
return result;
|
|
1419
1265
|
}
|
|
1420
|
-
|
|
1421
1266
|
/**
|
|
1422
1267
|
* Check if an isolated session is still running.
|
|
1423
1268
|
* Uses `$ --status` first, with a backend-specific fallback (screen -ls for
|
|
@@ -1434,7 +1279,6 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1434
1279
|
// Support legacy call signature: isSessionRunning(sessionId, verbose)
|
|
1435
1280
|
const opts = typeof options === 'boolean' ? { verbose: options } : options;
|
|
1436
1281
|
const { backend, verbose = false } = opts;
|
|
1437
|
-
|
|
1438
1282
|
const result = await querySessionStatus(sessionId, verbose);
|
|
1439
1283
|
if (result.exists && result.status) {
|
|
1440
1284
|
if (isExecutingSessionStatus(result.status)) {
|
|
@@ -1459,7 +1303,6 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1459
1303
|
return false;
|
|
1460
1304
|
}
|
|
1461
1305
|
}
|
|
1462
|
-
|
|
1463
1306
|
// Fallback used only when `$ --status` has no usable record. This works
|
|
1464
1307
|
// around older start-command bugs where `$ --status` can't resolve a session
|
|
1465
1308
|
// by its --session name (only by an internal UUID). See issue #1545.
|
|
@@ -1484,10 +1327,8 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1484
1327
|
return containerRunning;
|
|
1485
1328
|
}
|
|
1486
1329
|
}
|
|
1487
|
-
|
|
1488
1330
|
return false;
|
|
1489
1331
|
}
|
|
1490
|
-
|
|
1491
1332
|
/**
|
|
1492
1333
|
* Validate that an isolation backend value is valid
|
|
1493
1334
|
* @param {string} backend - Backend value to validate
|
|
@@ -1496,5 +1337,4 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
1496
1337
|
export function isValidIsolationBackend(backend) {
|
|
1497
1338
|
return VALID_ISOLATION_BACKENDS.includes(backend);
|
|
1498
1339
|
}
|
|
1499
|
-
|
|
1500
1340
|
export { VALID_ISOLATION_BACKENDS };
|