@link-assistant/hive-mind 2.0.2 → 2.0.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 +138 -0
- package/package.json +1 -1
- package/src/bot-lifecycle.lib.mjs +128 -0
- package/src/bot-logger.lib.mjs +253 -0
- package/src/cleanup.lib.mjs +22 -4
- package/src/cleanup.mjs +15 -2
- package/src/cleanup.os.lib.mjs +94 -8
- package/src/isolation-runner.lib.mjs +378 -11
- package/src/session-monitor.lib.mjs +389 -18
- package/src/session-resume.lib.mjs +269 -0
- package/src/session-status.lib.mjs +141 -0
- package/src/session-store.lib.mjs +232 -0
- package/src/telegram-bot.mjs +65 -13
- package/src/telegram-command-execution.lib.mjs +3 -1
- package/src/telegram-terminal-watch-command.lib.mjs +47 -6
- package/src/work-session-formatting.lib.mjs +44 -11
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #1927 (review follow-up): resume planning for killed `/solve` sessions.
|
|
5
|
+
*
|
|
6
|
+
* When a detached `/solve` session is OOM/SIGKILL-ed, the surviving parent
|
|
7
|
+
* (the Telegram bot, or `/hive`) can relaunch the work with the AI tool's
|
|
8
|
+
* `--resume <sessionId>` flow instead of starting from scratch. Two facts make
|
|
9
|
+
* that safe and correct, and this module encodes both so every call site agrees:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Use the LAST session id.** A single `/solve` run can spin up *many*
|
|
12
|
+
* tool sessions — auto-continue across usage-limit resets, uncommitted-
|
|
13
|
+
* changes restarts (`solve.watch`), and manual `--resume` chains. Every one
|
|
14
|
+
* prints a `Session ID:` marker to the captured log in chronological order,
|
|
15
|
+
* and start-command also renames the per-session log to `<sessionId>.log`.
|
|
16
|
+
* The most advanced context lives in the *last* of these, so resuming must
|
|
17
|
+
* pick the last id — never the first. {@link selectLastSessionId} /
|
|
18
|
+
* {@link findLatestSessionLogId} enforce that rule.
|
|
19
|
+
*
|
|
20
|
+
* 2. **Never storm.** Auto-resuming a killed session must be bounded so a job
|
|
21
|
+
* that reliably OOMs cannot spawn an infinite relaunch loop (which would be
|
|
22
|
+
* worse than the silent hang #1927 set out to fix). {@link planKilledSessionResume}
|
|
23
|
+
* caps the number of automatic resumes per session (default 1) and only ever
|
|
24
|
+
* acts on a session that actually *can* be resumed.
|
|
25
|
+
*
|
|
26
|
+
* The module is pure and dependency-free apart from an injectable `fs`, so it is
|
|
27
|
+
* trivially unit-testable and importable from the bot, the monitor, or `/hive`
|
|
28
|
+
* without pulling in heavy transitive dependencies.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
34
|
+
// A tool session id printed to the log. Claude/codex/gemini all emit a
|
|
35
|
+
// `Session ID: <id>` marker (sometimes prefixed with 📌 and/or wrapped in
|
|
36
|
+
// backticks for Markdown). We capture the first non-space, non-backtick token
|
|
37
|
+
// after the label, which covers UUIDs and the slug-style ids other tools use.
|
|
38
|
+
const SESSION_ID_MARKER_RE = /Session ID:\s*`?([^\s`]+)`?/gi;
|
|
39
|
+
|
|
40
|
+
// Canonical UUID v4-ish shape used by Claude Code session ids and by the
|
|
41
|
+
// `<sessionId>.log` files start-command writes. Used to validate directory
|
|
42
|
+
// scans so unrelated `*.log` files are never mistaken for a session.
|
|
43
|
+
const SESSION_LOG_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract every tool session id printed to a log, in the order they appear.
|
|
47
|
+
*
|
|
48
|
+
* Consecutive duplicates are collapsed (a single tool run prints its id more
|
|
49
|
+
* than once — startup, completion, verbose footer — and that is one session,
|
|
50
|
+
* not three) while still preserving order across genuinely different sessions.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text - Log text
|
|
53
|
+
* @returns {string[]} Ordered session ids (possibly empty)
|
|
54
|
+
*/
|
|
55
|
+
export function extractSessionIds(text) {
|
|
56
|
+
if (!text || typeof text !== 'string') return [];
|
|
57
|
+
const ids = [];
|
|
58
|
+
let match;
|
|
59
|
+
SESSION_ID_MARKER_RE.lastIndex = 0;
|
|
60
|
+
while ((match = SESSION_ID_MARKER_RE.exec(text)) !== null) {
|
|
61
|
+
const id = match[1];
|
|
62
|
+
// Skip obvious non-ids that can follow the label in prose/log output.
|
|
63
|
+
if (!id || id.toLowerCase() === 'unknown' || id.toLowerCase() === 'n/a') continue;
|
|
64
|
+
if (ids[ids.length - 1] !== id) ids.push(id);
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The session id to resume from a log: the LAST one printed (requirement:
|
|
71
|
+
* "when we have multiple sessions in a single /solve call we use last of them").
|
|
72
|
+
*
|
|
73
|
+
* @param {string} text - Log text
|
|
74
|
+
* @returns {string|null}
|
|
75
|
+
*/
|
|
76
|
+
export function selectLastSessionId(text) {
|
|
77
|
+
const ids = extractSessionIds(text);
|
|
78
|
+
return ids.length > 0 ? ids[ids.length - 1] : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the LAST tool session id from a `/solve` execution log. Only the tail of
|
|
83
|
+
* the file is scanned (the most recent session marker lives near the end), so
|
|
84
|
+
* this stays cheap on multi-megabyte logs. Never throws — a missing/unreadable
|
|
85
|
+
* log yields `null`.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} logPath
|
|
88
|
+
* @param {Object} [options]
|
|
89
|
+
* @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
|
|
90
|
+
* @param {number} [options.tailBytes=262144] - Trailing bytes to scan (256 KiB)
|
|
91
|
+
* @param {boolean} [options.verbose]
|
|
92
|
+
* @returns {string|null}
|
|
93
|
+
*/
|
|
94
|
+
export function readLastSessionIdFromLog(logPath, options = {}) {
|
|
95
|
+
const { fsImpl = fs, tailBytes = 262144, verbose = false } = options;
|
|
96
|
+
if (!logPath) return null;
|
|
97
|
+
try {
|
|
98
|
+
const stat = fsImpl.statSync(logPath);
|
|
99
|
+
const start = Math.max(0, stat.size - tailBytes);
|
|
100
|
+
const fd = fsImpl.openSync(logPath, 'r');
|
|
101
|
+
try {
|
|
102
|
+
const length = stat.size - start;
|
|
103
|
+
const buffer = Buffer.alloc(length);
|
|
104
|
+
fsImpl.readSync(fd, buffer, 0, length, start);
|
|
105
|
+
const id = selectLastSessionId(buffer.toString('utf8'));
|
|
106
|
+
if (verbose && id) {
|
|
107
|
+
console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id}`);
|
|
108
|
+
}
|
|
109
|
+
return id;
|
|
110
|
+
} finally {
|
|
111
|
+
fsImpl.closeSync(fd);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (verbose) {
|
|
115
|
+
console.log(`[VERBOSE] session-resume: could not read session id from ${logPath}: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the id of the most-recently-modified `<sessionId>.log` in a directory.
|
|
123
|
+
*
|
|
124
|
+
* start-command renames each tool session's log to `<sessionId>.log`, so the
|
|
125
|
+
* newest such file is the last session of the run — a second, filesystem-based
|
|
126
|
+
* source for the "use the last session" rule that works even when the captured
|
|
127
|
+
* stdout log has been rotated away. Never throws.
|
|
128
|
+
*
|
|
129
|
+
* @param {Object} options
|
|
130
|
+
* @param {string} options.dir - Directory holding `<sessionId>.log` files
|
|
131
|
+
* @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
|
|
132
|
+
* @param {boolean} [options.verbose]
|
|
133
|
+
* @returns {string|null}
|
|
134
|
+
*/
|
|
135
|
+
export function findLatestSessionLogId({ dir, fsImpl = fs, verbose = false } = {}) {
|
|
136
|
+
if (!dir) return null;
|
|
137
|
+
try {
|
|
138
|
+
const entries = fsImpl.readdirSync(dir);
|
|
139
|
+
let bestId = null;
|
|
140
|
+
let bestMtime = -Infinity;
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.endsWith('.log')) continue;
|
|
143
|
+
const id = entry.slice(0, -'.log'.length);
|
|
144
|
+
if (!SESSION_LOG_UUID_RE.test(id)) continue;
|
|
145
|
+
let mtime;
|
|
146
|
+
try {
|
|
147
|
+
mtime = fsImpl.statSync(path.join(dir, entry)).mtimeMs;
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (mtime > bestMtime) {
|
|
152
|
+
bestMtime = mtime;
|
|
153
|
+
bestId = id;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (verbose && bestId) {
|
|
157
|
+
console.log(`[VERBOSE] session-resume: latest <sessionId>.log in ${dir} is ${bestId}`);
|
|
158
|
+
}
|
|
159
|
+
return bestId;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (verbose) {
|
|
162
|
+
console.log(`[VERBOSE] session-resume: could not scan ${dir} for session logs: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function quoteArg(value) {
|
|
169
|
+
const str = String(value);
|
|
170
|
+
// Quote only when needed; keep already-safe tokens (URLs, flags) readable.
|
|
171
|
+
if (/^[A-Za-z0-9_./:@=-]+$/.test(str)) return str;
|
|
172
|
+
return `"${str.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Drop any pre-existing `--resume`/`-r <id>` pair from an args array so a fresh
|
|
177
|
+
* resume id can be appended without conflict. Pure; returns a new array.
|
|
178
|
+
*
|
|
179
|
+
* @param {string[]} args
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
export function stripResumeFlag(args) {
|
|
183
|
+
if (!Array.isArray(args)) return [];
|
|
184
|
+
const out = [];
|
|
185
|
+
for (let i = 0; i < args.length; i++) {
|
|
186
|
+
const a = args[i];
|
|
187
|
+
if (a === '--resume' || a === '-r') {
|
|
188
|
+
i += 1; // skip the value too
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (typeof a === 'string' && (a.startsWith('--resume=') || a.startsWith('-r='))) continue;
|
|
192
|
+
out.push(a);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build the command that resumes a killed `/solve` session with its LAST tool
|
|
199
|
+
* session id. Only `/solve` sessions are resumable this way — `/hive` and other
|
|
200
|
+
* commands return `null` (the caller surfaces nothing rather than a bogus
|
|
201
|
+
* command). When the original args were persisted they are reused verbatim
|
|
202
|
+
* (minus any stale `--resume`); otherwise a minimal `<url> [--tool]` command is
|
|
203
|
+
* reconstructed from the persisted session info.
|
|
204
|
+
*
|
|
205
|
+
* @param {Object} options
|
|
206
|
+
* @param {Object} options.sessionInfo - Persisted session info (command/url/tool/args)
|
|
207
|
+
* @param {string} options.lastSessionId - The session id to resume from
|
|
208
|
+
* @param {string} [options.binary] - Override the invoked binary (default: the command)
|
|
209
|
+
* @returns {{ binary: string, args: string[], display: string }|null}
|
|
210
|
+
*/
|
|
211
|
+
export function buildResumeCommand({ sessionInfo = {}, lastSessionId = null, binary = null } = {}) {
|
|
212
|
+
if (!lastSessionId) return null;
|
|
213
|
+
const command = sessionInfo.command || 'solve';
|
|
214
|
+
if (command !== 'solve') return null; // only /solve is resumable via --resume
|
|
215
|
+
const url = sessionInfo.url || (Array.isArray(sessionInfo.args) ? sessionInfo.args[0] : null);
|
|
216
|
+
if (!url) return null;
|
|
217
|
+
|
|
218
|
+
const bin = binary || command;
|
|
219
|
+
let args;
|
|
220
|
+
if (Array.isArray(sessionInfo.args) && sessionInfo.args.length > 0) {
|
|
221
|
+
args = stripResumeFlag(sessionInfo.args);
|
|
222
|
+
} else {
|
|
223
|
+
args = [url];
|
|
224
|
+
if (sessionInfo.tool && sessionInfo.tool !== 'claude') args.push('--tool', sessionInfo.tool);
|
|
225
|
+
}
|
|
226
|
+
args = [...args, '--resume', lastSessionId];
|
|
227
|
+
return { binary: bin, args, display: `${bin} ${args.map(quoteArg).join(' ')}` };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Decide whether — and how — a killed `/solve` session should be auto-resumed by
|
|
232
|
+
* a surviving parent, bounding the number of automatic attempts so a reliably
|
|
233
|
+
* crashing job can never storm.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} options
|
|
236
|
+
* @param {Object} options.sessionInfo - Persisted session info
|
|
237
|
+
* @param {string|null} [options.lastSessionId] - LAST tool session id (from the log)
|
|
238
|
+
* @param {number} [options.attempts=0] - Resume attempts already made for this session
|
|
239
|
+
* @param {number} [options.maxAttempts=1] - Hard cap on automatic resumes
|
|
240
|
+
* @returns {{ resumable: boolean, reason: string, command: object|null, attempt: number }}
|
|
241
|
+
*/
|
|
242
|
+
export function planKilledSessionResume({ sessionInfo = {}, lastSessionId = null, attempts = 0, maxAttempts = 1 } = {}) {
|
|
243
|
+
if (!lastSessionId) {
|
|
244
|
+
return { resumable: false, reason: 'no-session-id', command: null, attempt: attempts };
|
|
245
|
+
}
|
|
246
|
+
const command = buildResumeCommand({ sessionInfo, lastSessionId });
|
|
247
|
+
if (!command) {
|
|
248
|
+
return { resumable: false, reason: 'not-resumable', command: null, attempt: attempts };
|
|
249
|
+
}
|
|
250
|
+
if (attempts >= maxAttempts) {
|
|
251
|
+
return { resumable: false, reason: 'max-attempts-reached', command, attempt: attempts };
|
|
252
|
+
}
|
|
253
|
+
return { resumable: true, reason: 'ready', command, attempt: attempts + 1 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Markdown section surfaced under a killed-session completion message so an
|
|
258
|
+
* operator (or an automation) can resume the work with one copy-paste. Purely
|
|
259
|
+
* additive — returns `''` when there is nothing to resume.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} options
|
|
262
|
+
* @param {string|null} options.lastSessionId
|
|
263
|
+
* @param {{ display: string }|null} options.command
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
export function formatResumeSection({ lastSessionId = null, command = null } = {}) {
|
|
267
|
+
if (!lastSessionId || !command) return '';
|
|
268
|
+
return `♻️ *Resume from last session* \`${lastSessionId}\`:\n\`\`\`\n${command.display}\n\`\`\``;
|
|
269
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared session-status vocabulary and exit-code classification.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1927: a detached `/solve` was OOM-killed (exit 137) but the Telegram
|
|
5
|
+
* bot never reported the failure. Two gaps in the status vocabulary contributed:
|
|
6
|
+
*
|
|
7
|
+
* 1. start-command only emits `executing`/`executed`; it has no notion of a
|
|
8
|
+
* *killed* session, and a signal exit (137 = 128+SIGKILL) was treated the
|
|
9
|
+
* same as any other completion — or, worse, hidden entirely.
|
|
10
|
+
* 2. The sets that decide "is this running / terminal / a failure" were
|
|
11
|
+
* duplicated across isolation-runner, session-monitor and work-session
|
|
12
|
+
* formatting, so a fix in one place silently disagreed with another.
|
|
13
|
+
*
|
|
14
|
+
* This module is the single source of truth for that vocabulary and for mapping
|
|
15
|
+
* a process exit code to a signal/kill label. It is intentionally
|
|
16
|
+
* dependency-free (pure JS, no Node built-ins) so every layer can import it
|
|
17
|
+
* without pulling heavy transitive deps (command-stream, i18n, …).
|
|
18
|
+
*
|
|
19
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1927
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
function norm(status) {
|
|
23
|
+
return String(status || '')
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize an exit code to a finite integer or null.
|
|
30
|
+
* @param {*} value
|
|
31
|
+
* @returns {number|null}
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeExitCode(value) {
|
|
34
|
+
if (value === null || value === undefined || value === '') return null;
|
|
35
|
+
const numeric = Number(value);
|
|
36
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A session that is still executing. start-command emits `executing`; hive-mind
|
|
40
|
+
// historically also accepted `running`.
|
|
41
|
+
export const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
|
|
42
|
+
|
|
43
|
+
// Statuses that mean the process was killed (by a signal) rather than exiting on
|
|
44
|
+
// its own. Surfaced to the user as an explicit "killed" rather than a generic
|
|
45
|
+
// failure so an OOM/SIGKILL is recognizable. (Issue #1927 requirement #1.)
|
|
46
|
+
export const KILLED_SESSION_STATUSES = new Set(['killed', 'terminated', 'dead', 'oom', 'oom-killed', 'oomkilled', 'sigkill', 'sigterm', 'sigsegv']);
|
|
47
|
+
|
|
48
|
+
// Statuses that mean the session ended unsuccessfully (a non-zero/abnormal
|
|
49
|
+
// outcome). Kills are a subset of failures.
|
|
50
|
+
export const FAILURE_SESSION_STATUSES = new Set(['failed', 'cancelled', 'canceled', 'error', 'timeout', 'timedout', 'timed_out', ...KILLED_SESSION_STATUSES]);
|
|
51
|
+
|
|
52
|
+
// Statuses that mean the session is no longer executing (success or failure).
|
|
53
|
+
// A superset of the original {executed, completed, failed, cancelled, canceled,
|
|
54
|
+
// error} plus the kill/timeout vocabulary added for issue #1927.
|
|
55
|
+
export const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', ...FAILURE_SESSION_STATUSES]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} status
|
|
59
|
+
* @returns {boolean} True when the session is still executing.
|
|
60
|
+
*/
|
|
61
|
+
export function isExecutingSessionStatus(status) {
|
|
62
|
+
return RUNNING_SESSION_STATUSES.has(norm(status));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} status
|
|
67
|
+
* @returns {boolean} True when the session is no longer executing.
|
|
68
|
+
*/
|
|
69
|
+
export function isTerminalSessionStatus(status) {
|
|
70
|
+
return TERMINAL_SESSION_STATUSES.has(norm(status));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} status
|
|
75
|
+
* @returns {boolean} True when the session was killed by a signal.
|
|
76
|
+
*/
|
|
77
|
+
export function isKilledSessionStatus(status) {
|
|
78
|
+
return KILLED_SESSION_STATUSES.has(norm(status));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} status
|
|
83
|
+
* @returns {boolean} True when the session ended unsuccessfully.
|
|
84
|
+
*/
|
|
85
|
+
export function isFailureSessionStatus(status) {
|
|
86
|
+
return FAILURE_SESSION_STATUSES.has(norm(status));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// POSIX signals that commonly terminate a wrapped command, with the reason we
|
|
90
|
+
// surface to the user. Exit codes above 128 encode the signal as `128 + signum`
|
|
91
|
+
// (the shell/Node convention), so 137 → SIGKILL, 143 → SIGTERM, 139 → SIGSEGV.
|
|
92
|
+
const SIGNAL_DESCRIPTIONS = {
|
|
93
|
+
1: { name: 'SIGHUP', reason: 'hung up (SIGHUP)' },
|
|
94
|
+
2: { name: 'SIGINT', reason: 'interrupted (SIGINT)' },
|
|
95
|
+
3: { name: 'SIGQUIT', reason: 'quit (SIGQUIT)' },
|
|
96
|
+
6: { name: 'SIGABRT', reason: 'aborted (SIGABRT)' },
|
|
97
|
+
9: { name: 'SIGKILL', reason: 'killed — out of memory or forced kill (SIGKILL)' },
|
|
98
|
+
11: { name: 'SIGSEGV', reason: 'crashed — segmentation fault (SIGSEGV)' },
|
|
99
|
+
15: { name: 'SIGTERM', reason: 'terminated (SIGTERM)' },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Describe a signal-based exit code (anything above 128).
|
|
104
|
+
*
|
|
105
|
+
* @param {*} exitCode
|
|
106
|
+
* @returns {{signal: string, signalNumber: number, reason: string}|null}
|
|
107
|
+
* Signal details, or null when the exit code is not a signal exit.
|
|
108
|
+
*/
|
|
109
|
+
export function describeExitSignal(exitCode) {
|
|
110
|
+
const code = normalizeExitCode(exitCode);
|
|
111
|
+
if (code === null || code <= 128) return null;
|
|
112
|
+
const signalNumber = code - 128;
|
|
113
|
+
const info = SIGNAL_DESCRIPTIONS[signalNumber] || { name: `SIG${signalNumber}`, reason: `killed by signal ${signalNumber}` };
|
|
114
|
+
return { signal: info.name, signalNumber, reason: info.reason };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map an exit code to a canonical session status string.
|
|
119
|
+
*
|
|
120
|
+
* - 0 → 'executed' (success)
|
|
121
|
+
* - 137,139,… → 'killed' (SIGKILL/SIGSEGV/etc.)
|
|
122
|
+
* - 143,130 → 'terminated'(SIGTERM/SIGINT — orderly termination)
|
|
123
|
+
* - other != 0 → 'failed'
|
|
124
|
+
* - null → null (unknown)
|
|
125
|
+
*
|
|
126
|
+
* @param {*} exitCode
|
|
127
|
+
* @returns {string|null}
|
|
128
|
+
*/
|
|
129
|
+
export function classifyExitStatus(exitCode) {
|
|
130
|
+
const code = normalizeExitCode(exitCode);
|
|
131
|
+
if (code === null) return null;
|
|
132
|
+
if (code === 0) return 'executed';
|
|
133
|
+
const signal = describeExitSignal(code);
|
|
134
|
+
if (signal) {
|
|
135
|
+
// SIGTERM/SIGINT are orderly terminations; everything else above 128 is a
|
|
136
|
+
// hard kill/crash.
|
|
137
|
+
if (signal.signalNumber === 15 || signal.signalNumber === 2) return 'terminated';
|
|
138
|
+
return 'killed';
|
|
139
|
+
}
|
|
140
|
+
return 'failed';
|
|
141
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable persistence for tracked Telegram work sessions.
|
|
3
|
+
*
|
|
4
|
+
* Issue #1927: the session monitor kept its registry purely in-memory
|
|
5
|
+
* (`activeSessions` Map). When the bot process was killed/restarted that map was
|
|
6
|
+
* lost, so a /solve running in a detached `$` session became an orphan the bot
|
|
7
|
+
* could never report on — it just vanished. Requirement #2 asks the bot to
|
|
8
|
+
* "detect restart … and if after bot start we have commands in `$`, try to
|
|
9
|
+
* resume them, if they started before bot start time." Requirement #4 asks that
|
|
10
|
+
* we never destroy previous data.
|
|
11
|
+
*
|
|
12
|
+
* This module persists the minimal, plain-data subset of each session's
|
|
13
|
+
* metadata to disk so that after a restart the monitor can reload its registry
|
|
14
|
+
* and keep watching detached sessions to completion. Two artifacts are written:
|
|
15
|
+
*
|
|
16
|
+
* - `sessions.json` — an atomically-rewritten snapshot of the *current* set
|
|
17
|
+
* of tracked sessions (the source of truth for resume).
|
|
18
|
+
* - `sessions-events.jsonl` — an append-only, timestamped audit log of every
|
|
19
|
+
* track/complete event. It is never truncated, so the full history of what
|
|
20
|
+
* ran (and when it ended) survives even total failures.
|
|
21
|
+
*
|
|
22
|
+
* The store is dependency-free and fully injectable for unit testing.
|
|
23
|
+
*
|
|
24
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1927
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
// Only plain, serializable metadata is persisted. Runtime-only fields (the bot
|
|
32
|
+
// instance, cached limits snapshots, transient error strings) are deliberately
|
|
33
|
+
// excluded so the snapshot stays small and safe to reload.
|
|
34
|
+
// `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
|
|
35
|
+
// with its exact original invocation plus `--resume <lastSessionId>`.
|
|
36
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the directory durable bot state is written to. Honors
|
|
40
|
+
* HIVE_MIND_STATE_DIR, then a stable per-user fallback. Never throws.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [env=process.env]
|
|
43
|
+
* @param {Function} [homedir=os.homedir]
|
|
44
|
+
* @returns {string} Absolute directory path
|
|
45
|
+
*/
|
|
46
|
+
export function resolveBotStateDir(env = process.env, homedir = os.homedir) {
|
|
47
|
+
const explicit = String(env.HIVE_MIND_STATE_DIR || '').trim();
|
|
48
|
+
if (explicit) return explicit;
|
|
49
|
+
const home = (() => {
|
|
50
|
+
try {
|
|
51
|
+
return homedir();
|
|
52
|
+
} catch {
|
|
53
|
+
return '/tmp';
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
return path.join(home, '.hive-mind', 'state');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toIso(value) {
|
|
60
|
+
if (!value) return null;
|
|
61
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
62
|
+
const date = new Date(value);
|
|
63
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reduce a sessionInfo object to its persistable subset, normalizing the
|
|
68
|
+
* startTime to an ISO string.
|
|
69
|
+
* @param {object} sessionInfo
|
|
70
|
+
* @returns {object}
|
|
71
|
+
*/
|
|
72
|
+
export function serializeSessionInfo(sessionInfo = {}) {
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const field of PERSISTABLE_FIELDS) {
|
|
75
|
+
if (sessionInfo[field] === undefined) continue;
|
|
76
|
+
if (field === 'startTime') {
|
|
77
|
+
const iso = toIso(sessionInfo.startTime);
|
|
78
|
+
if (iso) out.startTime = iso;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
out[field] = sessionInfo[field];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rehydrate a persisted session record, converting startTime back to a Date.
|
|
88
|
+
* @param {object} record
|
|
89
|
+
* @returns {object}
|
|
90
|
+
*/
|
|
91
|
+
export function deserializeSessionInfo(record = {}) {
|
|
92
|
+
const out = { ...record };
|
|
93
|
+
if (out.startTime) {
|
|
94
|
+
const date = new Date(out.startTime);
|
|
95
|
+
if (!Number.isNaN(date.getTime())) out.startTime = date;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a durable session store bound to a directory.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} [options]
|
|
104
|
+
* @param {string} [options.dir] - State directory (default: resolveBotStateDir()).
|
|
105
|
+
* @param {object} [options.fsImpl=fs] - Injectable fs (for tests).
|
|
106
|
+
* @param {Function} [options.now] - Injectable clock returning a Date.
|
|
107
|
+
* @param {boolean} [options.verbose=false]
|
|
108
|
+
* @param {object} [options.logger] - Optional bot logger for structured events.
|
|
109
|
+
* @returns {object} Session store instance.
|
|
110
|
+
*/
|
|
111
|
+
export function createSessionStore(options = {}) {
|
|
112
|
+
const { dir = resolveBotStateDir(), fsImpl = fs, now = () => new Date(), verbose = false, logger = null } = options;
|
|
113
|
+
|
|
114
|
+
const snapshotPath = path.join(dir, 'sessions.json');
|
|
115
|
+
const eventsPath = path.join(dir, 'sessions-events.jsonl');
|
|
116
|
+
let disabled = false;
|
|
117
|
+
|
|
118
|
+
function log(level, message, meta) {
|
|
119
|
+
if (logger && typeof logger[level] === 'function') logger[level](message, meta);
|
|
120
|
+
else if (verbose) console.log(`[session-store] ${message}${meta ? ` ${JSON.stringify(meta)}` : ''}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ensureDir() {
|
|
124
|
+
if (disabled) return false;
|
|
125
|
+
try {
|
|
126
|
+
fsImpl.mkdirSync(dir, { recursive: true });
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
disabled = true;
|
|
130
|
+
log('error', `Could not create state dir ${dir}: ${error.message} — persistence disabled`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readSnapshotMap() {
|
|
136
|
+
try {
|
|
137
|
+
const raw = fsImpl.readFileSync(snapshotPath, 'utf8');
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object') {
|
|
140
|
+
return parsed.sessions;
|
|
141
|
+
}
|
|
142
|
+
return {};
|
|
143
|
+
} catch {
|
|
144
|
+
// Missing or corrupt snapshot is non-fatal — start from empty.
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeSnapshotMap(sessions) {
|
|
150
|
+
if (!ensureDir()) return;
|
|
151
|
+
const payload = JSON.stringify({ version: 1, updatedAt: toIso(now()), sessions }, null, 2);
|
|
152
|
+
const tmpPath = `${snapshotPath}.tmp`;
|
|
153
|
+
try {
|
|
154
|
+
// Atomic replace: write tmp then rename so a crash mid-write never leaves
|
|
155
|
+
// a half-written snapshot.
|
|
156
|
+
fsImpl.writeFileSync(tmpPath, payload);
|
|
157
|
+
fsImpl.renameSync(tmpPath, snapshotPath);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
log('error', `Could not write session snapshot: ${error.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function appendEvent(type, sessionName, data) {
|
|
164
|
+
if (!ensureDir()) return;
|
|
165
|
+
const entry = { ts: toIso(now()), type, sessionName, ...data };
|
|
166
|
+
try {
|
|
167
|
+
fsImpl.appendFileSync(eventsPath, JSON.stringify(entry) + '\n');
|
|
168
|
+
} catch (error) {
|
|
169
|
+
log('error', `Could not append session event: ${error.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
get snapshotPath() {
|
|
175
|
+
return snapshotPath;
|
|
176
|
+
},
|
|
177
|
+
get eventsPath() {
|
|
178
|
+
return eventsPath;
|
|
179
|
+
},
|
|
180
|
+
get disabled() {
|
|
181
|
+
return disabled;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Persist (upsert) a tracked session and append a `track` audit event.
|
|
186
|
+
* @param {string} sessionName
|
|
187
|
+
* @param {object} sessionInfo
|
|
188
|
+
*/
|
|
189
|
+
persist(sessionName, sessionInfo) {
|
|
190
|
+
if (!sessionName) return;
|
|
191
|
+
const sessions = readSnapshotMap();
|
|
192
|
+
const serialized = serializeSessionInfo(sessionInfo);
|
|
193
|
+
serialized.persistedAt = toIso(now());
|
|
194
|
+
sessions[sessionName] = serialized;
|
|
195
|
+
writeSnapshotMap(sessions);
|
|
196
|
+
appendEvent('track', sessionName, { sessionInfo: serialized });
|
|
197
|
+
log('debug', `Persisted session ${sessionName}`, { command: serialized.command, url: serialized.url });
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Remove a session from the snapshot and append a `complete` audit event.
|
|
202
|
+
* The event records the terminal status/exit code so the history survives
|
|
203
|
+
* even though the live snapshot no longer lists the session.
|
|
204
|
+
* @param {string} sessionName
|
|
205
|
+
* @param {object} [meta] - { status, exitCode }
|
|
206
|
+
*/
|
|
207
|
+
remove(sessionName, meta = {}) {
|
|
208
|
+
if (!sessionName) return;
|
|
209
|
+
const sessions = readSnapshotMap();
|
|
210
|
+
if (sessionName in sessions) {
|
|
211
|
+
delete sessions[sessionName];
|
|
212
|
+
writeSnapshotMap(sessions);
|
|
213
|
+
}
|
|
214
|
+
appendEvent('complete', sessionName, { status: meta.status ?? null, exitCode: meta.exitCode ?? null });
|
|
215
|
+
log('debug', `Removed session ${sessionName} from snapshot`, meta);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Load all persisted sessions as `{ sessionName, sessionInfo }` records with
|
|
220
|
+
* startTime rehydrated to a Date.
|
|
221
|
+
* @returns {Array<{sessionName: string, sessionInfo: object}>}
|
|
222
|
+
*/
|
|
223
|
+
load() {
|
|
224
|
+
const sessions = readSnapshotMap();
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const [sessionName, record] of Object.entries(sessions)) {
|
|
227
|
+
out.push({ sessionName, sessionInfo: deserializeSessionInfo(record) });
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|