@link-assistant/hive-mind 2.13.4 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/gemini.lib.mjs +5 -1
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +94 -287
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
|
@@ -26,6 +26,9 @@ import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseForm
|
|
|
26
26
|
// importing this runner and creating a cycle. Re-exported here because callers
|
|
27
27
|
// and tests have always reached them through the isolation runner. See #2154.
|
|
28
28
|
import { getDockerIsolationImage } from './hive-mind-image.lib.mjs';
|
|
29
|
+
import { buildRouterGitConfigEntries, buildRouterTaskEnv, getRouterSuppressedCredentialPaths, hasUseRouterFlag, isRouterEnabled, resolveRouterBaseUrl, resolveRouterGitHubRouting } from './router-isolation.lib.mjs';
|
|
30
|
+
import { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask } from './router-task-isolation.lib.mjs';
|
|
31
|
+
import { buildGitConfigEnv, GIT_PUSH_GUARD_CONTAINER_DIR, GIT_PUSH_GUARD_ESCAPE_ENV, hasForcePushOptIn, installGitPushGuard } from './git-push-guard.lib.mjs';
|
|
29
32
|
export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
|
|
30
33
|
let commandStreamDollarPromise = null;
|
|
31
34
|
async function getCommandStreamDollar() {
|
|
@@ -47,6 +50,10 @@ async function getCommandStreamDollar() {
|
|
|
47
50
|
}
|
|
48
51
|
// 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).
|
|
49
52
|
export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
|
|
53
|
+
// Issue #2175: the `$` output parsers live in their own module to keep this file
|
|
54
|
+
// under the 1350-line warning threshold. Re-exported so importers are unaffected.
|
|
55
|
+
import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
|
|
56
|
+
export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
|
|
50
57
|
// Valid isolation backends
|
|
51
58
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
52
59
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
@@ -59,17 +66,6 @@ const DOCKER_ISOLATION_SHELL = 'sh';
|
|
|
59
66
|
const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
|
|
60
67
|
// 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.
|
|
61
68
|
const DOCKER_START_GATE_WAIT_TENTHS = 300;
|
|
62
|
-
// 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.
|
|
63
|
-
const DOCKER_UNKNOWN_EXIT_CODE = -1;
|
|
64
|
-
function normalizeProcessIds(value) {
|
|
65
|
-
if (!value || typeof value !== 'object') return {};
|
|
66
|
-
const out = {};
|
|
67
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
68
|
-
const number = Number(raw);
|
|
69
|
-
if (Number.isInteger(number) && number > 0) out[key] = number;
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
69
|
function normalizeTool(tool) {
|
|
74
70
|
return String(tool || 'claude')
|
|
75
71
|
.trim()
|
|
@@ -121,21 +117,31 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
|
121
117
|
* commit. See issue #1939. Tool credentials are deliberately scoped: Codex
|
|
122
118
|
* sessions do not receive Claude files and Claude sessions do not receive Codex
|
|
123
119
|
* files.
|
|
120
|
+
*
|
|
121
|
+
* Issue #2164 (EXPERIMENTAL): with `useRouter` the vendor credential mounts are
|
|
122
|
+
* withheld entirely, so the task never holds the subscription — it reaches the
|
|
123
|
+
* `hive-mind-router` sidecar with its own scoped token instead. Git identity is
|
|
124
|
+
* still mounted, because it carries no secret and `solve` aborts without it
|
|
125
|
+
* (issue #1939). The gh config is only withheld when `ghRouted` says gh has
|
|
126
|
+
* somewhere else to go; otherwise the task would lose GitHub access entirely.
|
|
124
127
|
*/
|
|
125
|
-
export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
|
|
128
|
+
export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, ghRouted = false } = {}) {
|
|
126
129
|
const mounts = [];
|
|
127
130
|
const normalizedTool = normalizeTool(tool);
|
|
128
|
-
|
|
131
|
+
const suppressed = useRouter ? new Set(getRouterSuppressedCredentialPaths({ tool: normalizedTool, ghRouted })) : new Set();
|
|
132
|
+
if (!suppressed.has('.config/gh')) {
|
|
133
|
+
maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
|
|
134
|
+
}
|
|
129
135
|
// 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.
|
|
130
136
|
maybeAddMount(mounts, env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), path.join(DOCKER_CONTAINER_HOME, '.gitconfig'), existsSync);
|
|
131
137
|
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);
|
|
132
138
|
if (normalizedTool === 'codex') {
|
|
133
|
-
maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
|
|
139
|
+
if (!suppressed.has('.codex')) maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
|
|
134
140
|
// 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.
|
|
135
|
-
maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
|
|
141
|
+
if (!suppressed.has('.agents')) maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
|
|
136
142
|
} else if (normalizedTool === 'claude') {
|
|
137
|
-
maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
|
|
138
|
-
maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
|
|
143
|
+
if (!suppressed.has('.claude')) maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
|
|
144
|
+
if (!suppressed.has('.claude.json')) maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
|
|
139
145
|
}
|
|
140
146
|
return mounts;
|
|
141
147
|
}
|
|
@@ -198,7 +204,18 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
|
|
|
198
204
|
* reused instead of re-downloaded — no `--pull` plumbing required (issue #1879).
|
|
199
205
|
*/
|
|
200
206
|
export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
|
|
201
|
-
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
207
|
+
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, routerToken = null, installGuard = installGitPushGuard } = options;
|
|
208
|
+
// Issue #2164 (EXPERIMENTAL): router isolation replaces the credential mounts
|
|
209
|
+
// with a scoped token pointing at the `hive-mind-router` sidecar. It only
|
|
210
|
+
// engages when a token was actually issued; without one the task would have
|
|
211
|
+
// neither credentials nor a route, so we fail open to the default mounts
|
|
212
|
+
// rather than launching an agent that cannot reach any model.
|
|
213
|
+
const routerActive = isRouterEnabled({ useRouter, env }) && Boolean(routerToken);
|
|
214
|
+
const routerEndpoint = routerActive ? resolveRouterBaseUrl({ env }) : { baseUrl: null, external: false };
|
|
215
|
+
const routerBaseUrl = routerEndpoint.baseUrl;
|
|
216
|
+
const routerGitHub = routerActive ? resolveRouterGitHubRouting({ env, external: Boolean(routerEndpoint.external) }) : { mode: 'off', ghHost: null };
|
|
217
|
+
const routerEnv = routerActive && routerBaseUrl ? buildRouterTaskEnv({ tool, baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode, ghHost: routerGitHub.ghHost, homeDir: DOCKER_CONTAINER_HOME }) : {};
|
|
218
|
+
const routerWired = Object.keys(routerEnv).length > 0;
|
|
202
219
|
const image = getDockerIsolationImage({ env });
|
|
203
220
|
const startArgs = ['--isolated', 'docker', '--image', image];
|
|
204
221
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
@@ -212,8 +229,33 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
|
|
|
212
229
|
if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
|
|
213
230
|
startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
|
|
214
231
|
}
|
|
215
|
-
for (const
|
|
216
|
-
startArgs.push('
|
|
232
|
+
for (const [name, value] of Object.entries(routerEnv)) {
|
|
233
|
+
startArgs.push('-e', `${name}=${value}`);
|
|
234
|
+
}
|
|
235
|
+
const mounts = getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync, useRouter: routerWired, ghRouted: routerWired && routerGitHub.mode !== 'off' });
|
|
236
|
+
// Issue #2164 (R13): a routed task also loses the ability to destroy remote
|
|
237
|
+
// history by accident. The hook lives on the host and is mounted read-only, so
|
|
238
|
+
// the task cannot edit the rule it is being held to; git is pointed at it with
|
|
239
|
+
// GIT_CONFIG_* rather than `git config --global`, because the container's
|
|
240
|
+
// ~/.gitconfig is the operator's own file. This is one layer of three (see
|
|
241
|
+
// git-push-guard.lib.mjs) and `--no-verify` still gets past it.
|
|
242
|
+
if (routerWired) {
|
|
243
|
+
// One `GIT_CONFIG_COUNT` covers both the hook and the router's git
|
|
244
|
+
// transport: git shares the counter across all of them, so they have to be
|
|
245
|
+
// built together or the second would silently replace the first.
|
|
246
|
+
const gitConfigEntries = buildRouterGitConfigEntries({ baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode });
|
|
247
|
+
const guard = installGuard({ env, homeDir });
|
|
248
|
+
if (guard.installed) {
|
|
249
|
+
mounts.push({ source: guard.dir, target: GIT_PUSH_GUARD_CONTAINER_DIR, readOnly: true });
|
|
250
|
+
gitConfigEntries.unshift(['core.hooksPath', GIT_PUSH_GUARD_CONTAINER_DIR]);
|
|
251
|
+
if (hasForcePushOptIn(args)) startArgs.push('-e', `${GIT_PUSH_GUARD_ESCAPE_ENV}=1`);
|
|
252
|
+
}
|
|
253
|
+
for (const [name, value] of Object.entries(buildGitConfigEnv(gitConfigEntries))) {
|
|
254
|
+
startArgs.push('-e', `${name}=${value}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const mount of mounts) {
|
|
258
|
+
startArgs.push('--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
|
|
217
259
|
}
|
|
218
260
|
const taskCommand = buildShellCommand(command, args);
|
|
219
261
|
startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
|
|
@@ -269,233 +311,6 @@ async function runStartCommand(binPath, startCommandArgs) {
|
|
|
269
311
|
export function generateSessionId() {
|
|
270
312
|
return crypto.randomUUID();
|
|
271
313
|
}
|
|
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
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* Parse output from `$ --status <session>`.
|
|
316
|
-
*
|
|
317
|
-
* start-command versions used in the wild may return JSON when
|
|
318
|
-
* `--output-format json` is supported, or human-readable key/value text.
|
|
319
|
-
* Keep the parser tolerant so completion monitoring survives either format.
|
|
320
|
-
*
|
|
321
|
-
* @param {string} output - Raw stdout from `$ --status`
|
|
322
|
-
* @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
|
|
323
|
-
*/
|
|
324
|
-
export function parseSessionStatusOutput(output) {
|
|
325
|
-
const raw = (output || '').trim();
|
|
326
|
-
if (!raw) {
|
|
327
|
-
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: '' };
|
|
328
|
-
}
|
|
329
|
-
const normalizeBooleanField = value => {
|
|
330
|
-
if (typeof value === 'boolean') return value;
|
|
331
|
-
if (value === null || value === undefined) return null;
|
|
332
|
-
const normalized = String(value).trim().toLowerCase();
|
|
333
|
-
if (['true', '1', 'yes'].includes(normalized)) return true;
|
|
334
|
-
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
335
|
-
return null;
|
|
336
|
-
};
|
|
337
|
-
try {
|
|
338
|
-
const parsed = JSON.parse(raw);
|
|
339
|
-
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
340
|
-
// 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.
|
|
341
|
-
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;
|
|
342
|
-
const topPid = Number(data?.pid);
|
|
343
|
-
const processIds = normalizeProcessIds(data?.processIds);
|
|
344
|
-
if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
|
|
345
|
-
return {
|
|
346
|
-
exists: true,
|
|
347
|
-
uuid: data?.uuid || null,
|
|
348
|
-
status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
|
|
349
|
-
exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
|
|
350
|
-
startTime: data?.startTime || null,
|
|
351
|
-
endTime: data?.endTime || null,
|
|
352
|
-
currentTime: data?.currentTime || null,
|
|
353
|
-
logPath: data?.logPath || null,
|
|
354
|
-
command: data?.command || null,
|
|
355
|
-
isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
|
|
356
|
-
workingDirectory: data?.workingDirectory || null,
|
|
357
|
-
sessionName: data?.sessionName || data?.options?.sessionName || null,
|
|
358
|
-
processIds,
|
|
359
|
-
oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
|
|
360
|
-
raw,
|
|
361
|
-
};
|
|
362
|
-
} catch {
|
|
363
|
-
// Fall through to text parsing.
|
|
364
|
-
}
|
|
365
|
-
const firstLine =
|
|
366
|
-
raw
|
|
367
|
-
.split('\n')
|
|
368
|
-
.find(line => line.trim() && !line.includes(' '))
|
|
369
|
-
?.trim() || null;
|
|
370
|
-
const readField = name => {
|
|
371
|
-
const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
|
|
372
|
-
return match ? match[1].trim() : null;
|
|
373
|
-
};
|
|
374
|
-
const readBooleanField = name => normalizeBooleanField(readField(name));
|
|
375
|
-
const status = readField('status')?.toLowerCase() || null;
|
|
376
|
-
const exitCodeText = readField('exitCode');
|
|
377
|
-
// `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
|
|
378
|
-
// returned null for every real session and made /log + /terminal_watch
|
|
379
|
-
// reject screen/tmux/docker sessions. See issue #1700.
|
|
380
|
-
const isolationText = readField('isolated') || readField('isolation');
|
|
381
|
-
const processIds = {};
|
|
382
|
-
for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
|
|
383
|
-
const value = readField(name);
|
|
384
|
-
const number = Number(value);
|
|
385
|
-
if (Number.isInteger(number) && number > 0) processIds[name] = number;
|
|
386
|
-
}
|
|
387
|
-
return {
|
|
388
|
-
exists: Boolean(status || firstLine),
|
|
389
|
-
uuid: readField('uuid') || firstLine,
|
|
390
|
-
status,
|
|
391
|
-
exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
|
|
392
|
-
startTime: readField('startTime'),
|
|
393
|
-
endTime: readField('endTime'),
|
|
394
|
-
currentTime: readField('currentTime'),
|
|
395
|
-
logPath: readField('logPath'),
|
|
396
|
-
command: readField('command'),
|
|
397
|
-
isolation: isolationText?.toLowerCase() || null,
|
|
398
|
-
workingDirectory: readField('workingDirectory'),
|
|
399
|
-
sessionName: readField('sessionName'),
|
|
400
|
-
processIds,
|
|
401
|
-
oomKilled: readBooleanField('oomKilled'),
|
|
402
|
-
raw,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
/**
|
|
406
|
-
* Decide whether a detached-docker exit code is "unknown" (not a real result).
|
|
407
|
-
*
|
|
408
|
-
* start-command's detached docker logger writes the exit-code footer only after
|
|
409
|
-
* `docker logs -f` returns, capturing the real code via `docker inspect`. When
|
|
410
|
-
* it cannot capture one it records the sentinel `-1`. A `$ --status` that
|
|
411
|
-
* reports a terminal status ("executed") while still carrying that sentinel — or
|
|
412
|
-
* no exit code at all — is therefore ambiguous: the container may actually still
|
|
413
|
-
* be running. Callers treat such a status as provisional and cross-check the
|
|
414
|
-
* live container before declaring the session finished. See issue #1939.
|
|
415
|
-
*
|
|
416
|
-
* @param {number|null|undefined} exitCode
|
|
417
|
-
* @returns {boolean} True when the exit code carries no real result.
|
|
418
|
-
*/
|
|
419
|
-
export function isUnknownDockerExitCode(exitCode) {
|
|
420
|
-
return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
|
|
421
|
-
}
|
|
422
|
-
export function shouldFallbackToScreenStatus(statusResult) {
|
|
423
|
-
return !statusResult?.exists || !statusResult?.status;
|
|
424
|
-
}
|
|
425
|
-
/**
|
|
426
|
-
* Parse the footer start-command appends to every execution log when the wrapped
|
|
427
|
-
* command exits. The footer is authoritative about the terminal exit code even
|
|
428
|
-
* when `$ --status` is wrong: start-command writes it from the command's own
|
|
429
|
-
* `close`/`exited` handler, so its presence proves the command terminated.
|
|
430
|
-
*
|
|
431
|
-
* Footer shape (see start-command spawn-helpers.js):
|
|
432
|
-
*
|
|
433
|
-
* ==================================================
|
|
434
|
-
* Finished: 2026-06-14 19:10:49.822
|
|
435
|
-
* Exit Code: 137
|
|
436
|
-
*
|
|
437
|
-
* Issue #1927: start-command's `enrichDetachedStatus` can flip a completed
|
|
438
|
-
* `executed/137` record back to `executing` (nulling the exit code) when a
|
|
439
|
-
* lingering shell keeps the screen session alive — so `$ --status` reports
|
|
440
|
-
* `executing` forever and the bot never notices the kill. Reading this footer
|
|
441
|
-
* lets hive-mind detect the real terminal exit regardless of that flip.
|
|
442
|
-
*
|
|
443
|
-
* @param {string} text - Log text (typically the tail of the log file)
|
|
444
|
-
* @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
|
|
445
|
-
*/
|
|
446
|
-
export function parseSessionExitFooter(text) {
|
|
447
|
-
if (!text) return { finished: false, exitCode: null, endTime: null };
|
|
448
|
-
// Match the LAST footer block in the text (a re-run could append more than
|
|
449
|
-
// one). Anchor on the `=` separator so command output that merely prints
|
|
450
|
-
// "Exit Code: N" mid-stream is not mistaken for the footer.
|
|
451
|
-
const re = /={10,}\s*\r?\nFinished:\s*([^\r\n]+)\r?\nExit Code:\s*(-?\d+)/g;
|
|
452
|
-
let match;
|
|
453
|
-
let last = null;
|
|
454
|
-
while ((match = re.exec(text)) !== null) last = match;
|
|
455
|
-
if (!last) return { finished: false, exitCode: null, endTime: null };
|
|
456
|
-
return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Read the terminal exit code from the tail of a start-command execution log.
|
|
460
|
-
*
|
|
461
|
-
* Only the last `tailBytes` of the file are read (the footer lives at the end),
|
|
462
|
-
* so this is cheap even for multi-megabyte logs. Never throws — a missing or
|
|
463
|
-
* unreadable log yields `{ finished: false }`.
|
|
464
|
-
*
|
|
465
|
-
* @param {string} logPath
|
|
466
|
-
* @param {Object} [options]
|
|
467
|
-
* @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
|
|
468
|
-
* @param {number} [options.tailBytes=16384] - How many trailing bytes to scan
|
|
469
|
-
* @param {boolean} [options.verbose]
|
|
470
|
-
* @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
|
|
471
|
-
*/
|
|
472
|
-
export function readSessionExitFromLog(logPath, options = {}) {
|
|
473
|
-
const { fsImpl = fs, tailBytes = 16384, verbose = false } = options;
|
|
474
|
-
if (!logPath) return { finished: false, exitCode: null, endTime: null };
|
|
475
|
-
try {
|
|
476
|
-
const { size } = fsImpl.statSync(logPath);
|
|
477
|
-
if (!size) return { finished: false, exitCode: null, endTime: null };
|
|
478
|
-
const start = Math.max(0, size - tailBytes);
|
|
479
|
-
const length = size - start;
|
|
480
|
-
const buffer = Buffer.alloc(length);
|
|
481
|
-
const fd = fsImpl.openSync(logPath, 'r');
|
|
482
|
-
try {
|
|
483
|
-
fsImpl.readSync(fd, buffer, 0, length, start);
|
|
484
|
-
} finally {
|
|
485
|
-
fsImpl.closeSync(fd);
|
|
486
|
-
}
|
|
487
|
-
const result = parseSessionExitFooter(buffer.toString('utf8'));
|
|
488
|
-
if (verbose && result.finished) {
|
|
489
|
-
console.log(`[VERBOSE] isolation-runner: log footer for ${logPath} reports exit ${result.exitCode} (finished ${result.endTime})`);
|
|
490
|
-
}
|
|
491
|
-
return result;
|
|
492
|
-
} catch (error) {
|
|
493
|
-
if (verbose) {
|
|
494
|
-
console.log(`[VERBOSE] isolation-runner: could not read exit footer from ${logPath}: ${error.message}`);
|
|
495
|
-
}
|
|
496
|
-
return { finished: false, exitCode: null, endTime: null };
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
314
|
/**
|
|
500
315
|
* Find the `$` CLI binary path
|
|
501
316
|
* @returns {Promise<string|null>} Path to `$` binary or null
|
|
@@ -588,6 +403,25 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
588
403
|
const hostEnv = options.env || process.env;
|
|
589
404
|
const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
|
|
590
405
|
if (sidecarError) return failLaunch(sidecarError);
|
|
406
|
+
// Issue #2164 (EXPERIMENTAL): --use-router replaces the task's credential
|
|
407
|
+
// mounts with a token scoped to it alone. Like the Formal AI lease this is
|
|
408
|
+
// taken before the container exists, because the token is part of the
|
|
409
|
+
// environment the container is created with — and like it, it fails closed.
|
|
410
|
+
const { router, error: routerError } = await acquireRouterForTask({ backend, useRouter: options.useRouter === true || hasUseRouterFlag(args), model: options.model ?? null, tool: options.tool ?? 'claude', githubRepo: options.githubRepo ?? null, sessionId, env: hostEnv, verbose });
|
|
411
|
+
if (routerError) {
|
|
412
|
+
await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
|
|
413
|
+
return failLaunch(routerError);
|
|
414
|
+
}
|
|
415
|
+
// R11: when both sidecars are up, the router is taught to serve `formal-ai`
|
|
416
|
+
// itself, so that model is mediated and audited like every other one. Done
|
|
417
|
+
// before the container is created because the provider has to exist by the
|
|
418
|
+
// time the task issues its first request.
|
|
419
|
+
const formalAiRoutingError = await registerFormalAiWithRouter({ router, sidecar, verbose });
|
|
420
|
+
if (formalAiRoutingError) {
|
|
421
|
+
await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
|
|
422
|
+
await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
|
|
423
|
+
return failLaunch(formalAiRoutingError);
|
|
424
|
+
}
|
|
591
425
|
const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
|
|
592
426
|
const effectiveOptions =
|
|
593
427
|
backend === 'docker'
|
|
@@ -596,7 +430,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
596
430
|
env: await resolveFormalAiIsolationEnv(taskEnv),
|
|
597
431
|
}
|
|
598
432
|
: options;
|
|
599
|
-
const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
|
|
433
|
+
const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId, useRouter: Boolean(router), routerToken: router?.token ?? null });
|
|
600
434
|
if (verbose) {
|
|
601
435
|
console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
|
|
602
436
|
if (backend === 'docker') {
|
|
@@ -620,6 +454,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
620
454
|
}
|
|
621
455
|
let containerFilesystemStartBytes = null;
|
|
622
456
|
let formalAiAttachError = null;
|
|
457
|
+
let routerAttachError = null;
|
|
623
458
|
if (result.success && backend === 'docker') {
|
|
624
459
|
try {
|
|
625
460
|
containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
|
|
@@ -632,10 +467,17 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
632
467
|
// start sequence, and doing it here keeps the attach fail-closed on any
|
|
633
468
|
// installed version instead of silently one-network on older parsers.
|
|
634
469
|
formalAiAttachError = await attachFormalAiTaskContainer({ sidecar, sessionId, verbose });
|
|
470
|
+
routerAttachError = await attachRouterTaskContainer({ router, sessionId, env: hostEnv, verbose });
|
|
635
471
|
} finally {
|
|
636
472
|
await releaseDockerContainerStartGate(sessionId, verbose);
|
|
637
473
|
}
|
|
638
474
|
}
|
|
475
|
+
if (router && (!result.success || formalAiAttachError || routerAttachError)) {
|
|
476
|
+
// Fail closed for the same reason the acquire does: a task that cannot
|
|
477
|
+
// reach the router must not be left running with no route to a model.
|
|
478
|
+
if (routerAttachError) await removeDockerContainer(sessionId, verbose);
|
|
479
|
+
await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
|
|
480
|
+
}
|
|
639
481
|
if (sidecar && (!result.success || formalAiAttachError)) {
|
|
640
482
|
// Fail closed: without the internal network the task cannot reach Formal
|
|
641
483
|
// AI, and issue #2146 forbids falling back to another model.
|
|
@@ -645,6 +487,10 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
645
487
|
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 });
|
|
646
488
|
}
|
|
647
489
|
}
|
|
490
|
+
if (routerAttachError) {
|
|
491
|
+
if (sidecar) await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
|
|
492
|
+
return failLaunch(`The task container could not be joined to the router (internal network, CA trust or api.github.com interception), so it was stopped rather than run without a route to any model (issue #2164): ${routerAttachError}`, { output: result.output });
|
|
493
|
+
}
|
|
648
494
|
// Issue #1939: capture the freshly-launched docker session's reported status
|
|
649
495
|
// and the live container state together, so the next iteration has the data to
|
|
650
496
|
// diagnose a premature "executed/-1" status (problem #1) or a surprise image
|
|
@@ -700,45 +546,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
700
546
|
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: '' };
|
|
701
547
|
}
|
|
702
548
|
}
|
|
703
|
-
/**
|
|
704
|
-
* Parse output from `$ --list --output-format json`.
|
|
705
|
-
*
|
|
706
|
-
* start-command may return a top-level array, or an object with an
|
|
707
|
-
* `executions`/`sessions` array. Each entry is normalized to the same shape used
|
|
708
|
-
* by {@link parseSessionStatusOutput} (uuid/status/exitCode/command/isolation/…).
|
|
709
|
-
* Tolerant of unknown layouts — anything unparseable yields an empty list.
|
|
710
|
-
*
|
|
711
|
-
* @param {string} output - Raw stdout from `$ --list`
|
|
712
|
-
* @returns {Array<{uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null}>}
|
|
713
|
-
*/
|
|
714
|
-
export function parseSessionListOutput(output) {
|
|
715
|
-
const raw = (output || '').trim();
|
|
716
|
-
if (!raw) return [];
|
|
717
|
-
let parsed;
|
|
718
|
-
try {
|
|
719
|
-
parsed = JSON.parse(raw);
|
|
720
|
-
} catch {
|
|
721
|
-
return [];
|
|
722
|
-
}
|
|
723
|
-
const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
|
|
724
|
-
return records
|
|
725
|
-
.map(data => {
|
|
726
|
-
if (!data || typeof data !== 'object') return null;
|
|
727
|
-
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;
|
|
728
|
-
return {
|
|
729
|
-
uuid: data.uuid || data.session || data.sessionId || null,
|
|
730
|
-
status: typeof data.status === 'string' ? data.status.toLowerCase() : null,
|
|
731
|
-
exitCode: data.exitCode !== undefined && data.exitCode !== null ? Number(data.exitCode) : null,
|
|
732
|
-
startTime: data.startTime || null,
|
|
733
|
-
endTime: data.endTime || null,
|
|
734
|
-
command: data.command || null,
|
|
735
|
-
isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
|
|
736
|
-
workingDirectory: data.workingDirectory || null,
|
|
737
|
-
sessionName: data.sessionName || data.options?.sessionName || null,
|
|
738
|
-
};
|
|
739
|
-
})
|
|
740
|
-
.filter(Boolean);
|
|
741
|
-
}
|
|
742
549
|
/**
|
|
743
550
|
* List all executions known to start-command via `$ --list --output-format json`.
|
|
744
551
|
*
|