@link-assistant/hive-mind 1.56.7 ā 1.56.9
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 +13 -0
- package/package.json +2 -2
- package/src/isolation-runner.lib.mjs +86 -27
- package/src/session-monitor.lib.mjs +175 -93
- package/src/telegram-bot.mjs +20 -14
- package/src/telegram-isolation.lib.mjs +2 -2
- package/src/telegram-solve-queue.lib.mjs +85 -34
- package/src/work-session-formatting.lib.mjs +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.56.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 94448c3: Fix screen-isolated work-session Telegram updates so executing messages stay compact and completion messages use `$ --status` start/end timestamps and exit codes.
|
|
8
|
+
|
|
9
|
+
## 1.56.8
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 05a3e42: Fix CI/CD change detection for pull request synchronize events so metadata-only updates skip expensive test jobs while still reporting completed checks.
|
|
14
|
+
- c12f99d: Fix screen-isolated solve monitoring so completed `$ --status` sessions no longer block duplicate commands, queued status displays executing isolation sessions, and Telegram start messages stay in an executing state until completion.
|
|
15
|
+
|
|
3
16
|
## 1.56.7
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.9",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
18
|
+
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
19
19
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
20
20
|
"test:limits-display": "node tests/limits-display.test.mjs",
|
|
21
21
|
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
@@ -22,6 +22,8 @@ const { $ } = await use('command-stream');
|
|
|
22
22
|
|
|
23
23
|
// Valid isolation backends
|
|
24
24
|
const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
|
|
25
|
+
const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
|
|
26
|
+
const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Generate a UUID v4 for unique session identification
|
|
@@ -31,6 +33,76 @@ export function generateSessionId() {
|
|
|
31
33
|
return crypto.randomUUID();
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Parse output from `$ --status <session>`.
|
|
38
|
+
*
|
|
39
|
+
* start-command versions used in the wild may return JSON when
|
|
40
|
+
* `--output-format json` is supported, or human-readable key/value text.
|
|
41
|
+
* Keep the parser tolerant so completion monitoring survives either format.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} output - Raw stdout from `$ --status`
|
|
44
|
+
* @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function parseSessionStatusOutput(output) {
|
|
47
|
+
const raw = (output || '').trim();
|
|
48
|
+
if (!raw) {
|
|
49
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(raw);
|
|
54
|
+
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
55
|
+
return {
|
|
56
|
+
exists: true,
|
|
57
|
+
uuid: data?.uuid || null,
|
|
58
|
+
status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
|
|
59
|
+
exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
|
|
60
|
+
startTime: data?.startTime || null,
|
|
61
|
+
endTime: data?.endTime || null,
|
|
62
|
+
currentTime: data?.currentTime || null,
|
|
63
|
+
raw,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
// Fall through to text parsing.
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const firstLine =
|
|
70
|
+
raw
|
|
71
|
+
.split('\n')
|
|
72
|
+
.find(line => line.trim() && !line.includes(' '))
|
|
73
|
+
?.trim() || null;
|
|
74
|
+
const readField = name => {
|
|
75
|
+
const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
|
|
76
|
+
return match ? match[1].trim() : null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const status = readField('status')?.toLowerCase() || null;
|
|
80
|
+
const exitCodeText = readField('exitCode');
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
exists: Boolean(status || firstLine),
|
|
84
|
+
uuid: readField('uuid') || firstLine,
|
|
85
|
+
status,
|
|
86
|
+
exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
|
|
87
|
+
startTime: readField('startTime'),
|
|
88
|
+
endTime: readField('endTime'),
|
|
89
|
+
currentTime: readField('currentTime'),
|
|
90
|
+
raw,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isExecutingSessionStatus(status) {
|
|
95
|
+
return RUNNING_SESSION_STATUSES.has(String(status || '').toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isTerminalSessionStatus(status) {
|
|
99
|
+
return TERMINAL_SESSION_STATUSES.has(String(status || '').toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function shouldFallbackToScreenStatus(statusResult) {
|
|
103
|
+
return !statusResult?.exists || !statusResult?.status;
|
|
104
|
+
}
|
|
105
|
+
|
|
34
106
|
/**
|
|
35
107
|
* Find the `$` CLI binary path
|
|
36
108
|
* @returns {Promise<string|null>} Path to `$` binary or null
|
|
@@ -133,7 +205,7 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
133
205
|
*
|
|
134
206
|
* @param {string} sessionId - UUID of the session to check
|
|
135
207
|
* @param {boolean} [verbose] - Enable verbose logging
|
|
136
|
-
* @returns {Promise<{exists: boolean, status: string|null, exitCode: number|null, raw: string}>}
|
|
208
|
+
* @returns {Promise<{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}>}
|
|
137
209
|
*/
|
|
138
210
|
export async function querySessionStatus(sessionId, verbose = false) {
|
|
139
211
|
const binPath = await findStartCommandBinary();
|
|
@@ -141,7 +213,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
141
213
|
if (verbose) {
|
|
142
214
|
console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
|
|
143
215
|
}
|
|
144
|
-
return { exists: false, status: null, exitCode: null, raw: '' };
|
|
216
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
145
217
|
}
|
|
146
218
|
|
|
147
219
|
try {
|
|
@@ -153,30 +225,12 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
153
225
|
console.log(`[VERBOSE] isolation-runner: Status query result: ${stdout.substring(0, 300)}`);
|
|
154
226
|
}
|
|
155
227
|
|
|
156
|
-
|
|
157
|
-
const data = JSON.parse(stdout);
|
|
158
|
-
return {
|
|
159
|
-
exists: true,
|
|
160
|
-
status: data.status || null,
|
|
161
|
-
exitCode: data.exitCode !== undefined ? data.exitCode : null,
|
|
162
|
-
raw: stdout,
|
|
163
|
-
};
|
|
164
|
-
} catch {
|
|
165
|
-
// If JSON parsing fails, try text-based detection
|
|
166
|
-
const isExecuting = stdout.includes('executing');
|
|
167
|
-
const isExecuted = stdout.includes('executed');
|
|
168
|
-
return {
|
|
169
|
-
exists: isExecuting || isExecuted,
|
|
170
|
-
status: isExecuting ? 'executing' : isExecuted ? 'executed' : null,
|
|
171
|
-
exitCode: null,
|
|
172
|
-
raw: stdout,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
228
|
+
return parseSessionStatusOutput(stdout);
|
|
175
229
|
} catch (error) {
|
|
176
230
|
if (verbose) {
|
|
177
231
|
console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
|
|
178
232
|
}
|
|
179
|
-
return { exists: false, status: null, exitCode: null, raw: '' };
|
|
233
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
|
|
180
234
|
}
|
|
181
235
|
}
|
|
182
236
|
|
|
@@ -222,16 +276,21 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
222
276
|
const { backend, verbose = false } = opts;
|
|
223
277
|
|
|
224
278
|
const result = await querySessionStatus(sessionId, verbose);
|
|
225
|
-
if (result.exists && result.status
|
|
226
|
-
|
|
279
|
+
if (result.exists && result.status) {
|
|
280
|
+
if (isExecutingSessionStatus(result.status)) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
if (isTerminalSessionStatus(result.status)) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
227
286
|
}
|
|
228
287
|
|
|
229
288
|
// Fallback: for screen backend, check screen -ls directly.
|
|
230
|
-
// This works around
|
|
289
|
+
// Only use this when $ --status has no usable record. This works around
|
|
290
|
+
// older start-command bugs where:
|
|
231
291
|
// 1. $ --status can't find session by --session name (only by internal UUID)
|
|
232
|
-
// 2. $ --status reports "executed" immediately for --detached screen sessions
|
|
233
292
|
// See: https://github.com/link-assistant/hive-mind/issues/1545
|
|
234
|
-
if (backend === 'screen') {
|
|
293
|
+
if (backend === 'screen' && shouldFallbackToScreenStatus(result)) {
|
|
235
294
|
const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
|
|
236
295
|
if (screenRunning && verbose) {
|
|
237
296
|
console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import { promisify } from 'util';
|
|
19
19
|
import { exec as execCallback } from 'child_process';
|
|
20
|
+
import { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
21
|
+
|
|
22
|
+
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
20
23
|
|
|
21
24
|
const exec = promisify(execCallback);
|
|
22
25
|
|
|
@@ -28,12 +31,6 @@ async function getIsolationRunner() {
|
|
|
28
31
|
}
|
|
29
32
|
return _isolationRunner;
|
|
30
33
|
}
|
|
31
|
-
// Legacy accessor for querySessionStatus
|
|
32
|
-
async function getQuerySessionStatus() {
|
|
33
|
-
const mod = await getIsolationRunner();
|
|
34
|
-
return mod.querySessionStatus;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
// In-memory session store
|
|
38
35
|
const activeSessions = new Map();
|
|
39
36
|
|
|
@@ -65,51 +62,6 @@ export async function checkScreenSessionExists(sessionName) {
|
|
|
65
62
|
}
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
/**
|
|
69
|
-
* Check if an isolated session is still running.
|
|
70
|
-
* Uses isolation-runner's isSessionRunning which includes screen -ls fallback
|
|
71
|
-
* for screen-backend sessions to work around start-command UUID mismatch.
|
|
72
|
-
*
|
|
73
|
-
* @param {string} sessionId - UUID of the isolated session (screen session name)
|
|
74
|
-
* @param {Object} [options] - Options
|
|
75
|
-
* @param {string} [options.backend] - Isolation backend ('screen', 'tmux', 'docker')
|
|
76
|
-
* @param {boolean} [options.verbose] - Whether to log verbose output
|
|
77
|
-
* @returns {Promise<boolean>} True if session is still running
|
|
78
|
-
* @see https://github.com/link-assistant/hive-mind/issues/1545
|
|
79
|
-
*/
|
|
80
|
-
async function checkIsolatedSessionRunning(sessionId, options = {}) {
|
|
81
|
-
const opts = typeof options === 'boolean' ? { verbose: options } : options;
|
|
82
|
-
const { backend, verbose = false } = opts;
|
|
83
|
-
try {
|
|
84
|
-
const runner = await getIsolationRunner();
|
|
85
|
-
return await runner.isSessionRunning(sessionId, { backend, verbose });
|
|
86
|
-
} catch (error) {
|
|
87
|
-
if (verbose) {
|
|
88
|
-
console.error(`[VERBOSE] Error checking isolated session ${sessionId}: ${error.message}`);
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Get the exit code of a completed isolated session
|
|
96
|
-
* @param {string} sessionId - UUID of the isolated session
|
|
97
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
98
|
-
* @returns {Promise<number|null>} Exit code or null if unknown
|
|
99
|
-
*/
|
|
100
|
-
async function getIsolatedSessionExitCode(sessionId, verbose = false) {
|
|
101
|
-
try {
|
|
102
|
-
const queryStatus = await getQuerySessionStatus();
|
|
103
|
-
const result = await queryStatus(sessionId, verbose);
|
|
104
|
-
if (result.exists && result.status === 'executed') {
|
|
105
|
-
return result.exitCode;
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
65
|
/**
|
|
114
66
|
* Track a new session for completion monitoring
|
|
115
67
|
*
|
|
@@ -172,6 +124,67 @@ function completeSession(sessionName, exitCode = 0, verbose = false) {
|
|
|
172
124
|
}
|
|
173
125
|
}
|
|
174
126
|
|
|
127
|
+
function normalizeSessionUrl(url) {
|
|
128
|
+
return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
|
|
132
|
+
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
133
|
+
const elapsed = Date.now() - startTime.getTime();
|
|
134
|
+
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
135
|
+
if (verbose) {
|
|
136
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
|
|
137
|
+
}
|
|
138
|
+
activeSessions.delete(sessionName);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (verbose) {
|
|
142
|
+
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
143
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
|
|
149
|
+
const { verbose = false, statusProvider = null } = options;
|
|
150
|
+
const sessionId = sessionInfo.sessionId || sessionName;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const runner = await getIsolationRunner();
|
|
154
|
+
const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
|
|
155
|
+
|
|
156
|
+
if (statusResult?.exists && statusResult.status) {
|
|
157
|
+
if (runner.isExecutingSessionStatus(statusResult.status)) {
|
|
158
|
+
return { running: true, exitCode: null, status: statusResult.status, statusResult };
|
|
159
|
+
}
|
|
160
|
+
if (runner.isTerminalSessionStatus(statusResult.status)) {
|
|
161
|
+
return {
|
|
162
|
+
running: false,
|
|
163
|
+
exitCode: statusResult.exitCode !== undefined ? statusResult.exitCode : null,
|
|
164
|
+
status: statusResult.status,
|
|
165
|
+
statusResult,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const running = await runner.isSessionRunning(sessionId, {
|
|
171
|
+
backend: sessionInfo.isolationBackend,
|
|
172
|
+
verbose,
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
running,
|
|
176
|
+
exitCode: running ? null : (statusResult?.exitCode ?? null),
|
|
177
|
+
status: statusResult?.status || null,
|
|
178
|
+
statusResult,
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (verbose) {
|
|
182
|
+
console.error(`[VERBOSE] Error refreshing isolated session ${sessionId}: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
return { running: false, exitCode: null, status: null, statusResult: null };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
175
188
|
/**
|
|
176
189
|
* Monitor active sessions and send notifications when they complete
|
|
177
190
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
|
@@ -191,17 +204,16 @@ export async function monitorSessions(bot, verbose = false) {
|
|
|
191
204
|
for (const { sessionName, sessionInfo } of sessions) {
|
|
192
205
|
let stillRunning;
|
|
193
206
|
let exitCode = null;
|
|
207
|
+
let statusResult = null;
|
|
194
208
|
|
|
195
209
|
if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
|
|
196
|
-
// Isolation mode: use $ --status with screen -ls
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
exitCode = await getIsolatedSessionExitCode(sessionInfo.sessionId, verbose);
|
|
204
|
-
}
|
|
210
|
+
// Isolation mode: use $ --status, with screen -ls only as a fallback
|
|
211
|
+
// when the status record is unavailable. Terminal $ statuses are
|
|
212
|
+
// authoritative so completed screen sessions do not stay blocked.
|
|
213
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, { verbose });
|
|
214
|
+
stillRunning = state.running;
|
|
215
|
+
exitCode = state.exitCode;
|
|
216
|
+
statusResult = state.statusResult;
|
|
205
217
|
} else {
|
|
206
218
|
// Issue #1586: Non-isolation screen sessions cannot reliably detect
|
|
207
219
|
// completion because start-screen keeps the screen alive via `exec bash`.
|
|
@@ -226,21 +238,14 @@ export async function monitorSessions(bot, verbose = false) {
|
|
|
226
238
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
227
239
|
|
|
228
240
|
try {
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const isolationInfo = sessionInfo.isolationBackend ? `\nš Isolation: ${sessionInfo.isolationBackend}` : '';
|
|
238
|
-
|
|
239
|
-
let message = `${statusEmoji} *Work Session ${statusText}*\n\n`;
|
|
240
|
-
message += `š Session: \`${sessionName}\`\n`;
|
|
241
|
-
message += `ā±ļø Duration: ${minutes}m ${seconds}s\n`;
|
|
242
|
-
message += `š URL: ${sessionInfo.url}${isolationInfo}\n\n`;
|
|
243
|
-
message += `The work session has finished. You can now review the results.`;
|
|
241
|
+
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
242
|
+
const message = formatSessionCompletionMessage({
|
|
243
|
+
sessionName,
|
|
244
|
+
sessionInfo,
|
|
245
|
+
statusResult,
|
|
246
|
+
observedEndTime: new Date(),
|
|
247
|
+
exitCode: finalExitCode,
|
|
248
|
+
});
|
|
244
249
|
|
|
245
250
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
246
251
|
if (sessionInfo.messageId) {
|
|
@@ -249,7 +254,7 @@ export async function monitorSessions(bot, verbose = false) {
|
|
|
249
254
|
await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
|
|
250
255
|
}
|
|
251
256
|
|
|
252
|
-
completeSession(sessionName,
|
|
257
|
+
completeSession(sessionName, finalExitCode || 0, verbose);
|
|
253
258
|
} catch (error) {
|
|
254
259
|
console.error(`Failed to send completion notification for ${sessionName}:`, error);
|
|
255
260
|
completeSession(sessionName, 1, verbose);
|
|
@@ -294,27 +299,14 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
294
299
|
if (!url) return { isActive: false, sessionName: null };
|
|
295
300
|
|
|
296
301
|
// Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
|
|
297
|
-
const
|
|
298
|
-
const normalizedUrl = normalizeUrl(url);
|
|
302
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
299
303
|
|
|
300
304
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
301
305
|
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
302
|
-
if (!sessionInfo.isolationBackend) {
|
|
303
|
-
|
|
304
|
-
const elapsed = Date.now() - startTime.getTime();
|
|
305
|
-
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
306
|
-
if (verbose) {
|
|
307
|
-
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
|
|
308
|
-
}
|
|
309
|
-
activeSessions.delete(sessionName);
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
if (verbose) {
|
|
313
|
-
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
314
|
-
console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
|
|
315
|
-
}
|
|
306
|
+
if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
307
|
+
continue;
|
|
316
308
|
}
|
|
317
|
-
if (sessionInfo.url &&
|
|
309
|
+
if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
|
|
318
310
|
if (verbose) {
|
|
319
311
|
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
|
|
320
312
|
console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
|
|
@@ -329,6 +321,96 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
329
321
|
return { isActive: false, sessionName: null };
|
|
330
322
|
}
|
|
331
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Async active-session check for command handlers.
|
|
326
|
+
*
|
|
327
|
+
* Isolation-backed sessions are refreshed through `$ --status` before they
|
|
328
|
+
* block a duplicate URL, so completed screen-isolated runs no longer require
|
|
329
|
+
* waiting for the background polling interval.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} url - The GitHub URL to check
|
|
332
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
333
|
+
* @param {Object} [options] - Test/support options
|
|
334
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
335
|
+
* @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
|
|
336
|
+
*/
|
|
337
|
+
export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
|
|
338
|
+
if (!url) return { isActive: false, sessionName: null };
|
|
339
|
+
|
|
340
|
+
const normalizedUrl = normalizeSessionUrl(url);
|
|
341
|
+
|
|
342
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
343
|
+
if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!sessionInfo.isolationBackend) {
|
|
348
|
+
if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
|
|
349
|
+
return { isActive: true, sessionName, status: null };
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
355
|
+
verbose,
|
|
356
|
+
statusProvider: options.statusProvider,
|
|
357
|
+
});
|
|
358
|
+
if (state.running) {
|
|
359
|
+
if (verbose) {
|
|
360
|
+
console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
|
|
361
|
+
}
|
|
362
|
+
return { isActive: true, sessionName, status: state.status || null };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (verbose) {
|
|
366
|
+
console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
|
|
367
|
+
}
|
|
368
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
369
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (verbose) {
|
|
373
|
+
console.log(`[VERBOSE] No active session found for URL ${url}`);
|
|
374
|
+
}
|
|
375
|
+
return { isActive: false, sessionName: null };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Refresh tracked isolation sessions and count only those that are executing.
|
|
380
|
+
*
|
|
381
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
382
|
+
* @param {Object} [options] - Test/support options
|
|
383
|
+
* @param {Function} [options.statusProvider] - Optional `$ --status` provider
|
|
384
|
+
* @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
|
|
385
|
+
*/
|
|
386
|
+
export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
|
|
387
|
+
const sessions = [];
|
|
388
|
+
const byTool = {};
|
|
389
|
+
|
|
390
|
+
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
391
|
+
if (!sessionInfo.isolationBackend) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const state = await getIsolationSessionState(sessionName, sessionInfo, {
|
|
396
|
+
verbose,
|
|
397
|
+
statusProvider: options.statusProvider,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!state.running) {
|
|
401
|
+
sessionInfo.lastKnownStatus = state.status || null;
|
|
402
|
+
sessionInfo.lastKnownExitCode = state.exitCode ?? null;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const tool = sessionInfo.tool || 'claude';
|
|
407
|
+
sessions.push(sessionName);
|
|
408
|
+
byTool[tool] = (byTool[tool] || 0) + 1;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { count: sessions.length, sessions, byTool };
|
|
412
|
+
}
|
|
413
|
+
|
|
332
414
|
/**
|
|
333
415
|
* Get statistics about session tracking
|
|
334
416
|
* @param {boolean} verbose - Whether to log verbose output
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -49,7 +49,8 @@ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFro
|
|
|
49
49
|
const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
|
|
50
50
|
const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
|
|
51
51
|
const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
|
|
52
|
-
const { trackSession, startSessionMonitoring,
|
|
52
|
+
const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
|
|
53
|
+
const { formatExecutingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
|
|
53
54
|
|
|
54
55
|
const config = yargs(hideBin(process.argv))
|
|
55
56
|
.usage('Usage: hive-telegram-bot [options]')
|
|
@@ -549,7 +550,7 @@ async function safeReply(ctx, text, options = {}) {
|
|
|
549
550
|
}
|
|
550
551
|
}
|
|
551
552
|
|
|
552
|
-
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null) {
|
|
553
|
+
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
|
|
553
554
|
const { chat, message_id: msgId } = startingMessage;
|
|
554
555
|
const safeEdit = async text => {
|
|
555
556
|
try {
|
|
@@ -559,28 +560,33 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
|
|
|
559
560
|
}
|
|
560
561
|
};
|
|
561
562
|
const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
|
|
562
|
-
let result,
|
|
563
|
-
session,
|
|
564
|
-
extraInfo = '';
|
|
563
|
+
let result, session;
|
|
565
564
|
if (iso) {
|
|
566
565
|
session = iso.runner.generateSessionId();
|
|
567
566
|
VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
|
|
568
567
|
result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
|
|
569
|
-
|
|
570
|
-
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session }, VERBOSE);
|
|
568
|
+
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool }, VERBOSE);
|
|
571
569
|
} else {
|
|
572
570
|
result = await executeStartScreen(commandName, args);
|
|
573
571
|
const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
574
572
|
session = match ? match[1] : 'unknown';
|
|
575
573
|
// Issue #1586: Track non-isolation sessions with timeout-based expiry.
|
|
576
574
|
// These sessions cannot reliably detect completion (screen stays alive via
|
|
577
|
-
// `exec bash`), so
|
|
575
|
+
// `exec bash`), so active URL checks auto-expire them after 10 min.
|
|
578
576
|
// This prevents accidental duplicate commands within the timeout window.
|
|
579
|
-
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName }, VERBOSE);
|
|
577
|
+
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool }, VERBOSE);
|
|
580
578
|
}
|
|
581
579
|
if (result.warning) return safeEdit(`ā ļø ${result.warning}`);
|
|
582
|
-
if (result.success)
|
|
583
|
-
|
|
580
|
+
if (result.success) {
|
|
581
|
+
await safeEdit(
|
|
582
|
+
formatExecutingWorkSessionMessage({
|
|
583
|
+
commandName,
|
|
584
|
+
sessionName: session,
|
|
585
|
+
isolationBackend: iso?.backend || null,
|
|
586
|
+
infoBlock,
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
} else await safeEdit(`ā Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
|
|
584
590
|
}
|
|
585
591
|
|
|
586
592
|
bot.command('help', async ctx => {
|
|
@@ -990,7 +996,7 @@ async function handleSolveCommand(ctx) {
|
|
|
990
996
|
return;
|
|
991
997
|
}
|
|
992
998
|
// Issue #1567: Prevent concurrent sessions on the same PR/issue
|
|
993
|
-
const activeSession =
|
|
999
|
+
const activeSession = await hasActiveSessionForUrlAsync(normalizedUrl, VERBOSE);
|
|
994
1000
|
if (activeSession.isActive) {
|
|
995
1001
|
await safeReply(ctx, `ā A working session is already running for this URL.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nSession: \`${activeSession.sessionName}\`\n\nš” Wait for the current session to complete, or use /solve\\_stop to cancel it.`, { reply_to_message_id: ctx.message.message_id });
|
|
996
1002
|
return;
|
|
@@ -1006,7 +1012,7 @@ async function handleSolveCommand(ctx) {
|
|
|
1006
1012
|
const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
|
|
1007
1013
|
if (check.canStart && toolQueuedCount === 0) {
|
|
1008
1014
|
const startingMessage = await safeReply(ctx, `š Starting solve command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
|
|
1009
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation);
|
|
1015
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
|
|
1010
1016
|
} else {
|
|
1011
1017
|
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
|
|
1012
1018
|
let queueMessage = `š Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
|
|
@@ -1171,7 +1177,7 @@ async function handleHiveCommand(ctx) {
|
|
|
1171
1177
|
}
|
|
1172
1178
|
|
|
1173
1179
|
const startingMessage = await safeReply(ctx, `š Starting hive command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
|
|
1174
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation);
|
|
1180
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation, hiveTool);
|
|
1175
1181
|
}
|
|
1176
1182
|
|
|
1177
1183
|
bot.command(/^hive$/i, handleHiveCommand);
|
|
@@ -80,8 +80,8 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
|
|
|
80
80
|
if (iso) {
|
|
81
81
|
const sid = iso.runner.generateSessionId();
|
|
82
82
|
const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
|
|
83
|
-
if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid }, verbose);
|
|
84
|
-
return { ...r, output: r.output || `session: ${sid}` };
|
|
83
|
+
if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid, tool: item.tool || 'claude' }, verbose);
|
|
84
|
+
return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
|
|
85
85
|
}
|
|
86
86
|
return fallbackCallback(item);
|
|
87
87
|
};
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
|
|
19
19
|
export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
|
|
20
|
-
import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses,
|
|
20
|
+
import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
|
|
21
21
|
export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
|
|
22
22
|
import { QUEUE_CONFIG } from './queue-config.lib.mjs';
|
|
23
|
+
import { formatExecutingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
23
24
|
|
|
24
25
|
export const QueueItemStatus = {
|
|
25
26
|
QUEUED: 'queued',
|
|
@@ -133,6 +134,8 @@ export class SolveQueue {
|
|
|
133
134
|
this.verbose = options.verbose || false;
|
|
134
135
|
this.executeCallback = options.executeCallback || null;
|
|
135
136
|
this.messageUpdateCallback = options.messageUpdateCallback || null;
|
|
137
|
+
this.getRunningProcessesFn = options.getRunningProcesses || getRunningProcesses;
|
|
138
|
+
this.getRunningIsolatedSessionsFn = options.getRunningIsolatedSessions || getRunningIsolatedSessions;
|
|
136
139
|
|
|
137
140
|
// Separate queues per tool type - claude tasks never block agent tasks
|
|
138
141
|
// See: https://github.com/link-assistant/hive-mind/issues/1159
|
|
@@ -462,6 +465,46 @@ export class SolveQueue {
|
|
|
462
465
|
};
|
|
463
466
|
}
|
|
464
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Get external processing counts from both process scanning and tracked
|
|
470
|
+
* isolated sessions. The displayed/accounted value is the maximum of the two
|
|
471
|
+
* sources so screen-isolated sessions remain visible even when the AI CLI
|
|
472
|
+
* process is not directly observable, while regular non-isolated runs still
|
|
473
|
+
* use pgrep as before.
|
|
474
|
+
*
|
|
475
|
+
* @param {string[]} tools - Tool queues to count
|
|
476
|
+
* @returns {Promise<{byTool: Object, processByTool: Object, isolatedByTool: Object, total: number, isolatedTotal: number, processTotal: number}>}
|
|
477
|
+
*/
|
|
478
|
+
async getExternalProcessingSnapshot(tools = Object.keys(this.queues)) {
|
|
479
|
+
const uniqueTools = [...new Set(tools)];
|
|
480
|
+
const isolated = await this.getRunningIsolatedSessionsFn(this.verbose);
|
|
481
|
+
const isolatedByTool = isolated.byTool || {};
|
|
482
|
+
const processByTool = {};
|
|
483
|
+
const byTool = {};
|
|
484
|
+
|
|
485
|
+
await Promise.all(
|
|
486
|
+
uniqueTools.map(async tool => {
|
|
487
|
+
const result = await this.getRunningProcessesFn(tool, this.verbose);
|
|
488
|
+
const processCount = result?.count || 0;
|
|
489
|
+
const isolatedCount = isolatedByTool[tool] || 0;
|
|
490
|
+
processByTool[tool] = processCount;
|
|
491
|
+
byTool[tool] = Math.max(processCount, isolatedCount);
|
|
492
|
+
})
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const processTotal = Object.values(processByTool).reduce((sum, count) => sum + count, 0);
|
|
496
|
+
const isolatedTotal = isolated.count || Object.values(isolatedByTool).reduce((sum, count) => sum + count, 0);
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
byTool,
|
|
500
|
+
processByTool,
|
|
501
|
+
isolatedByTool,
|
|
502
|
+
total: Math.max(processTotal, isolatedTotal),
|
|
503
|
+
isolatedTotal,
|
|
504
|
+
processTotal,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
465
508
|
/**
|
|
466
509
|
* Check if a new command can start
|
|
467
510
|
*
|
|
@@ -505,16 +548,19 @@ export class SolveQueue {
|
|
|
505
548
|
}
|
|
506
549
|
}
|
|
507
550
|
|
|
508
|
-
// Check running
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
const
|
|
551
|
+
// Check running tool processes (this is a metric, not a blocking reason by itself).
|
|
552
|
+
// For screen-isolated sessions, use the maximum of `$ --status` executing
|
|
553
|
+
// counts and pgrep counts so detached sessions remain visible.
|
|
554
|
+
const externalProcessing = await this.getExternalProcessingSnapshot([...Object.keys(this.queues), tool]);
|
|
555
|
+
const claudeProcessCount = externalProcessing.byTool.claude || 0;
|
|
556
|
+
const codexProcessCount = externalProcessing.byTool.codex || 0;
|
|
557
|
+
const agentProcessCount = externalProcessing.byTool.agent || 0;
|
|
558
|
+
const hasRunningClaude = claudeProcessCount > 0;
|
|
559
|
+
const hasRunningCodex = codexProcessCount > 0;
|
|
514
560
|
|
|
515
561
|
// Calculate total processing count for system resources (all tools)
|
|
516
562
|
// System resources (RAM, CPU, disk) apply to all tools
|
|
517
|
-
const totalProcessing = this.processing.size +
|
|
563
|
+
const totalProcessing = this.processing.size + externalProcessing.total;
|
|
518
564
|
|
|
519
565
|
// Calculate Claude-specific processing count for Claude API limits
|
|
520
566
|
// Only counts Claude items in queue + external claude processes
|
|
@@ -572,10 +618,10 @@ export class SolveQueue {
|
|
|
572
618
|
// Add claude_running info at the END (not beginning) of reasons
|
|
573
619
|
// Since it's supplementary info, not the primary blocking reason
|
|
574
620
|
// See: https://github.com/link-assistant/hive-mind/issues/1078
|
|
575
|
-
reasons.push(formatWaitingReason('claude_running',
|
|
621
|
+
reasons.push(formatWaitingReason('claude_running', claudeProcessCount, 0) + ` (${claudeProcessCount} processes)`);
|
|
576
622
|
}
|
|
577
623
|
if (tool === 'codex' && hasRunningCodex && reasons.length > 0) {
|
|
578
|
-
reasons.push(formatWaitingReason('codex_running',
|
|
624
|
+
reasons.push(formatWaitingReason('codex_running', codexProcessCount, 0) + ` (${codexProcessCount} processes)`);
|
|
579
625
|
}
|
|
580
626
|
|
|
581
627
|
const canStart = reasons.length === 0 && !rejected;
|
|
@@ -595,8 +641,10 @@ export class SolveQueue {
|
|
|
595
641
|
reason: reasons.length > 0 ? reasons.join('\n') : undefined,
|
|
596
642
|
reasons,
|
|
597
643
|
oneAtATime,
|
|
598
|
-
claudeProcesses:
|
|
599
|
-
codexProcesses:
|
|
644
|
+
claudeProcesses: claudeProcessCount,
|
|
645
|
+
codexProcesses: codexProcessCount,
|
|
646
|
+
agentProcesses: agentProcessCount,
|
|
647
|
+
isolatedProcesses: externalProcessing.isolatedTotal,
|
|
600
648
|
totalProcessing,
|
|
601
649
|
claudeProcessingCount,
|
|
602
650
|
codexProcessingCount,
|
|
@@ -1075,7 +1123,7 @@ export class SolveQueue {
|
|
|
1075
1123
|
const result = await this.executeCallback(item);
|
|
1076
1124
|
|
|
1077
1125
|
// Extract session name from result
|
|
1078
|
-
let sessionName = 'unknown';
|
|
1126
|
+
let sessionName = result?.sessionId || 'unknown';
|
|
1079
1127
|
if (result && result.output) {
|
|
1080
1128
|
const sessionMatch = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/);
|
|
1081
1129
|
if (sessionMatch) sessionName = sessionMatch[1];
|
|
@@ -1098,7 +1146,12 @@ export class SolveQueue {
|
|
|
1098
1146
|
if (result.warning) {
|
|
1099
1147
|
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `ā ļø ${result.warning}`, { parse_mode: 'Markdown' });
|
|
1100
1148
|
} else if (result.success) {
|
|
1101
|
-
const response =
|
|
1149
|
+
const response = formatExecutingWorkSessionMessage({
|
|
1150
|
+
commandName: item.command || 'solve',
|
|
1151
|
+
sessionName,
|
|
1152
|
+
isolationBackend: result.isolationBackend,
|
|
1153
|
+
infoBlock: item.infoBlock,
|
|
1154
|
+
});
|
|
1102
1155
|
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
|
|
1103
1156
|
} else {
|
|
1104
1157
|
const response = `ā Error executing solve command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``;
|
|
@@ -1177,9 +1230,8 @@ export class SolveQueue {
|
|
|
1177
1230
|
* Format queue status for display in /limits command
|
|
1178
1231
|
* Shows per-tool queue breakdown with processing counts.
|
|
1179
1232
|
*
|
|
1180
|
-
* Processing count = actual
|
|
1181
|
-
*
|
|
1182
|
-
* work happens in the spawned system process (claude, agent, etc.).
|
|
1233
|
+
* Processing count = max(actual AI CLI processes via pgrep, tracked
|
|
1234
|
+
* `$ --status` executing screen-isolated sessions), not queue state.
|
|
1183
1235
|
*
|
|
1184
1236
|
* Output format:
|
|
1185
1237
|
* ```
|
|
@@ -1194,10 +1246,11 @@ export class SolveQueue {
|
|
|
1194
1246
|
*/
|
|
1195
1247
|
async formatStatus() {
|
|
1196
1248
|
// Always show per-tool breakdown for all known queues
|
|
1249
|
+
const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
|
|
1197
1250
|
let message = 'Queues\n';
|
|
1198
1251
|
for (const [tool, toolQueue] of Object.entries(this.queues)) {
|
|
1199
1252
|
const pending = toolQueue.length;
|
|
1200
|
-
const processing =
|
|
1253
|
+
const processing = externalProcessing.byTool[tool] || 0;
|
|
1201
1254
|
message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
|
|
1202
1255
|
}
|
|
1203
1256
|
|
|
@@ -1208,9 +1261,8 @@ export class SolveQueue {
|
|
|
1208
1261
|
* Format detailed queue status for Telegram message
|
|
1209
1262
|
* Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
|
|
1210
1263
|
*
|
|
1211
|
-
* Processing count = actual
|
|
1212
|
-
*
|
|
1213
|
-
* work happens in the spawned system process (claude, agent, etc.).
|
|
1264
|
+
* Processing count = max(actual AI CLI processes via pgrep, tracked
|
|
1265
|
+
* `$ --status` executing screen-isolated sessions), not queue state.
|
|
1214
1266
|
*
|
|
1215
1267
|
* Output format:
|
|
1216
1268
|
* ```
|
|
@@ -1231,16 +1283,17 @@ export class SolveQueue {
|
|
|
1231
1283
|
*/
|
|
1232
1284
|
async formatDetailedStatus() {
|
|
1233
1285
|
const stats = this.getStats();
|
|
1286
|
+
const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
|
|
1234
1287
|
|
|
1235
|
-
// Get actual
|
|
1236
|
-
//
|
|
1237
|
-
//
|
|
1288
|
+
// Get actual processing counts for each tool queue.
|
|
1289
|
+
// This combines pgrep with tracked isolation status so users see detached
|
|
1290
|
+
// screen-isolated work even when the direct AI CLI process count is lower.
|
|
1238
1291
|
let message = 'š *Solve Queue Status*\n\n';
|
|
1239
1292
|
|
|
1240
1293
|
// Show per-tool queue breakdown with items grouped by queue
|
|
1241
1294
|
for (const [tool, toolQueue] of Object.entries(this.queues)) {
|
|
1242
1295
|
const pending = toolQueue.length;
|
|
1243
|
-
const processing =
|
|
1296
|
+
const processing = externalProcessing.byTool[tool] || 0;
|
|
1244
1297
|
message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
|
|
1245
1298
|
|
|
1246
1299
|
// Show first 5 queued items for this tool
|
|
@@ -1308,7 +1361,7 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
|
|
|
1308
1361
|
const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
1309
1362
|
const session = match ? match[1] : null;
|
|
1310
1363
|
if (session) {
|
|
1311
|
-
trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve' });
|
|
1364
|
+
trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', tool: item.tool || 'claude' });
|
|
1312
1365
|
}
|
|
1313
1366
|
}
|
|
1314
1367
|
return result;
|
|
@@ -1316,23 +1369,21 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
|
|
|
1316
1369
|
}
|
|
1317
1370
|
|
|
1318
1371
|
/**
|
|
1319
|
-
* Get count of
|
|
1320
|
-
*
|
|
1321
|
-
* for more reliable task counting.
|
|
1372
|
+
* Get count of tracked isolated sessions that are still executing according
|
|
1373
|
+
* to `$ --status`. Queue display combines this with pgrep counts using max().
|
|
1322
1374
|
*
|
|
1323
1375
|
* @param {boolean} verbose - Whether to log verbose output
|
|
1324
|
-
* @returns {Promise<{count: number, sessions: string[]}>}
|
|
1376
|
+
* @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
|
|
1325
1377
|
*/
|
|
1326
1378
|
export async function getRunningIsolatedSessions(verbose = false) {
|
|
1327
1379
|
try {
|
|
1328
|
-
const {
|
|
1329
|
-
|
|
1330
|
-
return { count, sessions: [] };
|
|
1380
|
+
const { getRunningTrackedIsolationSessions } = await import('./session-monitor.lib.mjs');
|
|
1381
|
+
return await getRunningTrackedIsolationSessions(verbose);
|
|
1331
1382
|
} catch (error) {
|
|
1332
1383
|
if (verbose) {
|
|
1333
1384
|
console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
|
|
1334
1385
|
}
|
|
1335
|
-
return { count: 0, sessions: [] };
|
|
1386
|
+
return { count: 0, sessions: [], byTool: {} };
|
|
1336
1387
|
}
|
|
1337
1388
|
}
|
|
1338
1389
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const FAILURE_STATUSES = new Set(['failed', 'cancelled', 'canceled', 'error']);
|
|
2
|
+
|
|
3
|
+
function capitalizeCommandName(commandName) {
|
|
4
|
+
const normalized = commandName || 'solve';
|
|
5
|
+
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parseDateValue(value) {
|
|
9
|
+
if (!value) return null;
|
|
10
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
11
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeExitCode(value) {
|
|
15
|
+
if (value === null || value === undefined) return null;
|
|
16
|
+
const numeric = Number(value);
|
|
17
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSessionCompletionExitCode({ exitCode = null, statusResult = null } = {}) {
|
|
21
|
+
const explicitExitCode = normalizeExitCode(exitCode);
|
|
22
|
+
if (explicitExitCode !== null) return explicitExitCode;
|
|
23
|
+
|
|
24
|
+
const statusExitCode = normalizeExitCode(statusResult?.exitCode);
|
|
25
|
+
if (statusExitCode !== null) return statusExitCode;
|
|
26
|
+
|
|
27
|
+
const status = String(statusResult?.status || '').toLowerCase();
|
|
28
|
+
if (FAILURE_STATUSES.has(status)) return 1;
|
|
29
|
+
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatSessionDurationSeconds(seconds) {
|
|
34
|
+
const totalSeconds = Math.max(0, Math.round(Number(seconds) || 0));
|
|
35
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
36
|
+
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
|
37
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
38
|
+
const remainingSeconds = totalSeconds % 60;
|
|
39
|
+
const parts = [];
|
|
40
|
+
|
|
41
|
+
if (days > 0) parts.push(`${days}d`);
|
|
42
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
43
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
44
|
+
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
|
45
|
+
|
|
46
|
+
return parts.join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatExecutingWorkSessionMessage({ commandName = 'solve', sessionName = 'unknown', isolationBackend = null, infoBlock = '' } = {}) {
|
|
50
|
+
const isolationInfo = isolationBackend ? `\nš Isolation: \`${isolationBackend}\`` : '';
|
|
51
|
+
const details = infoBlock ? `\n\n${infoBlock}` : '';
|
|
52
|
+
return `ā³ ${capitalizeCommandName(commandName)} command executing...\n\nš Session: \`${sessionName}\`${isolationInfo}${details}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null } = {}) {
|
|
56
|
+
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
57
|
+
const failed = finalExitCode !== null && finalExitCode !== 0;
|
|
58
|
+
const statusEmoji = failed ? 'ā' : 'ā
';
|
|
59
|
+
const statusText = failed ? `Failed (exit code: ${finalExitCode})` : 'Completed';
|
|
60
|
+
const isolationInfo = sessionInfo?.isolationBackend ? `\nš Isolation: ${sessionInfo.isolationBackend}` : '';
|
|
61
|
+
const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
|
|
62
|
+
const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
|
|
63
|
+
const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
|
|
64
|
+
|
|
65
|
+
let message = `${statusEmoji} *Work Session ${statusText}*\n\n`;
|
|
66
|
+
message += `š Session: \`${sessionName || 'unknown'}\`\n`;
|
|
67
|
+
message += `ā±ļø Duration: ${formatSessionDurationSeconds(durationSeconds)}\n`;
|
|
68
|
+
message += `š URL: ${sessionInfo?.url || 'unknown'}${isolationInfo}\n\n`;
|
|
69
|
+
message += 'The work session has finished. You can now review the results.';
|
|
70
|
+
|
|
71
|
+
return message;
|
|
72
|
+
}
|