@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +1 -1
  7. package/src/agent.lib.mjs +5 -1
  8. package/src/claude.lib.mjs +5 -158
  9. package/src/claude.session-tokens.lib.mjs +180 -0
  10. package/src/codex.diagnostics.lib.mjs +135 -0
  11. package/src/codex.lib.mjs +8 -121
  12. package/src/config.lib.mjs +9 -0
  13. package/src/docker-sidecar.lib.mjs +276 -0
  14. package/src/formal-ai-maintenance.lib.mjs +2 -14
  15. package/src/formal-ai-sidecar.lib.mjs +17 -137
  16. package/src/gemini.lib.mjs +5 -1
  17. package/src/git-push-guard.lib.mjs +230 -0
  18. package/src/git-retry.lib.mjs +97 -0
  19. package/src/github-pr-idempotency.lib.mjs +83 -0
  20. package/src/github-rate-limit.lib.mjs +44 -41
  21. package/src/hive.mjs +8 -150
  22. package/src/hive.repository-fallback.lib.mjs +125 -0
  23. package/src/hive.startup-checks.lib.mjs +57 -0
  24. package/src/isolation-runner.lib.mjs +94 -287
  25. package/src/isolation-runner.parsers.lib.mjs +292 -0
  26. package/src/lib.mjs +79 -18
  27. package/src/opencode.lib.mjs +5 -1
  28. package/src/qwen.lib.mjs +5 -1
  29. package/src/router-isolation.lib.mjs +496 -0
  30. package/src/router-logs.lib.mjs +143 -0
  31. package/src/router-maintenance.lib.mjs +77 -0
  32. package/src/router-session-drain.lib.mjs +153 -0
  33. package/src/router-sidecar.lib.mjs +516 -0
  34. package/src/router-task-isolation.lib.mjs +121 -0
  35. package/src/session-monitor.lib.mjs +12 -272
  36. package/src/session-monitor.queries.lib.mjs +304 -0
  37. package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
  38. package/src/solve.auto-pr.lib.mjs +40 -154
  39. package/src/solve.config.lib.mjs +11 -0
  40. package/src/solve.mjs +8 -158
  41. package/src/solve.mode.lib.mjs +191 -0
  42. package/src/task.config.lib.mjs +5 -0
  43. package/src/task.mjs +1 -0
  44. package/src/telegram-bot.mjs +18 -0
  45. package/src/telegram-solve-queue.lib.mjs +19 -272
  46. package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
  47. 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 };