@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background maintenance for the router sidecar (issue #2164, R5).
|
|
3
|
+
*
|
|
4
|
+
* The issue's requirement is that the `hive-mind-router` container "only runs
|
|
5
|
+
* while at least one task uses it". `releaseRouterSidecar()` covers the orderly
|
|
6
|
+
* case — a task that finishes hands its lease back and stops the container when
|
|
7
|
+
* it was the last one — but nothing orderly happens when a task is killed, when
|
|
8
|
+
* the host reboots mid-run, or when the release path itself fails. This tick is
|
|
9
|
+
* the backstop for all three: leases are reconciled against Docker, which is the
|
|
10
|
+
* only source of truth about whether a task is still alive, and a sidecar with
|
|
11
|
+
* no live lease is stopped.
|
|
12
|
+
*
|
|
13
|
+
* Stopping never touches the data volume. The request logs are the reason the
|
|
14
|
+
* feature exists (R8), so they outlive every container that produced them.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is best-effort: maintenance must never take the Telegram bot
|
|
17
|
+
* down, so a failure is reported and retried on the next tick rather than thrown.
|
|
18
|
+
*
|
|
19
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { startSidecarMaintenance } from './docker-sidecar.lib.mjs';
|
|
23
|
+
import { isRouterSidecarEnabled, reconcileRouterSidecar, stopRouterSidecar, withRouterSidecarLock } from './router-sidecar.lib.mjs';
|
|
24
|
+
|
|
25
|
+
/** Default gap between maintenance ticks. */
|
|
26
|
+
export const DEFAULT_ROUTER_MAINTENANCE_INTERVAL_MS = 5 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stop the router sidecar once no routed task holds a lease any more.
|
|
30
|
+
*
|
|
31
|
+
* Runs under the sidecar lock so it cannot race a task that is in the middle of
|
|
32
|
+
* acquiring one: without the lock, a tick could observe zero leases in the
|
|
33
|
+
* instant between "container started" and "lease written" and tear the sidecar
|
|
34
|
+
* down under a task that is about to use it.
|
|
35
|
+
*
|
|
36
|
+
* @returns {Promise<{leaseCount: number|null, stopped: boolean, skipped?: string}>}
|
|
37
|
+
*/
|
|
38
|
+
export const stopIdleRouterSidecar = async ({ env = process.env, run, log = null, verbose = false, lockOptions = {} } = {}) => {
|
|
39
|
+
if (!isRouterSidecarEnabled(env)) return { leaseCount: null, stopped: false, skipped: 'sidecar management disabled' };
|
|
40
|
+
return withRouterSidecarLock(
|
|
41
|
+
async () => {
|
|
42
|
+
const reconciled = await reconcileRouterSidecar({ env, run, log, verbose });
|
|
43
|
+
if (reconciled.leaseCount > 0) return { leaseCount: reconciled.leaseCount, stopped: false };
|
|
44
|
+
if (!reconciled.container.exists) return { leaseCount: 0, stopped: false };
|
|
45
|
+
const outcome = await stopRouterSidecar({ env, run, log, verbose, reason: 'no routed tasks running' });
|
|
46
|
+
return { leaseCount: 0, stopped: outcome.stopped };
|
|
47
|
+
},
|
|
48
|
+
{ env, log, ...lockOptions }
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run one maintenance tick.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<{idle: object, errors: object[]}>}
|
|
56
|
+
*/
|
|
57
|
+
export const runRouterMaintenanceTick = async ({ env = process.env, run, log = null, verbose = false, stopIdle = stopIdleRouterSidecar } = {}) => {
|
|
58
|
+
const errors = [];
|
|
59
|
+
let idle = { leaseCount: null, stopped: false };
|
|
60
|
+
try {
|
|
61
|
+
idle = await stopIdle({ env, run, log, verbose });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
errors.push({ stage: 'stop-idle', error: error?.message || String(error) });
|
|
64
|
+
if (log) await log(`⚠️ Router maintenance could not reconcile the sidecar: ${errors[0].error}`);
|
|
65
|
+
}
|
|
66
|
+
if (verbose && log) await log(`[VERBOSE] router-maintenance: leases=${idle.leaseCount ?? 'unknown'} stopped=${idle.stopped}${idle.skipped ? ` skipped=${idle.skipped}` : ''}`);
|
|
67
|
+
return { idle, errors };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start the periodic maintenance timer.
|
|
72
|
+
*
|
|
73
|
+
* @returns {{stop: () => void}}
|
|
74
|
+
*/
|
|
75
|
+
export const startRouterMaintenance = ({ env = process.env, log = null, verbose = false, intervalMs = DEFAULT_ROUTER_MAINTENANCE_INTERVAL_MS, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, runTick = runRouterMaintenanceTick } = {}) => startSidecarMaintenance({ runTick, logPrefix: 'router-maintenance', env, log, verbose, intervalMs, setIntervalImpl, clearIntervalImpl });
|
|
76
|
+
|
|
77
|
+
export default { DEFAULT_ROUTER_MAINTENANCE_INTERVAL_MS, runRouterMaintenanceTick, startRouterMaintenance, stopIdleRouterSidecar };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draining a finished task's agent session data into the audit archive
|
|
3
|
+
* (issue #2164, R7).
|
|
4
|
+
*
|
|
5
|
+
* A routed task never receives `~/.claude` or `~/.codex` from the host, so the
|
|
6
|
+
* session transcripts the agent CLI writes — the tool calls it made, the files
|
|
7
|
+
* it touched, the reasoning it recorded — live only inside that task's own
|
|
8
|
+
* container and die with it. The router's request log answers "what went to the
|
|
9
|
+
* model"; this answers "what the agent did with the answer", and a security
|
|
10
|
+
* audit needs both.
|
|
11
|
+
*
|
|
12
|
+
* So when a lease ends, the data is copied out *before* the container is
|
|
13
|
+
* reclaimed and merged into the router's preserved data volume next to the
|
|
14
|
+
* request logs. `docker cp` works on a stopped container, which is what makes
|
|
15
|
+
* the lease-drop path — the one that also catches killed and crashed tasks — a
|
|
16
|
+
* usable hook.
|
|
17
|
+
*
|
|
18
|
+
* Nothing sensitive is being moved here: the routed container never held a
|
|
19
|
+
* vendor credential in the first place, which is the entire point of the
|
|
20
|
+
* feature. What is copied is the record of an agent's own behaviour.
|
|
21
|
+
*
|
|
22
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2164
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
import { dockerOk, inspectDockerContainer } from './docker-sidecar.lib.mjs';
|
|
32
|
+
import { ROUTER_DATA_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME } from './router-isolation.lib.mjs';
|
|
33
|
+
|
|
34
|
+
const execFileAsync = promisify(execFile);
|
|
35
|
+
const LOG_PREFIX = 'router-session-drain';
|
|
36
|
+
|
|
37
|
+
/** Home directory inside every Docker-isolated task container (see isolation-runner.lib.mjs). */
|
|
38
|
+
export const TASK_CONTAINER_HOME = '/home/box';
|
|
39
|
+
|
|
40
|
+
/** Where drained sessions land inside the router's data volume. */
|
|
41
|
+
export const TASK_SESSION_ARCHIVE_DIR = `${ROUTER_DATA_MOUNT}/task-sessions`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What is worth taking out of a finished task.
|
|
45
|
+
*
|
|
46
|
+
* `.claude.json` is included because it carries the session index; the vendor
|
|
47
|
+
* OAuth block it would normally hold is absent in a routed task, which never
|
|
48
|
+
* logged in.
|
|
49
|
+
*/
|
|
50
|
+
export const DRAINABLE_SESSION_PATHS = Object.freeze([Object.freeze({ label: 'claude', source: `${TASK_CONTAINER_HOME}/.claude` }), Object.freeze({ label: 'claude.json', source: `${TASK_CONTAINER_HOME}/.claude.json` }), Object.freeze({ label: 'codex', source: `${TASK_CONTAINER_HOME}/.codex` })]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Is draining switched on?
|
|
54
|
+
*
|
|
55
|
+
* On by default whenever routing is: an audit trail that has to be enabled
|
|
56
|
+
* separately is an audit trail that will be missing exactly when it matters.
|
|
57
|
+
*/
|
|
58
|
+
export const isSessionDrainEnabled = (env = process.env) => {
|
|
59
|
+
const raw = String(env?.HIVE_MIND_ROUTER_DRAIN_SESSIONS ?? '')
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase();
|
|
62
|
+
return !(raw === '0' || raw === 'false' || raw === 'no');
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Optional host directory to archive into instead of the router volume.
|
|
67
|
+
*
|
|
68
|
+
* Issue #2164 allows either "the router container or root hive-mind container";
|
|
69
|
+
* an operator who would rather keep the transcripts on the root host — where
|
|
70
|
+
* they can be rotated and backed up with everything else — sets this.
|
|
71
|
+
*/
|
|
72
|
+
export const resolveSessionArchiveHostDir = (env = process.env) => {
|
|
73
|
+
const raw = String(env?.HIVE_MIND_SESSION_ARCHIVE_DIR || '').trim();
|
|
74
|
+
return raw || null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Copy one path out of a task container into a staging directory.
|
|
79
|
+
*
|
|
80
|
+
* A missing path is not a failure: a task that only ever ran Codex has no
|
|
81
|
+
* `.claude`, and a task that died during startup may have neither.
|
|
82
|
+
*
|
|
83
|
+
* @returns {Promise<boolean>} whether anything was copied
|
|
84
|
+
*/
|
|
85
|
+
export const copyTaskPath = async ({ sessionId, source, destination, run = execFileAsync, timeoutMs }) => dockerOk(run, ['cp', `${sessionId}:${source}`, destination], { timeoutMs });
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Drain a finished task's session data into the audit archive.
|
|
89
|
+
*
|
|
90
|
+
* Always resolves. A drain failure must never keep a lease alive or block a
|
|
91
|
+
* sidecar teardown — the request log, which is the primary record, is already
|
|
92
|
+
* safe in the volume either way.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<{drained: string[], destination: string|null, skipped: string|null, error: string|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export const drainTaskSessionData = async ({ sessionId, env = process.env, run = execFileAsync, timeoutMs, fsImpl = fs, tmpDir = os.tmpdir(), paths = DRAINABLE_SESSION_PATHS, routerContainer = ROUTER_SIDECAR_CONTAINER_NAME, log = null, verbose = false, now = () => new Date() } = {}) => {
|
|
97
|
+
const empty = { drained: [], destination: null, skipped: null, error: null };
|
|
98
|
+
if (!sessionId) return { ...empty, skipped: 'no sessionId' };
|
|
99
|
+
if (!isSessionDrainEnabled(env)) return { ...empty, skipped: 'disabled' };
|
|
100
|
+
|
|
101
|
+
let staging = null;
|
|
102
|
+
try {
|
|
103
|
+
const task = await inspectDockerContainer(sessionId, { run, timeoutMs });
|
|
104
|
+
// Nothing left to copy from: the container was already reclaimed.
|
|
105
|
+
if (!task.exists) return { ...empty, skipped: 'task container is gone' };
|
|
106
|
+
|
|
107
|
+
staging = fsImpl.mkdtempSync(path.join(tmpDir, `hive-mind-drain-${sessionId}-`));
|
|
108
|
+
const drained = [];
|
|
109
|
+
for (const entry of paths) {
|
|
110
|
+
if (await copyTaskPath({ sessionId, source: entry.source, destination: path.join(staging, entry.label), run, timeoutMs })) drained.push(entry.label);
|
|
111
|
+
}
|
|
112
|
+
if (drained.length === 0) return { ...empty, skipped: 'task recorded no session data' };
|
|
113
|
+
|
|
114
|
+
// A manifest makes an archived directory self-describing: an auditor reading
|
|
115
|
+
// it months later should not have to correlate it with a lease file to learn
|
|
116
|
+
// which task it came from.
|
|
117
|
+
fsImpl.writeFileSync(path.join(staging, 'manifest.json'), `${JSON.stringify({ sessionId, drainedAt: now().toISOString(), paths: drained, source: 'hive-mind router session drain (issue #2164)' }, null, 2)}\n`, { encoding: 'utf8' });
|
|
118
|
+
|
|
119
|
+
const hostDir = resolveSessionArchiveHostDir(env);
|
|
120
|
+
if (hostDir) {
|
|
121
|
+
const destination = path.join(hostDir, sessionId);
|
|
122
|
+
fsImpl.mkdirSync(destination, { recursive: true });
|
|
123
|
+
fsImpl.cpSync(staging, destination, { recursive: true });
|
|
124
|
+
if (log) await log(`🗄️ Archived session data for '${sessionId}' to ${destination} (${drained.join(', ')})`);
|
|
125
|
+
return { drained, destination, skipped: null, error: null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const destination = `${TASK_SESSION_ARCHIVE_DIR}/${sessionId}`;
|
|
129
|
+
if (!(await dockerOk(run, ['exec', routerContainer, 'mkdir', '-p', destination], { timeoutMs }))) {
|
|
130
|
+
return { drained: [], destination: null, skipped: null, error: `router container '${routerContainer}' is not available to archive into` };
|
|
131
|
+
}
|
|
132
|
+
// The trailing `/.` copies the directory's *contents*, so the archive is
|
|
133
|
+
// `<sessionId>/claude`, not `<sessionId>/<staging-name>/claude`.
|
|
134
|
+
if (!(await dockerOk(run, ['cp', `${staging}/.`, `${routerContainer}:${destination}`], { timeoutMs }))) {
|
|
135
|
+
return { drained: [], destination: null, skipped: null, error: `could not copy session data into '${routerContainer}'` };
|
|
136
|
+
}
|
|
137
|
+
if (log) await log(`🗄️ Archived session data for '${sessionId}' into the router volume at ${destination} (${drained.join(', ')})`);
|
|
138
|
+
if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: drained ${drained.join(', ')} from '${sessionId}'`);
|
|
139
|
+
return { drained, destination, skipped: null, error: null };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { ...empty, error: error?.message || String(error) };
|
|
142
|
+
} finally {
|
|
143
|
+
if (staging) {
|
|
144
|
+
try {
|
|
145
|
+
fsImpl.rmSync(staging, { recursive: true, force: true });
|
|
146
|
+
} catch {
|
|
147
|
+
// A leftover staging directory in /tmp is harmless; failing here is not.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export default { DRAINABLE_SESSION_PATHS, TASK_SESSION_ARCHIVE_DIR, copyTaskPath, drainTaskSessionData, isSessionDrainEnabled, resolveSessionArchiveHostDir };
|