@link-assistant/hive-mind 2.12.2 → 2.12.4
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 +12 -0
- package/package.json +1 -1
- package/src/credential-sanitization-core.lib.mjs +104 -9
- package/src/encoded-credential-detection.lib.mjs +1009 -0
- package/src/formal-ai-image.lib.mjs +222 -0
- package/src/formal-ai-isolation.lib.mjs +8 -1
- package/src/formal-ai-sidecar.lib.mjs +36 -13
- package/src/formal-ai-updater.lib.mjs +14 -2
- package/src/hive-mind-image.lib.mjs +56 -0
- package/src/isolation-runner.lib.mjs +71 -54
- package/src/locales/en.lino +3 -0
- package/src/locales/hi.lino +3 -0
- package/src/locales/ru.lino +3 -0
- package/src/locales/zh.lino +3 -0
- package/src/session-kill-recovery.lib.mjs +9 -1
- package/src/session-monitor.lib.mjs +35 -4
- package/src/session-store.lib.mjs +7 -2
- package/src/telegram-command-execution.lib.mjs +26 -7
- package/src/telegram-solve-queue.lib.mjs +30 -5
- package/src/token-sanitization.lib.mjs +182 -5
- package/src/work-session-formatting.lib.mjs +56 -3
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve — and prove available — the image the Formal AI sidecar boots from
|
|
3
|
+
* (issue #2154).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this module exists
|
|
6
|
+
*
|
|
7
|
+
* Before this module, `acquireFormalAiSidecar` handed
|
|
8
|
+
* `ghcr.io/link-assistant/formal-ai:<version>` straight to `docker run` and
|
|
9
|
+
* relied on Docker's implicit pull. The published package is private, so every
|
|
10
|
+
* Formal AI task died with the raw daemon dump:
|
|
11
|
+
*
|
|
12
|
+
* ```text
|
|
13
|
+
* Command failed: docker run --detach --name hive-mind-formal-ai …
|
|
14
|
+
* Unable to find image 'ghcr.io/link-assistant/formal-ai:0.339.1' locally
|
|
15
|
+
* docker: Error response from daemon: error from registry: unauthorized
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Three separate defects made that fatal:
|
|
19
|
+
*
|
|
20
|
+
* 1. **No preflight.** The registry problem surfaced as a failed *container
|
|
21
|
+
* launch* rather than as a failed *image resolution*, so nothing could tell
|
|
22
|
+
* the operator what to do about it.
|
|
23
|
+
* 2. **No diagnosis.** `unauthorized` from a registry has exactly three causes
|
|
24
|
+
* (private package, missing/insufficient credentials, wrong reference) and
|
|
25
|
+
* each has a different fix. The raw dump named none of them.
|
|
26
|
+
* 3. **No alternative.** The Hive Mind images themselves bake
|
|
27
|
+
* `/usr/local/bin/formal-ai` at the same pinned version (`Dockerfile`,
|
|
28
|
+
* `Dockerfile.dind`: `cargo install formal-ai --version ${FORMAL_AI_VERSION}
|
|
29
|
+
* --locked`), and that image is already present on the host because every
|
|
30
|
+
* isolated task runs from it. A registry outage therefore never had to stop
|
|
31
|
+
* Formal AI work at all.
|
|
32
|
+
*
|
|
33
|
+
* ## Contract
|
|
34
|
+
*
|
|
35
|
+
* - `HIVE_MIND_FORMAL_AI_IMAGE` is an **exact** operator pin: it is the only
|
|
36
|
+
* candidate, and if it cannot be resolved the acquire fails. An operator who
|
|
37
|
+
* names an image means that image.
|
|
38
|
+
* - Otherwise the published image is preferred, and the local Hive Mind image is
|
|
39
|
+
* the fallback. The fallback is only *used*; it is never pulled, because its
|
|
40
|
+
* whole point is that it is already on the host.
|
|
41
|
+
* - Nothing here downgrades a Formal AI task to another model. Issue #2146's
|
|
42
|
+
* fail-closed rule still holds: when no candidate resolves, the task is
|
|
43
|
+
* refused with an actionable message.
|
|
44
|
+
*
|
|
45
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2154
|
|
46
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2146
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { execFile } from 'node:child_process';
|
|
50
|
+
import { promisify } from 'node:util';
|
|
51
|
+
|
|
52
|
+
import { FORMAL_AI_BOOTSTRAP_VERSION } from './formal-ai-version.lib.mjs';
|
|
53
|
+
import { getDockerIsolationImage } from './hive-mind-image.lib.mjs';
|
|
54
|
+
|
|
55
|
+
const execFileAsync = promisify(execFile);
|
|
56
|
+
|
|
57
|
+
/** Image published by every Formal AI release (`:latest` plus the bare version). */
|
|
58
|
+
export const FORMAL_AI_IMAGE_REPOSITORY = 'ghcr.io/link-assistant/formal-ai';
|
|
59
|
+
|
|
60
|
+
const DEFAULT_DOCKER_TIMEOUT_MS = 600_000;
|
|
61
|
+
|
|
62
|
+
/** Where each candidate came from, so logs and errors can explain themselves. */
|
|
63
|
+
export const FORMAL_AI_IMAGE_SOURCES = Object.freeze({
|
|
64
|
+
PINNED: 'operator-pin',
|
|
65
|
+
PUBLISHED: 'published-image',
|
|
66
|
+
HIVE_MIND: 'hive-mind-image',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const errorText = error => error?.stderr?.toString?.().trim() || error?.stdout?.toString?.().trim() || error?.message || String(error);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify a `docker pull` failure into the operator action that fixes it.
|
|
73
|
+
*
|
|
74
|
+
* Registry errors are famously interchangeable-looking; the distinction that
|
|
75
|
+
* matters in practice is that GHCR answers `unauthorized` for a package that
|
|
76
|
+
* *exists but is private* and `denied` for one that does not exist or that the
|
|
77
|
+
* presented token may not see.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} message - Raw stderr from the failed pull.
|
|
80
|
+
* @returns {{kind: string, reason: string, remediation: string[]}}
|
|
81
|
+
*/
|
|
82
|
+
export const classifyDockerRegistryError = message => {
|
|
83
|
+
const text = String(message || '');
|
|
84
|
+
if (/unauthorized|authentication required|requires authentication/i.test(text)) {
|
|
85
|
+
return {
|
|
86
|
+
kind: 'unauthorized',
|
|
87
|
+
reason: 'the registry refused an anonymous or under-scoped pull',
|
|
88
|
+
remediation: ['make the package public (GHCR packages published by GITHUB_TOKEN are private by default), or', 'authenticate the daemon with a token carrying the `read:packages` scope: `echo $TOKEN | docker login ghcr.io -u <user> --password-stdin`, or', 'point HIVE_MIND_FORMAL_AI_IMAGE at an image this host can already pull'],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (/denied|forbidden|insufficient_scope|permission_denied/i.test(text)) {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'denied',
|
|
94
|
+
reason: 'the registry denied access to that reference',
|
|
95
|
+
remediation: ['check the repository name and tag exist', 'check the credentials in use carry the `read:packages` scope'],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (/manifest unknown|not found|no such (?:image|manifest)/i.test(text)) {
|
|
99
|
+
return { kind: 'not-found', reason: 'the registry has no such tag', remediation: ['check the tag exists in the registry', 'pin a published tag with HIVE_MIND_FORMAL_AI_IMAGE'] };
|
|
100
|
+
}
|
|
101
|
+
if (/no space left on device/i.test(text)) {
|
|
102
|
+
return { kind: 'disk-full', reason: 'the daemon ran out of disk while unpacking the image', remediation: ['free disk space on the Docker data root, then retry'] };
|
|
103
|
+
}
|
|
104
|
+
if (/timeout|timed out|temporary failure|dial tcp|i\/o timeout|connection refused|network is unreachable|EOF/i.test(text)) {
|
|
105
|
+
return { kind: 'network', reason: 'the registry was unreachable', remediation: ['check network/proxy access to the registry, then retry'] };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'unknown', reason: 'the pull failed', remediation: ['inspect the daemon error above'] };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the local Hive Mind image, which bakes `formal-ai` at the same pinned
|
|
112
|
+
* version as the published sidecar image and is already present on any host
|
|
113
|
+
* that runs Docker-isolated tasks.
|
|
114
|
+
*/
|
|
115
|
+
export const resolveFormalAiFallbackImage = (env = process.env) => String(env.HIVE_MIND_FORMAL_AI_FALLBACK_IMAGE || '').trim() || getDockerIsolationImage({ env });
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Ordered list of images to try, most preferred first.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} [env]
|
|
121
|
+
* @returns {Array<{image: string, source: string, pullable: boolean}>}
|
|
122
|
+
*/
|
|
123
|
+
export const resolveFormalAiSidecarImageCandidates = (env = process.env) => {
|
|
124
|
+
const pinned = String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim();
|
|
125
|
+
if (pinned) return [{ image: pinned, source: FORMAL_AI_IMAGE_SOURCES.PINNED, pullable: true }];
|
|
126
|
+
const candidates = [{ image: `${FORMAL_AI_IMAGE_REPOSITORY}:${FORMAL_AI_BOOTSTRAP_VERSION}`, source: FORMAL_AI_IMAGE_SOURCES.PUBLISHED, pullable: true }];
|
|
127
|
+
const fallback = resolveFormalAiFallbackImage(env);
|
|
128
|
+
// Never pulled: the fallback's value is that it is already on the host. A
|
|
129
|
+
// deployment that has to pull it would be pulling the *bigger* image.
|
|
130
|
+
if (fallback && fallback !== candidates[0].image) candidates.push({ image: fallback, source: FORMAL_AI_IMAGE_SOURCES.HIVE_MIND, pullable: false });
|
|
131
|
+
return candidates;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The preferred image, kept for callers and logs that only need the headline reference. */
|
|
135
|
+
export const resolveFormalAiSidecarImage = (env = process.env) => resolveFormalAiSidecarImageCandidates(env)[0].image;
|
|
136
|
+
|
|
137
|
+
const inspectLocalImage = async (image, { run, timeoutMs }) => {
|
|
138
|
+
try {
|
|
139
|
+
const result = await run('docker', ['image', 'inspect', image, '--format', '{{.Id}}'], { encoding: 'utf8', timeout: timeoutMs });
|
|
140
|
+
return String(result?.stdout ?? '').trim() || null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render the aggregated failure so an operator reading the Telegram reply or the
|
|
148
|
+
* bot log knows the cause and the fix without opening a shell.
|
|
149
|
+
*/
|
|
150
|
+
const describeFailure = attempts => {
|
|
151
|
+
const lines = ['No Formal AI image could be resolved, so the sidecar was not started.'];
|
|
152
|
+
for (const attempt of attempts) {
|
|
153
|
+
if (attempt.source === FORMAL_AI_IMAGE_SOURCES.HIVE_MIND && attempt.kind === 'absent') {
|
|
154
|
+
lines.push(`• ${attempt.image} (local Hive Mind image, fallback): not present on this host — Docker-isolated tasks would have to pull it too.`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
lines.push(`• ${attempt.image} (${attempt.source}): ${attempt.reason}${attempt.error ? ` — ${attempt.error}` : ''}`);
|
|
158
|
+
for (const step of attempt.remediation ?? []) lines.push(` → ${step}`);
|
|
159
|
+
}
|
|
160
|
+
lines.push('Formal AI tasks fail closed by design (issue #2146): Hive Mind will not silently run them on another model.');
|
|
161
|
+
return lines.join('\n');
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Return the first candidate image that is usable on this host, pulling only the
|
|
166
|
+
* pullable ones and never throwing for a candidate that simply is not there.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} params
|
|
169
|
+
* @param {object} [params.env]
|
|
170
|
+
* @param {Function} [params.run] - `execFile`-shaped seam, so tests can drive a fake daemon.
|
|
171
|
+
* @param {number} [params.timeoutMs]
|
|
172
|
+
* @param {Function|null} [params.log]
|
|
173
|
+
* @param {boolean} [params.verbose]
|
|
174
|
+
* @param {boolean} [params.pull] - Set false to accept only images already on the host.
|
|
175
|
+
* @returns {Promise<{image: string, source: string, digest: string|null, pulled: boolean, attempts: object[]}>}
|
|
176
|
+
* @throws {Error} When no candidate resolves; the message names every attempt and its fix.
|
|
177
|
+
*/
|
|
178
|
+
export const ensureFormalAiSidecarImage = async ({ env = process.env, run = execFileAsync, timeoutMs = DEFAULT_DOCKER_TIMEOUT_MS, log = null, verbose = false, pull = true, candidates = resolveFormalAiSidecarImageCandidates(env) } = {}) => {
|
|
179
|
+
const attempts = [];
|
|
180
|
+
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
const localDigest = await inspectLocalImage(candidate.image, { run, timeoutMs });
|
|
183
|
+
if (localDigest) {
|
|
184
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-image: using '${candidate.image}' (${candidate.source}) already present locally, digest=${localDigest}`);
|
|
185
|
+
return { image: candidate.image, source: candidate.source, digest: localDigest, pulled: false, attempts };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!candidate.pullable || !pull) {
|
|
189
|
+
attempts.push({ ...candidate, kind: 'absent', reason: 'not present locally and not pulled', error: null, remediation: [] });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (log) await log(`⬇️ Pulling the Formal AI sidecar image ${candidate.image}`);
|
|
194
|
+
try {
|
|
195
|
+
await run('docker', ['pull', candidate.image], { encoding: 'utf8', timeout: timeoutMs });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const message = errorText(error);
|
|
198
|
+
const diagnosis = classifyDockerRegistryError(message);
|
|
199
|
+
attempts.push({ ...candidate, kind: diagnosis.kind, reason: diagnosis.reason, error: message, remediation: diagnosis.remediation });
|
|
200
|
+
if (log) await log(`⚠️ Could not pull ${candidate.image}: ${diagnosis.reason} (${diagnosis.kind}). ${message}`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const digest = await inspectLocalImage(candidate.image, { run, timeoutMs });
|
|
205
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-image: pulled '${candidate.image}' (${candidate.source}), digest=${digest ?? 'unknown'}`);
|
|
206
|
+
return { image: candidate.image, source: candidate.source, digest, pulled: true, attempts };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const error = new Error(describeFailure(attempts));
|
|
210
|
+
error.formalAiImageAttempts = attempts;
|
|
211
|
+
throw error;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export default {
|
|
215
|
+
FORMAL_AI_IMAGE_REPOSITORY,
|
|
216
|
+
FORMAL_AI_IMAGE_SOURCES,
|
|
217
|
+
classifyDockerRegistryError,
|
|
218
|
+
ensureFormalAiSidecarImage,
|
|
219
|
+
resolveFormalAiFallbackImage,
|
|
220
|
+
resolveFormalAiSidecarImage,
|
|
221
|
+
resolveFormalAiSidecarImageCandidates,
|
|
222
|
+
};
|
|
@@ -32,7 +32,14 @@ export const acquireFormalAiSidecarForTask = async ({ backend, args = [], model
|
|
|
32
32
|
try {
|
|
33
33
|
return { sidecar: await acquire({ sessionId, tool, model, env, verbose, log }), error: null };
|
|
34
34
|
} catch (error) {
|
|
35
|
-
|
|
35
|
+
const message = `Formal AI sidecar could not be started, so the task was not launched (issue #2146): ${error?.message || error}`;
|
|
36
|
+
// Issue #2154: this used to be returned and nothing else. The reply went to
|
|
37
|
+
// Telegram, the session was untracked, and the bot log showed only the
|
|
38
|
+
// untracking — so the operator could see that a task had vanished but never
|
|
39
|
+
// why. Every refusal is now on the record, with the session UUID that names
|
|
40
|
+
// the task in `$ --list` and in the session store.
|
|
41
|
+
console.error(`[formal-ai-isolation] Session ${sessionId}: ${message}`);
|
|
42
|
+
return { sidecar: null, error: message };
|
|
36
43
|
}
|
|
37
44
|
};
|
|
38
45
|
|
|
@@ -46,7 +46,8 @@ import fs from 'node:fs';
|
|
|
46
46
|
import path from 'node:path';
|
|
47
47
|
import { promisify } from 'node:util';
|
|
48
48
|
|
|
49
|
-
import {
|
|
49
|
+
import { FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast } from './formal-ai-version.lib.mjs';
|
|
50
|
+
import { ensureFormalAiSidecarImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
|
|
50
51
|
import { isFormalAiModel } from './formal-ai-model.lib.mjs';
|
|
51
52
|
import { getModelFromArgs } from './model-args.lib.mjs';
|
|
52
53
|
import { resolveBotStateDir } from './session-store.lib.mjs';
|
|
@@ -77,8 +78,14 @@ export const FORMAL_AI_SIDECAR_PORT = 8080;
|
|
|
77
78
|
export const FORMAL_AI_MEMORY_MOUNT = '/home/box/.formal-ai';
|
|
78
79
|
export const FORMAL_AI_MEMORY_PATH = `${FORMAL_AI_MEMORY_MOUNT}/memory.lino`;
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Image published by every Formal AI release (`:latest` plus the bare version).
|
|
83
|
+
*
|
|
84
|
+
* Re-exported from `formal-ai-image.lib.mjs`, which owns image resolution since
|
|
85
|
+
* issue #2154 taught us that "which image" and "is it actually pullable" are the
|
|
86
|
+
* same question.
|
|
87
|
+
*/
|
|
88
|
+
export { FORMAL_AI_IMAGE_REPOSITORY, resolveFormalAiSidecarImage, resolveFormalAiSidecarImageCandidates } from './formal-ai-image.lib.mjs';
|
|
82
89
|
|
|
83
90
|
/** Applied to the sidecar, its network and its volume so reconciliation can find them. */
|
|
84
91
|
export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
|
|
@@ -86,6 +93,9 @@ export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
|
|
|
86
93
|
const STATE_FILE_NAME = 'formal-ai-sidecar.json';
|
|
87
94
|
const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
|
|
88
95
|
const DEFAULT_DOCKER_TIMEOUT_MS = 120_000;
|
|
96
|
+
// Pulling a sidecar image is the one Docker call that legitimately takes many
|
|
97
|
+
// minutes, so it gets its own budget instead of the general command timeout.
|
|
98
|
+
const DEFAULT_IMAGE_TIMEOUT_MS = 600_000;
|
|
89
99
|
const DEFAULT_HEALTH_ATTEMPTS = 60;
|
|
90
100
|
const DEFAULT_HEALTH_DELAY_MS = 1000;
|
|
91
101
|
|
|
@@ -107,9 +117,6 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
107
117
|
*/
|
|
108
118
|
export const isFormalAiTask = ({ args = [], model = null } = {}) => isFormalAiModel(model || getModelFromArgs(args));
|
|
109
119
|
|
|
110
|
-
/** Resolve the image the sidecar boots with. Operators may pin their own build. */
|
|
111
|
-
export const resolveFormalAiSidecarImage = (env = process.env) => String(env.HIVE_MIND_FORMAL_AI_IMAGE || '').trim() || `${FORMAL_AI_IMAGE_REPOSITORY}:${FORMAL_AI_BOOTSTRAP_VERSION}`;
|
|
112
|
-
|
|
113
120
|
/** Build the endpoint origin for a host name or address. */
|
|
114
121
|
export const buildFormalAiSidecarBaseUrl = (host = FORMAL_AI_SIDECAR_NETWORK_ALIAS) => `http://${host}:${FORMAL_AI_SIDECAR_PORT}`;
|
|
115
122
|
|
|
@@ -433,18 +440,14 @@ export const stopFormalAiSidecar = async ({ env = process.env, fsImpl = fs, run
|
|
|
433
440
|
* Must be called *before* the task container's command is allowed to run.
|
|
434
441
|
* Returns the endpoint the task should be pointed at.
|
|
435
442
|
*/
|
|
436
|
-
export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = null, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, log = null, verbose = false, now = () => new Date(), healthAttempts, healthDelayMs, sleepImpl = sleep, lockOptions = {} } = {}) => {
|
|
443
|
+
export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = null, env = process.env, fsImpl = fs, run = execFileAsync, timeoutMs, imageTimeoutMs = DEFAULT_IMAGE_TIMEOUT_MS, log = null, verbose = false, now = () => new Date(), healthAttempts, healthDelayMs, sleepImpl = sleep, lockOptions = {} } = {}) => {
|
|
437
444
|
if (!sessionId) throw new Error('acquireFormalAiSidecar requires a sessionId');
|
|
438
445
|
|
|
439
446
|
return withFormalAiSidecarLock(
|
|
440
447
|
async () => {
|
|
441
|
-
const image = resolveFormalAiSidecarImage(env);
|
|
442
448
|
const state = readFormalAiSidecarState({ env, fsImpl });
|
|
443
449
|
const leases = await reconcileLeases(state.leases, { run, timeoutMs, log, verbose });
|
|
444
450
|
|
|
445
|
-
await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
|
|
446
|
-
await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
|
|
447
|
-
|
|
448
451
|
let container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
449
452
|
if (container.exists && !container.running) {
|
|
450
453
|
// A stopped container may predate an image change; recreate instead of
|
|
@@ -453,8 +456,18 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
|
|
|
453
456
|
container = { exists: false, running: false, image: null, imageDigest: null };
|
|
454
457
|
}
|
|
455
458
|
|
|
459
|
+
// Resolve the image *before* anything shells out with it. Until issue
|
|
460
|
+
// #2154 the reference went straight into `docker run`, so a registry that
|
|
461
|
+
// refused the pull surfaced as an unreadable `Command failed: docker run …`
|
|
462
|
+
// dump and the task died even though a usable image sat on the host.
|
|
463
|
+
const resolved = container.exists && container.image ? { image: container.image, source: 'running-sidecar', pulled: false } : await ensureFormalAiSidecarImage({ env, run, timeoutMs: imageTimeoutMs, log, verbose });
|
|
464
|
+
const image = resolved.image;
|
|
465
|
+
|
|
466
|
+
await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
|
|
467
|
+
await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
|
|
468
|
+
|
|
456
469
|
if (!container.exists) {
|
|
457
|
-
if (log) await log(`🧠 Starting the Formal AI sidecar (${image}) on the internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
470
|
+
if (log) await log(`🧠 Starting the Formal AI sidecar (${image}, ${resolved.source}) on the internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
|
|
458
471
|
await dockerText(run, buildFormalAiSidecarRunArgs({ image, env }), { timeoutMs });
|
|
459
472
|
container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
|
|
460
473
|
}
|
|
@@ -466,12 +479,21 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
|
|
|
466
479
|
throw new Error(`Formal AI sidecar '${FORMAL_AI_SIDECAR_CONTAINER_NAME}' did not become healthy: ${health.error}`);
|
|
467
480
|
}
|
|
468
481
|
|
|
482
|
+
// The sidecar may now boot from any of several images (issue #2154), so
|
|
483
|
+
// the version floor is enforced against what the process actually reports
|
|
484
|
+
// rather than assumed from the tag. A too-old binary would answer /health
|
|
485
|
+
// and then fail on the agent-mode API the tasks depend on.
|
|
486
|
+
const reportedVersion = health.health?.version ?? null;
|
|
487
|
+
if (reportedVersion && !isFormalAiVersionAtLeast(reportedVersion, FORMAL_AI_MINIMUM_VERSION)) {
|
|
488
|
+
throw new Error(`Formal AI sidecar image ${image} (${resolved.source}) runs formal-ai ${reportedVersion}, but Hive Mind requires >= ${FORMAL_AI_MINIMUM_VERSION}. Rebuild or repin the image (HIVE_MIND_FORMAL_AI_IMAGE) before running Formal AI tasks.`);
|
|
489
|
+
}
|
|
490
|
+
|
|
469
491
|
const address = await readFormalAiSidecarAddress({ run, timeoutMs });
|
|
470
492
|
const acquiredAt = now().toISOString();
|
|
471
493
|
const nextLeases = [...leases.filter(lease => lease.sessionId !== sessionId), { sessionId, tool, model, acquiredAt }];
|
|
472
494
|
writeFormalAiSidecarState({ ...state, image: container.image || image, imageDigest: container.imageDigest, startedAt: state.startedAt || acquiredAt, leases: nextLeases }, { env, fsImpl });
|
|
473
495
|
|
|
474
|
-
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' acquired (${nextLeases.length} active), image=${container.image || image}, digest=${container.imageDigest ?? 'unknown'}, address=${address ?? 'unknown'}, formal-ai=${health.health?.version ?? 'unknown'}, memory schema=${health.health?.memory?.schema_version ?? 'unknown'}`);
|
|
496
|
+
if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' acquired (${nextLeases.length} active), image=${container.image || image} (${resolved.source}), digest=${container.imageDigest ?? 'unknown'}, address=${address ?? 'unknown'}, formal-ai=${health.health?.version ?? 'unknown'}, memory schema=${health.health?.memory?.schema_version ?? 'unknown'}`);
|
|
475
497
|
|
|
476
498
|
return {
|
|
477
499
|
address,
|
|
@@ -482,6 +504,7 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
|
|
|
482
504
|
containerName: FORMAL_AI_SIDECAR_CONTAINER_NAME,
|
|
483
505
|
memoryVolume: FORMAL_AI_MEMORY_VOLUME_NAME,
|
|
484
506
|
image: container.image || image,
|
|
507
|
+
imageSource: resolved.source,
|
|
485
508
|
imageDigest: container.imageDigest,
|
|
486
509
|
health: health.health,
|
|
487
510
|
leaseCount: nextLeases.length,
|
|
@@ -33,6 +33,7 @@ import { execFile } from 'node:child_process';
|
|
|
33
33
|
import fs from 'node:fs';
|
|
34
34
|
import { promisify } from 'node:util';
|
|
35
35
|
|
|
36
|
+
import { classifyDockerRegistryError } from './formal-ai-image.lib.mjs';
|
|
36
37
|
import { FORMAL_AI_IMAGE_REPOSITORY, FORMAL_AI_MEMORY_MOUNT, FORMAL_AI_MEMORY_PATH, FORMAL_AI_MEMORY_VOLUME_NAME, FORMAL_AI_SIDECAR_CONTAINER_NAME, buildFormalAiSidecarRunArgs, ensureFormalAiMemoryVolume, ensureFormalAiNetwork, inspectDockerContainer, readDockerImageDigest, readFormalAiSidecarState, reconcileFormalAiSidecar, stopFormalAiSidecar, waitForFormalAiSidecarHealth, withFormalAiSidecarLock, writeFormalAiSidecarState } from './formal-ai-sidecar.lib.mjs';
|
|
37
38
|
|
|
38
39
|
const execFileAsync = promisify(execFile);
|
|
@@ -207,8 +208,19 @@ export const updateFormalAiSidecarWhenIdle = async ({ env = process.env, fsImpl
|
|
|
207
208
|
await dockerText(run, ['pull', '--quiet', image], { timeoutMs: pullTimeoutMs });
|
|
208
209
|
} catch (error) {
|
|
209
210
|
const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
// Issue #2154: this warning fired every ~5 minutes for hours ("Could not
|
|
212
|
+
// pull … unauthorized") without ever saying that the registry was
|
|
213
|
+
// refusing us, and the operator only learned of it when a Formal AI task
|
|
214
|
+
// failed to start. A permanent refusal (unauthorized/denied/not-found)
|
|
215
|
+
// is a configuration fault, not a transient blip: name it, say how to
|
|
216
|
+
// fix it, and report the classification to the caller.
|
|
217
|
+
const classification = classifyDockerRegistryError(message);
|
|
218
|
+
const permanent = ['unauthorized', 'denied', 'not-found'].includes(classification.kind);
|
|
219
|
+
if (log) {
|
|
220
|
+
const remediation = classification.remediation.length ? ` Fix it by one of: ${classification.remediation.join('; ')}.` : '';
|
|
221
|
+
await log(`${permanent ? '🚨' : '⚠️'} Could not pull ${image} — ${classification.reason} (${classification.kind}); keeping the current Formal AI image: ${message}${permanent ? remediation : ''}`);
|
|
222
|
+
}
|
|
223
|
+
return { status: 'failed', stage: 'pull', image, error: message, classification: classification.kind, permanent, remediation: classification.remediation };
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
const pulledDigest = await readDockerImageDigest(image, { run, timeoutMs });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the Hive Mind container image references shared by the Docker
|
|
3
|
+
* isolation runner and the Formal AI sidecar.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `isolation-runner.lib.mjs` for issue #2154: the Formal AI
|
|
6
|
+
* sidecar needs the very same reference to fall back to the locally present
|
|
7
|
+
* Hive Mind image (which bakes `/usr/local/bin/formal-ai`) when the published
|
|
8
|
+
* Formal AI image cannot be pulled, and importing the isolation runner from the
|
|
9
|
+
* sidecar would create an import cycle.
|
|
10
|
+
*
|
|
11
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2154
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
15
|
+
export const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
16
|
+
export const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the tag used for the Docker isolation image.
|
|
20
|
+
*
|
|
21
|
+
* Release Docker images bake this env var from `HIVE_MIND_VERSION`, so a parent
|
|
22
|
+
* container started via `:latest` still launches child isolation containers from
|
|
23
|
+
* the same immutable release tag. Local/PR builds fall back to `latest`, and
|
|
24
|
+
* operators can override the tag explicitly when using custom images. Pinning
|
|
25
|
+
* matters for Docker-in-Docker deployments: the nested daemon starts with an
|
|
26
|
+
* empty image store, so a `:latest` digest drift from the host copy forces a
|
|
27
|
+
* fresh multi-gigabyte pull. See issue #1879.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
|
|
30
|
+
const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
|
|
31
|
+
return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pick the Docker image used for `--isolation docker`.
|
|
36
|
+
*
|
|
37
|
+
* start-command defaults its Docker backend to a base OS image. Hive Mind needs
|
|
38
|
+
* an image with the same CLI/tooling baseline as the parent process instead.
|
|
39
|
+
*
|
|
40
|
+
* `HIVE_MIND_DOCKER_ISOLATION_IMAGE` is a full override (repo:tag). Otherwise
|
|
41
|
+
* the repo is chosen by image variant and the tag by
|
|
42
|
+
* `resolveDockerIsolationImageTag()`.
|
|
43
|
+
*/
|
|
44
|
+
export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
45
|
+
if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
|
|
46
|
+
const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
|
|
47
|
+
return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default {
|
|
51
|
+
DEFAULT_HIVE_MIND_IMAGE_TAG,
|
|
52
|
+
HIVE_MIND_DIND_IMAGE_REPO,
|
|
53
|
+
HIVE_MIND_IMAGE_REPO,
|
|
54
|
+
getDockerIsolationImage,
|
|
55
|
+
resolveDockerIsolationImageTag,
|
|
56
|
+
};
|
|
@@ -21,6 +21,12 @@ import os from 'node:os';
|
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
|
|
23
23
|
import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseFormalAiSidecarForTask } from './formal-ai-isolation.lib.mjs';
|
|
24
|
+
// The image references live in their own module so the Formal AI sidecar can
|
|
25
|
+
// resolve the locally present Hive Mind image (which bakes `formal-ai`) without
|
|
26
|
+
// importing this runner and creating a cycle. Re-exported here because callers
|
|
27
|
+
// and tests have always reached them through the isolation runner. See #2154.
|
|
28
|
+
import { getDockerIsolationImage } from './hive-mind-image.lib.mjs';
|
|
29
|
+
export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
|
|
24
30
|
let commandStreamDollarPromise = null;
|
|
25
31
|
async function getCommandStreamDollar() {
|
|
26
32
|
if (!commandStreamDollarPromise) {
|
|
@@ -43,9 +49,6 @@ async function getCommandStreamDollar() {
|
|
|
43
49
|
export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
|
|
44
50
|
// Valid isolation backends
|
|
45
51
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
46
|
-
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
47
|
-
const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
48
|
-
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
49
52
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
50
53
|
const FORMAL_AI_COMPOSE_HOSTNAME = 'link-assistant-formal-ai';
|
|
51
54
|
// 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.
|
|
@@ -96,36 +99,6 @@ function maybeAddMount(mounts, source, target, existsSync) {
|
|
|
96
99
|
if (!existsSync(source)) return;
|
|
97
100
|
mounts.push({ source, target });
|
|
98
101
|
}
|
|
99
|
-
/**
|
|
100
|
-
* Resolve the tag used for the Docker isolation image.
|
|
101
|
-
*
|
|
102
|
-
* Release Docker images bake this env var from `HIVE_MIND_VERSION`, so a parent
|
|
103
|
-
* container started via `:latest` still launches child isolation containers from
|
|
104
|
-
* the same immutable release tag. Local/PR builds fall back to `latest`, and
|
|
105
|
-
* operators can override the tag explicitly when using custom images. Pinning
|
|
106
|
-
* matters for Docker-in-Docker deployments: the nested daemon starts with an
|
|
107
|
-
* empty image store, so a `:latest` digest drift from the host copy forces a
|
|
108
|
-
* fresh multi-gigabyte pull. See issue #1879.
|
|
109
|
-
*/
|
|
110
|
-
export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
|
|
111
|
-
const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
|
|
112
|
-
return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Pick the Docker image used for `--isolation docker`.
|
|
116
|
-
*
|
|
117
|
-
* start-command defaults its Docker backend to a base OS image. Hive Mind needs
|
|
118
|
-
* an image with the same CLI/tooling baseline as the parent process instead.
|
|
119
|
-
*
|
|
120
|
-
* `HIVE_MIND_DOCKER_ISOLATION_IMAGE` is a full override (repo:tag). Otherwise
|
|
121
|
-
* the repo is chosen by image variant and the tag by
|
|
122
|
-
* `resolveDockerIsolationImageTag()`.
|
|
123
|
-
*/
|
|
124
|
-
export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
125
|
-
if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
|
|
126
|
-
const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
|
|
127
|
-
return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
|
|
128
|
-
}
|
|
129
102
|
/**
|
|
130
103
|
* Resolve the path where the host Docker socket is expected to be mounted inside
|
|
131
104
|
* a DinD container. box's entrypoint reads this socket to copy host images into
|
|
@@ -296,6 +269,48 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
296
269
|
export function generateSessionId() {
|
|
297
270
|
return crypto.randomUUID();
|
|
298
271
|
}
|
|
272
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
273
|
+
/**
|
|
274
|
+
* Extract start-command's own execution UUID from a launch banner.
|
|
275
|
+
*
|
|
276
|
+
* Issue #2154: an isolated task has two UUIDs. Hive Mind generates the session
|
|
277
|
+
* name and passes it as `--session` (it also becomes the container name);
|
|
278
|
+
* start-command mints a separate execution UUID and prints it as the `session`
|
|
279
|
+
* field of its launch banner:
|
|
280
|
+
*
|
|
281
|
+
* ```
|
|
282
|
+
* │ session edc7b051-e12f-4f7b-b677-c885f3208407
|
|
283
|
+
* │ container 0a3627ef-f1f1-4801-a073-3678b9453db7
|
|
284
|
+
* ```
|
|
285
|
+
*
|
|
286
|
+
* `$ --list` shows the execution UUID, while Telegram and the logs showed the
|
|
287
|
+
* session UUID, so the two views could not be joined — which is why three
|
|
288
|
+
* refused tasks and two healthy ones looked equally unaccounted for. Returning
|
|
289
|
+
* it lets the caller record both.
|
|
290
|
+
*
|
|
291
|
+
* Only a well-formed UUID is returned; a banner we do not recognise yields
|
|
292
|
+
* null rather than a guess, because a wrong correlation is worse than none.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} output - Raw stdout from the detached `$` launch
|
|
295
|
+
* @returns {string|null}
|
|
296
|
+
*/
|
|
297
|
+
export function parseStartCommandExecutionUuid(output) {
|
|
298
|
+
const raw = (output || '').trim();
|
|
299
|
+
if (!raw) return null;
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(raw);
|
|
302
|
+
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
303
|
+
const uuid = data?.uuid || data?.session || null;
|
|
304
|
+
if (typeof uuid === 'string' && UUID_PATTERN.test(uuid.trim())) return uuid.trim();
|
|
305
|
+
} catch {
|
|
306
|
+
// Human-readable banner — fall through.
|
|
307
|
+
}
|
|
308
|
+
// The banner is box-drawn (`│ session <uuid>`); tolerate the prefix, an
|
|
309
|
+
// ASCII `|`, or no prefix at all.
|
|
310
|
+
const match = raw.match(/^[\s│|]*session\s+([^\s]+)\s*$/im);
|
|
311
|
+
const candidate = match?.[1]?.trim();
|
|
312
|
+
return candidate && UUID_PATTERN.test(candidate) ? candidate : null;
|
|
313
|
+
}
|
|
299
314
|
/**
|
|
300
315
|
* Parse output from `$ --status <session>`.
|
|
301
316
|
*
|
|
@@ -544,23 +559,22 @@ async function logDockerIsolationPostLaunchDiagnostics(sessionId, env = process.
|
|
|
544
559
|
export async function executeWithIsolation(command, args, options = {}) {
|
|
545
560
|
const { backend, verbose = false } = options;
|
|
546
561
|
const sessionId = options.sessionId || generateSessionId();
|
|
562
|
+
// Issue #2154: a launch that never produced a container left no trace in the
|
|
563
|
+
// bot log — the reply went to Telegram and the log jumped straight to
|
|
564
|
+
// "session untracked", so an operator could see that tasks were disappearing
|
|
565
|
+
// but not why, and could not even name them. Every unsuccessful return from
|
|
566
|
+
// this function now records the session UUID (the same one `$ --list` and the
|
|
567
|
+
// session store use) together with the reason.
|
|
568
|
+
const failLaunch = (error, extra = {}) => {
|
|
569
|
+
console.error(`[isolation-runner] Session ${sessionId} was not launched (backend=${backend}, tool=${options.tool ?? 'claude'}, model=${options.model ?? 'default'}): ${error}`);
|
|
570
|
+
return { success: false, sessionId, output: '', error, ...extra };
|
|
571
|
+
};
|
|
547
572
|
if (!VALID_ISOLATION_BACKENDS.includes(backend)) {
|
|
548
|
-
return {
|
|
549
|
-
success: false,
|
|
550
|
-
sessionId,
|
|
551
|
-
output: '',
|
|
552
|
-
error: `Invalid isolation backend: '${backend}'. Must be one of: ${VALID_ISOLATION_BACKENDS.join(', ')}`,
|
|
553
|
-
};
|
|
573
|
+
return failLaunch(`Invalid isolation backend: '${backend}'. Must be one of: ${VALID_ISOLATION_BACKENDS.join(', ')}`);
|
|
554
574
|
}
|
|
555
575
|
const binPath = await findStartCommandBinary();
|
|
556
576
|
if (!binPath) {
|
|
557
|
-
return {
|
|
558
|
-
success: false,
|
|
559
|
-
sessionId,
|
|
560
|
-
output: '',
|
|
561
|
-
warning: '⚠️ WARNING: start-command ($) not found in PATH\nPlease install: npm install -g start-command',
|
|
562
|
-
error: 'start-command ($) not found',
|
|
563
|
-
};
|
|
577
|
+
return failLaunch('start-command ($) not found', { warning: '⚠️ WARNING: start-command ($) not found in PATH\nPlease install: npm install -g start-command' });
|
|
564
578
|
}
|
|
565
579
|
if (verbose) {
|
|
566
580
|
console.log(`[VERBOSE] isolation-runner: Using $ binary at: ${binPath}`);
|
|
@@ -573,7 +587,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
573
587
|
// fails. Fail closed — a Formal AI task must never start without Formal AI.
|
|
574
588
|
const hostEnv = options.env || process.env;
|
|
575
589
|
const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
|
|
576
|
-
if (sidecarError) return
|
|
590
|
+
if (sidecarError) return failLaunch(sidecarError);
|
|
577
591
|
const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
|
|
578
592
|
const effectiveOptions =
|
|
579
593
|
backend === 'docker'
|
|
@@ -628,7 +642,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
628
642
|
if (formalAiAttachError) await removeDockerContainer(sessionId, verbose);
|
|
629
643
|
await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
|
|
630
644
|
if (formalAiAttachError) {
|
|
631
|
-
return
|
|
645
|
+
return failLaunch(`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}`, { output: result.output });
|
|
632
646
|
}
|
|
633
647
|
}
|
|
634
648
|
// Issue #1939: capture the freshly-launched docker session's reported status
|
|
@@ -639,19 +653,22 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
639
653
|
await logDockerIsolationPostLaunchDiagnostics(sessionId, options.env || process.env);
|
|
640
654
|
}
|
|
641
655
|
if (result.success) {
|
|
656
|
+
// Issue #2154: hand the caller start-command's own execution UUID as well.
|
|
657
|
+
// It is the identifier `$ --list` prints, so without it the bot and the
|
|
658
|
+
// session list cannot be joined by an operator.
|
|
659
|
+
const executionUuid = parseStartCommandExecutionUuid(result.output);
|
|
660
|
+
if (verbose) {
|
|
661
|
+
console.log(executionUuid ? `[VERBOSE] isolation-runner: start-command execution UUID for session ${sessionId}: ${executionUuid} (this is what '$ --list' shows)` : `[VERBOSE] isolation-runner: start-command reported no execution UUID for session ${sessionId}; '$ --list' cannot be correlated for this session`);
|
|
662
|
+
}
|
|
642
663
|
return {
|
|
643
664
|
success: true,
|
|
644
665
|
sessionId,
|
|
666
|
+
executionUuid,
|
|
645
667
|
output: result.output,
|
|
646
668
|
containerFilesystemStartBytes,
|
|
647
669
|
};
|
|
648
670
|
}
|
|
649
|
-
return {
|
|
650
|
-
success: false,
|
|
651
|
-
sessionId,
|
|
652
|
-
output: result.output,
|
|
653
|
-
error: result.error,
|
|
654
|
-
};
|
|
671
|
+
return failLaunch(result.error, { output: result.output });
|
|
655
672
|
}
|
|
656
673
|
/**
|
|
657
674
|
* Query the status of an isolated session via `$ --status <uuid>`
|
package/src/locales/en.lino
CHANGED
|
@@ -654,6 +654,7 @@ en
|
|
|
654
654
|
runner_also_failed "The runner also failed; its exit code is preserved for investigation."
|
|
655
655
|
killed "Work session {{reason}}{{exitSuffix}}"
|
|
656
656
|
stopped "Work session stopped by user{{requestedBy}}{{exitSuffix}}"
|
|
657
|
+
not_launched "The work session was not launched, so it has no log and is not listed by `--list`."
|
|
657
658
|
duration
|
|
658
659
|
label "Duration"
|
|
659
660
|
session
|
|
@@ -670,6 +671,8 @@ en
|
|
|
670
671
|
resumed_attempt "🔄 A new working session was started to recover from this kill (attempt {{attempt}}): {{sessionId}}"
|
|
671
672
|
isolation
|
|
672
673
|
label "Isolation"
|
|
674
|
+
execution
|
|
675
|
+
label "Execution"
|
|
673
676
|
error
|
|
674
677
|
executing
|
|
675
678
|
command "❌ Error executing {{commandName}} command"
|