@link-assistant/hive-mind 2.5.2 → 2.5.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 +12 -0
- package/package.json +1 -1
- package/src/locales/en.lino +1 -0
- package/src/locales/hi.lino +1 -0
- package/src/locales/ru.lino +1 -0
- package/src/locales/zh.lino +1 -0
- package/src/session-monitor.lib.mjs +27 -0
- package/src/solve.interrupt.lib.mjs +17 -0
- package/src/telegram-solve-queue.helpers.lib.mjs +58 -0
- package/src/telegram-solve-queue.lib.mjs +47 -46
- package/src/telegram-start-stop-command.lib.mjs +24 -0
- package/src/work-session-formatting.lib.mjs +15 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.5.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2ebdb3d: Add dequeue-decision diagnostics to the Telegram solve queue so global FIFO ordering across the per-tool queues can be audited in production (issue #2051). The oldest startable task still wins the single, globally-paced startup slot; when an older task is skipped because it cannot start, the queue now logs a concise, deduplicated "FIFO queue-jump" line naming the older task and the exact reason it is blocked (Claude/Codex limits, RAM/CPU/disk, min-interval, or one-at-a-time), and records it on `stats.lastQueueJump`. Verbose mode additionally prints a per-tool head snapshot each cycle. No change to ordering, pacing, or the minimum start interval.
|
|
8
|
+
|
|
9
|
+
## 2.5.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- eb755b5: Recognize an operator-initiated `/stop` as "🛑 Work session stopped by user" instead of "killed — out of memory or forced kill (SIGKILL)" (issue #2052). The Telegram `/stop <uuid>` flow now records the stop via `markSessionStopRequested` before forwarding CTRL+C, so the resulting SIGTERM/SIGKILL exit (143/137) is reported as an intentional user stop. Adds `--verbose` interrupt timing traces (auto-commit vs log-upload) to make the `docker stop` grace-period race behind "no log uploaded on stop" measurable, plus a case study under `docs/case-studies/issue-2052`.
|
|
14
|
+
|
|
3
15
|
## 2.5.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/locales/en.lino
CHANGED
|
@@ -624,6 +624,7 @@ en
|
|
|
624
624
|
finished "Work session finished successfully"
|
|
625
625
|
failed "Work session failed (exit code: {{exitCode}})"
|
|
626
626
|
killed "Work session {{reason}}{{exitSuffix}}"
|
|
627
|
+
stopped "Work session stopped by user{{requestedBy}}{{exitSuffix}}"
|
|
627
628
|
duration
|
|
628
629
|
label "Duration"
|
|
629
630
|
session
|
package/src/locales/hi.lino
CHANGED
|
@@ -624,6 +624,7 @@ hi
|
|
|
624
624
|
finished "कार्य सत्र सफलतापूर्वक पूरा हुआ"
|
|
625
625
|
failed "कार्य सत्र विफल हुआ (exit code: {{exitCode}})"
|
|
626
626
|
killed "कार्य सत्र रोका गया: {{reason}}{{exitSuffix}}"
|
|
627
|
+
stopped "कार्य सत्र उपयोगकर्ता द्वारा रोका गया{{requestedBy}}{{exitSuffix}}"
|
|
627
628
|
duration
|
|
628
629
|
label "अवधि"
|
|
629
630
|
session
|
package/src/locales/ru.lino
CHANGED
|
@@ -624,6 +624,7 @@ ru
|
|
|
624
624
|
finished "Рабочий сеанс успешно завершен"
|
|
625
625
|
failed "Рабочий сеанс завершился с ошибкой (код выхода: {{exitCode}})"
|
|
626
626
|
killed "Рабочий сеанс остановлен: {{reason}}{{exitSuffix}}"
|
|
627
|
+
stopped "Рабочий сеанс остановлен пользователем{{requestedBy}}{{exitSuffix}}"
|
|
627
628
|
duration
|
|
628
629
|
label "Длительность"
|
|
629
630
|
session
|
package/src/locales/zh.lino
CHANGED
|
@@ -217,6 +217,33 @@ export function getTrackedSessionInfo(sessionName) {
|
|
|
217
217
|
return activeSessions.get(sessionName) || null;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Issue #2052: record that an operator explicitly requested a session stop
|
|
222
|
+
* (e.g. Telegram `/stop <uuid>`). The subsequent SIGTERM/SIGKILL exit (143/137,
|
|
223
|
+
* delivered by `docker stop`) is then reported as "🛑 Stopped by user" instead
|
|
224
|
+
* of the misleading "out of memory or forced kill (SIGKILL)". Matches by
|
|
225
|
+
* tracking key OR by the isolation `sessionId` UUID (which is how `/stop`
|
|
226
|
+
* addresses sessions).
|
|
227
|
+
* @param {string} sessionId - UUID or session name of the session being stopped
|
|
228
|
+
* @param {{requestedBy?: string|null, verbose?: boolean}} [opts]
|
|
229
|
+
* @returns {boolean} True when a tracked session was marked.
|
|
230
|
+
*/
|
|
231
|
+
export function markSessionStopRequested(sessionId, { requestedBy = null, verbose = false } = {}) {
|
|
232
|
+
if (!sessionId) return false;
|
|
233
|
+
const target = activeSessions.get(sessionId) || Array.from(activeSessions.values()).find(info => info?.sessionId === sessionId) || null;
|
|
234
|
+
if (!target) {
|
|
235
|
+
if (verbose) console.log(`[VERBOSE] markSessionStopRequested: no tracked session found for ${sessionId}`);
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
target.stopRequestedByUser = true;
|
|
239
|
+
if (requestedBy) target.stopRequestedBy = requestedBy;
|
|
240
|
+
const key = target.sessionId || sessionId;
|
|
241
|
+
persistSessionSnapshot(key, target);
|
|
242
|
+
if (verbose) console.log(`[VERBOSE] markSessionStopRequested: ${sessionId} marked stopped by user${requestedBy ? ` (${requestedBy})` : ''}`);
|
|
243
|
+
logEvent('session_stop_requested', { sessionName: key, sessionId, requestedBy: requestedBy || null });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
220
247
|
/**
|
|
221
248
|
* Stop tracking a session that was registered optimistically but never actually
|
|
222
249
|
* started (e.g. the start-command launch failed). Removes it from the in-memory
|
|
@@ -22,10 +22,24 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
|
|
|
22
22
|
const ctx = cleanupContext;
|
|
23
23
|
if (!ctx.tempDir || !ctx.argv) return;
|
|
24
24
|
|
|
25
|
+
// Issue #2052: the interrupt handler races the isolation backend's grace
|
|
26
|
+
// period — `docker stop` sends SIGTERM, waits ~10s, then SIGKILL. Auto-commit
|
|
27
|
+
// is fast, but attaching a multi-MB log to a Gist/PR can take several seconds
|
|
28
|
+
// and may be cut off by SIGKILL, which is exactly "no log uploaded on stop".
|
|
29
|
+
// These verbose timing traces make the race measurable on the next iteration
|
|
30
|
+
// (they are silent unless --verbose is set).
|
|
31
|
+
const verbose = ctx.argv.verbose || false;
|
|
32
|
+
const startedAt = Date.now();
|
|
33
|
+
const trace = async message => {
|
|
34
|
+
if (verbose) await log(`[interrupt] +${Date.now() - startedAt}ms ${message}`, { verbose: true });
|
|
35
|
+
};
|
|
36
|
+
await trace('handler entered');
|
|
37
|
+
|
|
25
38
|
await log('\n⚠️ Session interrupted by user (CTRL+C)');
|
|
26
39
|
|
|
27
40
|
// Always auto-commit uncommitted changes on CTRL+C to preserve work
|
|
28
41
|
if (ctx.branchName) {
|
|
42
|
+
await trace('auto-commit: start');
|
|
29
43
|
try {
|
|
30
44
|
await checkForUncommittedChanges(
|
|
31
45
|
ctx.tempDir,
|
|
@@ -37,6 +51,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
|
|
|
37
51
|
true, // always autoCommit on CTRL+C to preserve work
|
|
38
52
|
false // no autoRestart
|
|
39
53
|
);
|
|
54
|
+
await trace('auto-commit: done');
|
|
40
55
|
} catch (commitError) {
|
|
41
56
|
await log(`⚠️ Could not auto-commit changes on interrupt: ${commitError.message}`, {
|
|
42
57
|
level: 'warning',
|
|
@@ -47,6 +62,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
|
|
|
47
62
|
// Upload logs if --attach-logs is enabled and we have a PR
|
|
48
63
|
if (shouldAttachLogs && ctx.prNumber && ctx.owner && ctx.repo) {
|
|
49
64
|
await log('📎 Uploading interrupted session logs to Pull Request...');
|
|
65
|
+
await trace('log-upload: start');
|
|
50
66
|
try {
|
|
51
67
|
await attachLogToGitHub({
|
|
52
68
|
logFile: getLogFile(),
|
|
@@ -63,6 +79,7 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
|
|
|
63
79
|
requestedModel: ctx.argv.model,
|
|
64
80
|
tool: ctx.argv.tool || 'claude',
|
|
65
81
|
});
|
|
82
|
+
await trace('log-upload: done');
|
|
66
83
|
} catch (uploadError) {
|
|
67
84
|
await log(`⚠️ Could not upload logs on interrupt: ${uploadError.message}`, {
|
|
68
85
|
level: 'warning',
|
|
@@ -492,3 +492,61 @@ export function formatWaitingReason(metric, currentValue, threshold, options = {
|
|
|
492
492
|
return `${metric} threshold exceeded`;
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Report the dequeue decision across all tool queues for a SolveQueue.
|
|
498
|
+
*
|
|
499
|
+
* Provides the observability that issue #2051 needs to confirm whether a
|
|
500
|
+
* long-waiting task is blocked legitimately (limits/resources/pacing) or by an
|
|
501
|
+
* ordering defect:
|
|
502
|
+
* - In verbose mode: prints a per-tool head snapshot (age + startable +
|
|
503
|
+
* blocking reasons) and the selected item each cycle.
|
|
504
|
+
* - Always-on: emits a concise "FIFO queue-jump" line whenever the
|
|
505
|
+
* globally-oldest queued head is skipped in favor of a younger startable head,
|
|
506
|
+
* naming the older task and the exact reason it is blocked. Deduplicated by
|
|
507
|
+
* (task id + block reasons) so a persistently-blocked task is only reported
|
|
508
|
+
* when its situation changes.
|
|
509
|
+
*
|
|
510
|
+
* @param {Object} queue - SolveQueue-like instance (uses verbose, log, recordThrottle, stats).
|
|
511
|
+
* @param {Array<{tool: string, item: Object, ageMs: number, startable: boolean, blockReasons: string[]}>} headDiagnostics
|
|
512
|
+
* @param {{item: Object, tool: string}|undefined} selected
|
|
513
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2051
|
|
514
|
+
*/
|
|
515
|
+
export function reportDequeueDecision(queue, headDiagnostics, selected) {
|
|
516
|
+
if (!headDiagnostics || headDiagnostics.length === 0) return;
|
|
517
|
+
|
|
518
|
+
if (queue.verbose) {
|
|
519
|
+
queue.log('Dequeue decision (global FIFO across tool queues):');
|
|
520
|
+
for (const d of headDiagnostics) {
|
|
521
|
+
const detail = d.startable ? 'STARTABLE' : `blocked by [${d.blockReasons.join('; ') || 'unknown'}]`;
|
|
522
|
+
queue.log(` ${d.tool}: waited ${formatDuration(d.ageMs)} - ${detail}`);
|
|
523
|
+
}
|
|
524
|
+
queue.log(selected ? ` -> selected ${selected.tool} (${selected.item.url})` : ' -> nothing startable this cycle');
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!selected) return;
|
|
528
|
+
|
|
529
|
+
// Identify the globally-oldest queued head regardless of startability.
|
|
530
|
+
const oldest = headDiagnostics.reduce((a, b) => (a.item.createdAt <= b.item.createdAt ? a : b));
|
|
531
|
+
if (oldest.item.id === selected.item.id) return; // Strict FIFO honored - nothing to report.
|
|
532
|
+
|
|
533
|
+
const waitedMs = Date.now() - oldest.item.createdAt;
|
|
534
|
+
const blockedBy = oldest.blockReasons.join('; ') || 'unknown';
|
|
535
|
+
queue.recordThrottle('fifo_queue_jump');
|
|
536
|
+
queue.stats.lastQueueJump = {
|
|
537
|
+
skippedTool: oldest.tool,
|
|
538
|
+
skippedUrl: oldest.item.url,
|
|
539
|
+
waitedMs,
|
|
540
|
+
blockedBy: oldest.blockReasons,
|
|
541
|
+
startedTool: selected.tool,
|
|
542
|
+
startedUrl: selected.item.url,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Deduplicate the always-on notice so a persistently-blocked task does not
|
|
546
|
+
// spam the log on every poll; only report when the reason changes.
|
|
547
|
+
const signature = `${oldest.item.id}|${blockedBy}`;
|
|
548
|
+
if (queue._lastQueueJumpSignature !== signature) {
|
|
549
|
+
queue._lastQueueJumpSignature = signature;
|
|
550
|
+
console.log(`[solve_queue] FIFO queue-jump: ${selected.tool} task started ahead of an older ${oldest.tool} task waiting ${formatDuration(waitedMs)} (${oldest.item.url}) — older task blocked by: ${blockedBy}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
@@ -2,22 +2,17 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Telegram Solve Queue Library
|
|
4
4
|
*
|
|
5
|
-
* Producer/consumer queue for /solve commands in the Telegram bot.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Resource checking (RAM, CPU, disk)
|
|
10
|
-
* - API limit checking (Claude, GitHub)
|
|
11
|
-
* - Minimum interval between command starts
|
|
12
|
-
* - Running process detection
|
|
13
|
-
* - Status tracking: Queued -> Waiting -> Starting -> Started
|
|
5
|
+
* Producer/consumer queue for /solve commands in the Telegram bot. Implements
|
|
6
|
+
* resource-aware throttling (RAM, CPU, disk), API limit checking (Claude,
|
|
7
|
+
* GitHub), a minimum interval between command starts, running-process
|
|
8
|
+
* detection, and status tracking (Queued -> Waiting -> Starting -> Started).
|
|
14
9
|
*
|
|
15
10
|
* @see https://github.com/link-assistant/hive-mind/issues/1041
|
|
16
11
|
*/
|
|
17
12
|
|
|
18
13
|
import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
|
|
19
14
|
export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
|
|
20
|
-
import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems, groupQueueItemsByTool } from './telegram-solve-queue.helpers.lib.mjs';
|
|
15
|
+
import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems, groupQueueItemsByTool, reportDequeueDecision } from './telegram-solve-queue.helpers.lib.mjs';
|
|
21
16
|
export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
|
|
22
17
|
import { QUEUE_CONFIG } from './queue-config.lib.mjs';
|
|
23
18
|
import { reserveStartSlotForQueue } from './queue-start-reservation.lib.mjs';
|
|
@@ -190,8 +185,7 @@ export class SolveQueue {
|
|
|
190
185
|
qwen: null,
|
|
191
186
|
gemini: null,
|
|
192
187
|
};
|
|
193
|
-
// Legacy:
|
|
194
|
-
this.lastStartTime = null;
|
|
188
|
+
this.lastStartTime = null; // Legacy: global last-start timestamp
|
|
195
189
|
|
|
196
190
|
// Consumer task reference
|
|
197
191
|
this.consumerTask = null;
|
|
@@ -385,14 +379,12 @@ export class SolveQueue {
|
|
|
385
379
|
}
|
|
386
380
|
|
|
387
381
|
/**
|
|
388
|
-
* Find the next startable item across all tool queues.
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* Also immediately rejects all queued items when a 'reject' strategy threshold
|
|
395
|
-
* is exceeded, instead of leaving them waiting indefinitely.
|
|
382
|
+
* Find the next startable item across all tool queues. Each tool is checked
|
|
383
|
+
* independently so tool-specific limits do not block unrelated tools; issue
|
|
384
|
+
* #2015 adds a global startup interval, so even when multiple tools are
|
|
385
|
+
* startable this returns only the oldest startable item (global FIFO) to
|
|
386
|
+
* prevent burst launches. Queued items are rejected immediately when a
|
|
387
|
+
* 'reject' strategy threshold is exceeded rather than left waiting.
|
|
396
388
|
*
|
|
397
389
|
* @returns {Promise<Array<{item: SolveQueueItem, tool: string, index: number, check: Object}>>}
|
|
398
390
|
* @see https://github.com/link-assistant/hive-mind/issues/1159
|
|
@@ -400,6 +392,9 @@ export class SolveQueue {
|
|
|
400
392
|
*/
|
|
401
393
|
async findStartableItems() {
|
|
402
394
|
const startableItems = [];
|
|
395
|
+
// Per-tool head diagnostics: why each queue head is/isn't startable, so
|
|
396
|
+
// global FIFO ordering can be audited in production (issue #2051).
|
|
397
|
+
const headDiagnostics = [];
|
|
403
398
|
|
|
404
399
|
for (const [tool, toolQueue] of Object.entries(this.queues)) {
|
|
405
400
|
if (toolQueue.length === 0) continue;
|
|
@@ -415,23 +410,36 @@ export class SolveQueue {
|
|
|
415
410
|
continue;
|
|
416
411
|
}
|
|
417
412
|
|
|
413
|
+
const item = toolQueue[0];
|
|
414
|
+
if (!item) continue;
|
|
415
|
+
|
|
416
|
+
// Determine startability and capture the blocking reason(s) for diagnostics.
|
|
417
|
+
// For tool-specific one-at-a-time, only count that tool's processing items.
|
|
418
|
+
const toolProcessingCount = this.getProcessingCountByTool(tool);
|
|
419
|
+
let startable = false;
|
|
420
|
+
const blockReasons = Array.isArray(check.reasons) ? [...check.reasons] : [];
|
|
418
421
|
if (check.canStart) {
|
|
419
|
-
const item = toolQueue[0];
|
|
420
|
-
if (!item) continue;
|
|
421
|
-
// Also check one-at-a-time mode for this specific tool
|
|
422
|
-
// For tool-specific one-at-a-time, only count that tool's processing items
|
|
423
|
-
const toolProcessingCount = this.getProcessingCountByTool(tool);
|
|
424
422
|
if (check.oneAtATime && toolProcessingCount > 0) {
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
423
|
+
// One-at-a-time for this tool with a task already processing: skip it
|
|
424
|
+
// but don't block other tools.
|
|
425
|
+
blockReasons.push(`one-at-a-time: ${toolProcessingCount} ${tool} task(s) already processing`);
|
|
426
|
+
} else {
|
|
427
|
+
startable = true;
|
|
428
|
+
startableItems.push({ item, tool, index: 0, check });
|
|
428
429
|
}
|
|
429
|
-
startableItems.push({ item, tool, index: 0, check });
|
|
430
430
|
}
|
|
431
|
+
|
|
432
|
+
headDiagnostics.push({ tool, item, ageMs: Date.now() - item.createdAt, startable, blockReasons });
|
|
431
433
|
}
|
|
432
434
|
|
|
435
|
+
// Global FIFO: the oldest startable head wins the (globally paced) startup slot.
|
|
433
436
|
startableItems.sort((a, b) => a.item.createdAt - b.item.createdAt);
|
|
434
|
-
|
|
437
|
+
const selected = startableItems.slice(0, 1);
|
|
438
|
+
|
|
439
|
+
// Observe the dequeue decision (issue #2051): see reportDequeueDecision().
|
|
440
|
+
reportDequeueDecision(this, headDiagnostics, selected[0]);
|
|
441
|
+
|
|
442
|
+
return selected;
|
|
435
443
|
}
|
|
436
444
|
|
|
437
445
|
/**
|
|
@@ -555,23 +563,16 @@ export class SolveQueue {
|
|
|
555
563
|
}
|
|
556
564
|
|
|
557
565
|
/**
|
|
558
|
-
* Check if a new command can start
|
|
559
|
-
*
|
|
560
|
-
* Logic per issue #1061:
|
|
561
|
-
* 1. "Claude process is already running" is NOT a limit by itself - it's a metric
|
|
562
|
-
* 2. Commands can run in parallel as long as actual limits are not exceeded
|
|
563
|
-
* 3. When any limit >= threshold, allow exactly one claude command to pass
|
|
564
|
-
*
|
|
565
|
-
* Logic per issue #1159:
|
|
566
|
-
* - Different tools have different limits. Claude limits only apply to 'claude' tool.
|
|
567
|
-
* - Processing count for Claude limits only includes Claude items, not agent/codex/gemini/qwen items.
|
|
568
|
-
* - This allows non-Claude tasks to run in parallel when Claude limits are reached.
|
|
566
|
+
* Check if a new command can start.
|
|
569
567
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* -
|
|
573
|
-
*
|
|
574
|
-
*
|
|
568
|
+
* - #1061: a running Claude process is a metric, not a limit by itself;
|
|
569
|
+
* commands run in parallel while actual limits are not exceeded.
|
|
570
|
+
* - #1159: tools have independent limits — Claude limits (and Claude
|
|
571
|
+
* processing counts) apply only to the 'claude' tool, so non-Claude tasks
|
|
572
|
+
* run in parallel when Claude limits are reached.
|
|
573
|
+
* - #1253: every threshold supports a configurable strategy — 'reject'
|
|
574
|
+
* (reject without queueing), 'enqueue' (block until the metric drops), or
|
|
575
|
+
* 'dequeue-one-at-a-time' (allow one, block subsequent).
|
|
575
576
|
*
|
|
576
577
|
* @param {Object} options - Options for the check
|
|
577
578
|
* @param {string} options.tool - The tool being used ('claude', 'agent', 'codex', 'gemini', 'qwen', etc.)
|
|
@@ -285,6 +285,24 @@ export function registerStartStopCommands(bot, options) {
|
|
|
285
285
|
return mod.getTrackedSessionInfo(sessionId);
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
// Issue #2052: record that this stop was operator-initiated, so the eventual
|
|
289
|
+
// SIGTERM/SIGKILL exit (delivered by `docker stop`) is reported as
|
|
290
|
+
// "🛑 Stopped by user" instead of "out of memory or forced kill (SIGKILL)".
|
|
291
|
+
// Tolerant of a sync or async stub, and never lets a marking failure block
|
|
292
|
+
// the actual stop.
|
|
293
|
+
async function markSessionStopRequestedSafe(sessionId, requestedBy) {
|
|
294
|
+
try {
|
|
295
|
+
if (typeof options.markSessionStopRequested === 'function') {
|
|
296
|
+
return await options.markSessionStopRequested(sessionId, { requestedBy, verbose: VERBOSE });
|
|
297
|
+
}
|
|
298
|
+
const mod = await import('./session-monitor.lib.mjs');
|
|
299
|
+
return mod.markSessionStopRequested(sessionId, { requestedBy, verbose: VERBOSE });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('[ERROR] /stop: markSessionStopRequested failed:', error);
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
288
306
|
// Issue #1871: look a URL up in the session-monitor registry of running
|
|
289
307
|
// detached sessions. A /solve or /codex that started immediately (queue
|
|
290
308
|
// empty) is dispatched straight to an isolation session and removed from the
|
|
@@ -425,6 +443,12 @@ export function registerStartStopCommands(bot, options) {
|
|
|
425
443
|
reply_to_message_id: message.message_id,
|
|
426
444
|
});
|
|
427
445
|
|
|
446
|
+
// Issue #2052: mark the stop as user-initiated BEFORE forwarding CTRL+C, so
|
|
447
|
+
// even a fast SIGKILL race still finds the flag when the completion message
|
|
448
|
+
// is formatted.
|
|
449
|
+
const requestedBy = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || null;
|
|
450
|
+
await markSessionStopRequestedSafe(sessionId, requestedBy);
|
|
451
|
+
|
|
428
452
|
let result;
|
|
429
453
|
try {
|
|
430
454
|
result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
|
|
@@ -132,13 +132,25 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
132
132
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
133
133
|
const outcome = classifySessionOutcome({ exitCode: finalExitCode, status: statusResult?.status || null });
|
|
134
134
|
const { failed, killed, signal } = outcome;
|
|
135
|
-
const statusEmoji = failed ? '❌' : '✅';
|
|
136
135
|
const messageLocale = locale || sessionInfo?.locale || null;
|
|
137
136
|
// Issue #1927: a killed session (OOM/SIGKILL/SIGTERM) must never read as a
|
|
138
137
|
// success, and the signal/reason is surfaced explicitly so an operator can
|
|
139
138
|
// tell an out-of-memory kill apart from an ordinary non-zero exit.
|
|
139
|
+
// Issue #2052: when the operator explicitly requested a stop (e.g. Telegram
|
|
140
|
+
// `/stop <uuid>` → `docker stop` → SIGTERM then SIGKILL), the resulting signal
|
|
141
|
+
// exit (143/137) must NOT read as "out of memory or forced kill". A user stop
|
|
142
|
+
// is an orderly, intentional termination, so surface it as such regardless of
|
|
143
|
+
// which signal actually delivered the kill.
|
|
144
|
+
const stopRequestedByUser = Boolean(sessionInfo?.stopRequestedByUser);
|
|
145
|
+
let statusEmojiOverride = null;
|
|
140
146
|
let statusText;
|
|
141
|
-
if (killed) {
|
|
147
|
+
if (killed && stopRequestedByUser) {
|
|
148
|
+
const showCode = finalExitCode !== null && !(!signal && finalExitCode === 1);
|
|
149
|
+
const exitSuffix = showCode ? ` (exit code: ${finalExitCode})` : '';
|
|
150
|
+
const requestedBy = sessionInfo?.stopRequestedBy ? ` by ${sessionInfo.stopRequestedBy}` : '';
|
|
151
|
+
statusEmojiOverride = '🛑';
|
|
152
|
+
statusText = text(messageLocale, 'telegram.work_session_stopped', `Work session stopped by user${requestedBy}${exitSuffix}`, { requestedBy, exitCode: finalExitCode ?? '', signal: signal?.signal ?? '', exitSuffix });
|
|
153
|
+
} else if (killed) {
|
|
142
154
|
// A real signal exit is always >128; an exit code of exactly 1 on a
|
|
143
155
|
// status-only kill (process vanished, code unknown) is a synthesized failure
|
|
144
156
|
// sentinel, so suppress the misleading "(exit code: 1)" in that case.
|
|
@@ -164,6 +176,7 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
164
176
|
if (pullRequestUrl) resolvedInfoBlock = appendPullRequestLine(resolvedInfoBlock, pullRequestUrl, { locale: messageLocale });
|
|
165
177
|
const details = resolvedInfoBlock ? `\n\n${resolvedInfoBlock}` : '';
|
|
166
178
|
|
|
179
|
+
const statusEmoji = statusEmojiOverride || (failed ? '❌' : '✅');
|
|
167
180
|
let message = `${statusEmoji} *${statusText}*\n\n`;
|
|
168
181
|
message += `⏱️ ${durationLabel}: ${formatSessionDurationSeconds(durationSeconds)}\n`;
|
|
169
182
|
message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${isolationInfo}${details}`;
|