@link-assistant/hive-mind 2.10.0 → 2.10.1
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 +6 -0
- package/package.json +1 -1
- package/src/session-monitor.lib.mjs +6 -10
- package/src/session-resume.lib.mjs +43 -23
- package/src/session-store.lib.mjs +3 -1
- package/src/telegram-bot.mjs +2 -2
- package/src/telegram-command-execution.lib.mjs +2 -2
- package/src/telegram-isolation.lib.mjs +2 -0
- package/src/telegram-solve-queue.lib.mjs +5 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.10.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a9b0967: Fix killed Telegram task resume instructions by recovering the last tool session from the complete task log, rejecting unrelated shared-directory session logs, and preserving the original slash-command alias through queued and restarted sessions.
|
|
8
|
+
|
|
3
9
|
## 2.10.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -25,8 +25,7 @@ import { promisify } from 'util';
|
|
|
25
25
|
import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifySessionOutcome } from './work-session-formatting.lib.mjs';
|
|
26
26
|
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
27
27
|
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
28
|
-
import
|
|
29
|
-
import { readLastSessionIdFromLog, findLatestSessionLogId, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
28
|
+
import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
30
29
|
|
|
31
30
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
32
31
|
|
|
@@ -913,14 +912,11 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
913
912
|
const logPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
914
913
|
// The id must be the AI TOOL's session id, not the isolation session
|
|
915
914
|
// id (sessionInfo.sessionId — wrong namespace for `solve --resume`).
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
if (!lastSessionId && logPath) {
|
|
922
|
-
lastSessionId = findLatestSessionLogId({ dir: path.dirname(logPath), verbose });
|
|
923
|
-
}
|
|
915
|
+
// Scan backwards through this task's captured log for its last
|
|
916
|
+
// `Session ID:` marker. Do not guess from neighboring UUID-named
|
|
917
|
+
// logs: start-command stores unrelated tasks in the same backend
|
|
918
|
+
// directory, which caused issue #2109's invalid resume id.
|
|
919
|
+
const lastSessionId = readLastSessionIdFromLog(logPath, { verbose });
|
|
924
920
|
const resumeCommand = buildResumeCommand({ sessionInfo, lastSessionId });
|
|
925
921
|
const resumeSection = formatResumeSection({ lastSessionId, command: resumeCommand });
|
|
926
922
|
if (resumeSection) {
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
* 1. **Use the LAST session id.** A single `/solve` run can spin up *many*
|
|
12
12
|
* tool sessions — auto-continue across usage-limit resets, uncommitted-
|
|
13
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`.
|
|
14
|
+
* prints a `Session ID:` marker to the captured log in chronological order.
|
|
16
15
|
* 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
|
|
16
|
+
* pick the last id — never the first. {@link selectLastSessionId} and
|
|
17
|
+
* {@link readLastSessionIdFromLog} enforce that rule.
|
|
19
18
|
*
|
|
20
19
|
* 2. **Never storm.** Auto-resuming a killed session must be bounded so a job
|
|
21
20
|
* that reliably OOMs cannot spawn an infinite relaunch loop (which would be
|
|
@@ -41,6 +40,8 @@ const SESSION_ID_MARKER_RE = /Session ID:\s*`?([^\s`]+)`?/gi;
|
|
|
41
40
|
// `<sessionId>.log` files start-command writes. Used to validate directory
|
|
42
41
|
// scans so unrelated `*.log` files are never mistaken for a session.
|
|
43
42
|
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;
|
|
43
|
+
const DEFAULT_LOG_SCAN_CHUNK_BYTES = 262144;
|
|
44
|
+
const LOG_SCAN_OVERLAP_BYTES = 1024;
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Extract every tool session id printed to a log, in the order they appear.
|
|
@@ -79,34 +80,52 @@ export function selectLastSessionId(text) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
|
-
* Read the LAST tool session id from a `/solve` execution log.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* Read the LAST tool session id from a `/solve` execution log. The file is
|
|
84
|
+
* scanned backwards in bounded chunks: this normally stops after the tail
|
|
85
|
+
* chunk, while still finding a valid marker that precedes a long tool trace.
|
|
86
|
+
* Adjacent chunks overlap so a marker split at a chunk boundary is not lost.
|
|
87
|
+
* Never throws — a missing/unreadable log yields `null`.
|
|
86
88
|
*
|
|
87
89
|
* @param {string} logPath
|
|
88
90
|
* @param {Object} [options]
|
|
89
91
|
* @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
|
|
90
|
-
* @param {number} [options.tailBytes=262144] -
|
|
92
|
+
* @param {number} [options.tailBytes=262144] - Bytes per backwards scan chunk
|
|
91
93
|
* @param {boolean} [options.verbose]
|
|
92
94
|
* @returns {string|null}
|
|
93
95
|
*/
|
|
94
96
|
export function readLastSessionIdFromLog(logPath, options = {}) {
|
|
95
|
-
const { fsImpl = fs, tailBytes =
|
|
97
|
+
const { fsImpl = fs, tailBytes = DEFAULT_LOG_SCAN_CHUNK_BYTES, verbose = false } = options;
|
|
96
98
|
if (!logPath) return null;
|
|
97
99
|
try {
|
|
98
100
|
const stat = fsImpl.statSync(logPath);
|
|
99
|
-
const
|
|
101
|
+
const chunkBytes = Number.isFinite(tailBytes) && tailBytes > 0 ? Math.floor(tailBytes) : DEFAULT_LOG_SCAN_CHUNK_BYTES;
|
|
100
102
|
const fd = fsImpl.openSync(logPath, 'r');
|
|
101
103
|
try {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
let end = stat.size;
|
|
105
|
+
let laterPrefix = Buffer.alloc(0);
|
|
106
|
+
let chunksScanned = 0;
|
|
107
|
+
while (end > 0) {
|
|
108
|
+
const start = Math.max(0, end - chunkBytes);
|
|
109
|
+
const length = end - start;
|
|
110
|
+
const buffer = Buffer.alloc(length);
|
|
111
|
+
const bytesRead = fsImpl.readSync(fd, buffer, 0, length, start);
|
|
112
|
+
const current = bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
113
|
+
const scanBuffer = laterPrefix.length > 0 ? Buffer.concat([current, laterPrefix]) : current;
|
|
114
|
+
const id = selectLastSessionId(scanBuffer.toString('utf8'));
|
|
115
|
+
chunksScanned += 1;
|
|
116
|
+
if (id) {
|
|
117
|
+
if (verbose) {
|
|
118
|
+
console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id} (scanned ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'})`);
|
|
119
|
+
}
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
laterPrefix = current.subarray(0, Math.min(current.length, LOG_SCAN_OVERLAP_BYTES));
|
|
123
|
+
end = start;
|
|
108
124
|
}
|
|
109
|
-
|
|
125
|
+
if (verbose) {
|
|
126
|
+
console.log(`[VERBOSE] session-resume: no tool session id found in ${logPath} after scanning ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'}`);
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
110
129
|
} finally {
|
|
111
130
|
fsImpl.closeSync(fd);
|
|
112
131
|
}
|
|
@@ -121,10 +140,10 @@ export function readLastSessionIdFromLog(logPath, options = {}) {
|
|
|
121
140
|
/**
|
|
122
141
|
* Find the id of the most-recently-modified `<sessionId>.log` in a directory.
|
|
123
142
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
143
|
+
* This helper is safe only when `dir` is already known to contain logs for one
|
|
144
|
+
* task. A shared start-command directory cannot attribute its newest UUID log
|
|
145
|
+
* to a particular `/solve` run, so completion notifications must not use this
|
|
146
|
+
* as a fallback. Never throws.
|
|
128
147
|
*
|
|
129
148
|
* @param {Object} options
|
|
130
149
|
* @param {string} options.dir - Directory holding `<sessionId>.log` files
|
|
@@ -215,7 +234,8 @@ export function buildResumeCommand({ sessionInfo = {}, lastSessionId = null, bin
|
|
|
215
234
|
const url = sessionInfo.url || (Array.isArray(sessionInfo.args) ? sessionInfo.args[0] : null);
|
|
216
235
|
if (!url) return null;
|
|
217
236
|
|
|
218
|
-
const
|
|
237
|
+
const commandAlias = typeof sessionInfo.commandAlias === 'string' && /^[a-z0-9_-]+$/i.test(sessionInfo.commandAlias) ? sessionInfo.commandAlias : null;
|
|
238
|
+
const bin = binary || (commandAlias ? `/${commandAlias}` : command);
|
|
219
239
|
let args;
|
|
220
240
|
if (Array.isArray(sessionInfo.args) && sessionInfo.args.length > 0) {
|
|
221
241
|
args = stripResumeFlag(sessionInfo.args);
|
|
@@ -33,7 +33,9 @@ import path from 'node:path';
|
|
|
33
33
|
// excluded so the snapshot stays small and safe to reload.
|
|
34
34
|
// `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
|
|
35
35
|
// with its exact original invocation plus `--resume <lastSessionId>`.
|
|
36
|
-
|
|
36
|
+
// `commandAlias` (#2109) preserves the Telegram spelling (`solve`, `codex`,
|
|
37
|
+
// `claude`, etc.) so a bot notification never suggests a terminal-only command.
|
|
38
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Resolve the directory durable bot state is written to. Honors
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -820,13 +820,13 @@ async function handleSolveCommand(ctx) {
|
|
|
820
820
|
|
|
821
821
|
if (check.canStart && check.startReserved) {
|
|
822
822
|
const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock, locale: solveLocale }), { reply_to_message_id: ctx.message.message_id });
|
|
823
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
|
|
823
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale, commandAlias: solveCommandName });
|
|
824
824
|
} else {
|
|
825
825
|
if (!solveQueue.executeCallback) {
|
|
826
826
|
const _t = (s, i) => trackSession(s, i, VERBOSE);
|
|
827
827
|
solveQueue.executeCallback = createIsolationAwareQueueCallback(ISOLATION_BACKEND, isolationRunner, _t, createQueueExecuteCallback(executeStartScreen, _t), VERBOSE);
|
|
828
828
|
}
|
|
829
|
-
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
|
|
829
|
+
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, commandAlias: solveCommandName, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
|
|
830
830
|
const queueMessage = buildSolveQueuedMessage({ locale: solveLocale, tool: solveTool, position: toolQueuedCount + 1, infoBlock, reason: check.reason ? escapeMarkdown(check.reason) : '' }); // tool-specific position (#1551)
|
|
831
831
|
const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
|
|
832
832
|
queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
|
|
@@ -99,7 +99,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
|
|
|
99
99
|
*/
|
|
100
100
|
export function buildExecuteAndUpdateMessage(deps) {
|
|
101
101
|
const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
|
|
102
|
-
return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null } = {}) {
|
|
102
|
+
return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null, commandAlias = null } = {}) {
|
|
103
103
|
const { chat, message_id: msgId } = startingMessage;
|
|
104
104
|
const safeEdit = async text => {
|
|
105
105
|
try {
|
|
@@ -111,7 +111,7 @@ export function buildExecuteAndUpdateMessage(deps) {
|
|
|
111
111
|
const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
|
|
112
112
|
// #1927 review follow-up: persist the full args so a killed /solve can be
|
|
113
113
|
// resumed with its exact original invocation + `--resume <lastSessionId>`.
|
|
114
|
-
const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
|
|
114
|
+
const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, commandAlias, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
|
|
115
115
|
const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
|
|
116
116
|
let result, session, sessionInfo;
|
|
117
117
|
if (iso) {
|
|
@@ -90,11 +90,13 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
|
|
|
90
90
|
startTime: new Date(),
|
|
91
91
|
url: item.url,
|
|
92
92
|
command: item.command || 'solve',
|
|
93
|
+
commandAlias: item.commandAlias || null,
|
|
93
94
|
isolationBackend: iso.backend,
|
|
94
95
|
sessionId: sid,
|
|
95
96
|
containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
|
|
96
97
|
tool,
|
|
97
98
|
infoBlock: item.infoBlock,
|
|
99
|
+
args: Array.isArray(item.args) ? [...item.args] : undefined,
|
|
98
100
|
// Issue #1688: propagate URL context + requester through the queue so the
|
|
99
101
|
// completion notification can append a 'Pull request:' line and skip
|
|
100
102
|
// notifying the requester twice via /subscribe.
|
|
@@ -53,6 +53,7 @@ class SolveQueueItem {
|
|
|
53
53
|
this.ctx = options.ctx;
|
|
54
54
|
this.requester = options.requester;
|
|
55
55
|
this.infoBlock = options.infoBlock;
|
|
56
|
+
this.commandAlias = options.commandAlias || null; // #2109: retain Telegram spelling for resume guidance
|
|
56
57
|
this.tool = options.tool || 'claude';
|
|
57
58
|
// Issue #1983: preserve per-command isolation through queued execution.
|
|
58
59
|
this.perCommandIsolation = options.perCommandIsolation || null;
|
|
@@ -1442,16 +1443,14 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
|
|
|
1442
1443
|
startTime: new Date(),
|
|
1443
1444
|
url: item.url,
|
|
1444
1445
|
command: 'solve',
|
|
1446
|
+
commandAlias: item.commandAlias || null,
|
|
1445
1447
|
tool: item.tool || 'claude',
|
|
1446
1448
|
infoBlock: item.infoBlock,
|
|
1447
|
-
|
|
1448
|
-
//
|
|
1449
|
-
// notifying the requester twice via /subscribe.
|
|
1449
|
+
args: Array.isArray(item.args) ? [...item.args] : undefined,
|
|
1450
|
+
// #1688: propagate URL context and requester to completion.
|
|
1450
1451
|
urlContext: item.urlContext || null,
|
|
1451
1452
|
requesterUserId: item.requesterUserId ?? null,
|
|
1452
|
-
//
|
|
1453
|
-
// snapshot from the queueing point through to the completion
|
|
1454
|
-
// handler so it can render an end-of-task delta.
|
|
1453
|
+
// #594: carry the start-of-task limits snapshot to completion.
|
|
1455
1454
|
showLimits: item.showLimits === true,
|
|
1456
1455
|
limitsAtStart: item.limitsAtStart || null,
|
|
1457
1456
|
locale: item.locale || null,
|