@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +4 -1
  3. package/src/agent-command.lib.mjs +74 -0
  4. package/src/agent.lib.mjs +59 -34
  5. package/src/agentic-cli-updater.lib.mjs +241 -0
  6. package/src/claude.connection.lib.mjs +209 -0
  7. package/src/claude.lib.mjs +6 -202
  8. package/src/codex.lib.mjs +0 -128
  9. package/src/formal-ai-isolation.lib.mjs +62 -0
  10. package/src/formal-ai-maintenance.lib.mjs +106 -0
  11. package/src/formal-ai-model.lib.mjs +25 -0
  12. package/src/formal-ai-runtime.lib.mjs +10 -0
  13. package/src/formal-ai-sidecar.lib.mjs +565 -0
  14. package/src/formal-ai-updater.lib.mjs +294 -0
  15. package/src/formal-ai-version.lib.mjs +100 -0
  16. package/src/formal-ai.lib.mjs +11 -16
  17. package/src/github-rate-limit.lib.mjs +3 -0
  18. package/src/github-url-parser.lib.mjs +255 -0
  19. package/src/github.lib.mjs +22 -343
  20. package/src/hive.mjs +0 -152
  21. package/src/interactive-mode.lib.mjs +0 -43
  22. package/src/isolation-runner.lib.mjs +44 -173
  23. package/src/limits.lib.mjs +0 -89
  24. package/src/model-args.lib.mjs +32 -0
  25. package/src/models/index.mjs +5 -19
  26. package/src/session-monitor.lib.mjs +14 -172
  27. package/src/solve.auto-merge.lib.mjs +70 -164
  28. package/src/solve.mjs +31 -193
  29. package/src/solve.repository.lib.mjs +0 -83
  30. package/src/solve.results.lib.mjs +2 -92
  31. package/src/solve.session.lib.mjs +52 -19
  32. package/src/solve.tool-uncommitted.lib.mjs +22 -0
  33. package/src/state-lock.lib.mjs +82 -0
  34. package/src/telegram-bot.mjs +17 -65
  35. package/src/telegram-fix-command.lib.mjs +1 -8
  36. package/src/telegram-merge-queue.lib.mjs +3 -155
  37. package/src/telegram-solve-queue.lib.mjs +9 -168
  38. package/src/telegram-task-command.lib.mjs +1 -8
  39. package/src/use-m-bootstrap.lib.mjs +6 -5
  40. package/src/use-with-retry.lib.mjs +128 -2
  41. 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(options.env || process.env),
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 };