@link-assistant/hive-mind 2.1.4 → 2.1.6
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/README.hi.md +20 -0
- package/README.md +19 -0
- package/README.ru.md +20 -0
- package/README.zh.md +19 -0
- package/package.json +1 -1
- package/src/config.lib.mjs +3 -2
- package/src/github-merge-targets.lib.mjs +205 -0
- package/src/isolation-runner.lib.mjs +13 -1
- package/src/limits.lib.mjs +2 -2
- 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/queue-config.lib.mjs +6 -3
- package/src/session-monitor.lib.mjs +70 -1
- package/src/telegram-merge-command.lib.mjs +93 -15
- package/src/telegram-merge-queue.lib.mjs +108 -24
- package/src/telegram-merge-wait.lib.mjs +75 -0
- package/src/telegram-solve-queue.lib.mjs +11 -9
- package/src/work-session-formatting.lib.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.1.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- efcbcce: Handle Docker `oomKilled` status markers as terminal Telegram work-session failures, delay Docker backend-gone killed notifications long enough for start-command to publish a real terminal status or log footer, pace queued task startups at a minimum 10-minute interval, and cap system resource cache freshness at 1 minute.
|
|
8
|
+
|
|
9
|
+
## 2.1.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 26b07f2: Extend Telegram `/merge` to accept repository, issue, and pull request targets, including reply-based GitHub link extraction, and wait for unfinished PRs to become mergeable before merging.
|
|
14
|
+
|
|
3
15
|
## 2.1.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.hi.md
CHANGED
|
@@ -511,6 +511,26 @@ Examples:
|
|
|
511
511
|
/hive https://github.com/microsoft --all-issues --concurrency 3
|
|
512
512
|
```
|
|
513
513
|
|
|
514
|
+
#### `/merge` - तैयार Pull Requests merge करें
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
|
|
518
|
+
|
|
519
|
+
Examples:
|
|
520
|
+
/merge https://github.com/owner/repo
|
|
521
|
+
/merge https://github.com/owner/repo/issues/123
|
|
522
|
+
/merge https://github.com/owner/repo/pull/456
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Repository targets `ready` label वाले PRs को क्रम से process करते हैं। Issue और
|
|
526
|
+
pull request targets केवल linked या selected PR process करते हैं। आप किसी ऐसे
|
|
527
|
+
message पर `/merge` से reply भी कर सकते हैं जिसमें एक GitHub repository, issue,
|
|
528
|
+
या pull request link हो, जैसे कोई पुराना `/codex ...issues/123` command.
|
|
529
|
+
|
|
530
|
+
अगर target PR अभी finished नहीं है, तो `/merge` merge करने से पहले उसके
|
|
531
|
+
mergeable होने तक wait करता है। Merge-conflict skips अभी भी `--auto-resolve` के
|
|
532
|
+
साथ काम करते हैं।
|
|
533
|
+
|
|
514
534
|
#### `/limits` - उपयोग सीमाएँ दिखाएँ
|
|
515
535
|
|
|
516
536
|
```
|
package/README.md
CHANGED
|
@@ -532,6 +532,25 @@ Examples:
|
|
|
532
532
|
/hive https://github.com/microsoft --all-issues --concurrency 3
|
|
533
533
|
```
|
|
534
534
|
|
|
535
|
+
#### `/merge` - Merge Ready Pull Requests
|
|
536
|
+
|
|
537
|
+
```
|
|
538
|
+
/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
|
|
539
|
+
|
|
540
|
+
Examples:
|
|
541
|
+
/merge https://github.com/owner/repo
|
|
542
|
+
/merge https://github.com/owner/repo/issues/123
|
|
543
|
+
/merge https://github.com/owner/repo/pull/456
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Repository targets process PRs with the `ready` label sequentially. Issue and
|
|
547
|
+
pull request targets process only the linked or selected PR. You can also reply
|
|
548
|
+
with `/merge` to a message containing one GitHub repository, issue, or pull
|
|
549
|
+
request link, such as a previous `/codex ...issues/123` command.
|
|
550
|
+
|
|
551
|
+
If a target PR is not finished yet, `/merge` waits for it to become mergeable
|
|
552
|
+
before merging. Merge-conflict skips still work with `--auto-resolve`.
|
|
553
|
+
|
|
535
554
|
#### `/limits` - Show Usage Limits
|
|
536
555
|
|
|
537
556
|
```
|
package/README.ru.md
CHANGED
|
@@ -512,6 +512,26 @@ Examples:
|
|
|
512
512
|
/hive https://github.com/microsoft --all-issues --concurrency 3
|
|
513
513
|
```
|
|
514
514
|
|
|
515
|
+
#### `/merge` — Слияние готовых Pull Request
|
|
516
|
+
|
|
517
|
+
```
|
|
518
|
+
/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
|
|
519
|
+
|
|
520
|
+
Examples:
|
|
521
|
+
/merge https://github.com/owner/repo
|
|
522
|
+
/merge https://github.com/owner/repo/issues/123
|
|
523
|
+
/merge https://github.com/owner/repo/pull/456
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Цель-репозиторий обрабатывает PR с меткой `ready` последовательно. Цели issue и
|
|
527
|
+
pull request обрабатывают только связанный или выбранный PR. Также можно ответить
|
|
528
|
+
командой `/merge` на сообщение с одной ссылкой на GitHub repository, issue или
|
|
529
|
+
pull request, например на предыдущую команду `/codex ...issues/123`.
|
|
530
|
+
|
|
531
|
+
Если целевой PR ещё не завершён, `/merge` ждёт, пока он станет mergeable, и затем
|
|
532
|
+
выполняет слияние. Пропуск конфликтов слияния по-прежнему работает с
|
|
533
|
+
`--auto-resolve`.
|
|
534
|
+
|
|
515
535
|
#### `/limits` — Показать лимиты использования
|
|
516
536
|
|
|
517
537
|
```
|
package/README.zh.md
CHANGED
|
@@ -508,6 +508,25 @@ Examples:
|
|
|
508
508
|
/hive https://github.com/microsoft --all-issues --concurrency 3
|
|
509
509
|
```
|
|
510
510
|
|
|
511
|
+
#### `/merge` - 合并就绪的 Pull Request
|
|
512
|
+
|
|
513
|
+
```
|
|
514
|
+
/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
|
|
515
|
+
|
|
516
|
+
Examples:
|
|
517
|
+
/merge https://github.com/owner/repo
|
|
518
|
+
/merge https://github.com/owner/repo/issues/123
|
|
519
|
+
/merge https://github.com/owner/repo/pull/456
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
仓库目标会按顺序处理带有 `ready` 标签的 PR。Issue 和 Pull Request
|
|
523
|
+
目标只处理关联或指定的 PR。你也可以回复一条包含单个 GitHub
|
|
524
|
+
仓库、Issue 或 Pull Request 链接的消息并发送 `/merge`,例如之前的
|
|
525
|
+
`/codex ...issues/123` 命令。
|
|
526
|
+
|
|
527
|
+
如果目标 PR 尚未完成,`/merge` 会等待它变为可合并后再合并。合并冲突跳过仍可配合
|
|
528
|
+
`--auto-resolve` 使用。
|
|
529
|
+
|
|
511
530
|
#### `/limits` - 显示用量限制
|
|
512
531
|
|
|
513
532
|
```
|
package/package.json
CHANGED
package/src/config.lib.mjs
CHANGED
|
@@ -687,8 +687,9 @@ export const cacheTtl = {
|
|
|
687
687
|
// because users still hit "Resets in 3m xs" rate-limit responses. The API
|
|
688
688
|
// returns null values or 429 when called too frequently.
|
|
689
689
|
usageApi: parseIntWithDefault('HIVE_MIND_USAGE_API_CACHE_TTL_MS', 13 * 60 * 1000), // 13 minutes
|
|
690
|
-
// System metrics cache TTL (RAM, CPU, disk)
|
|
691
|
-
|
|
690
|
+
// System metrics cache TTL (RAM, CPU, disk). Issue #2015 caps this at
|
|
691
|
+
// 1 minute so queue decisions do not use stale host pressure data.
|
|
692
|
+
system: Math.min(parseIntWithDefault('HIVE_MIND_SYSTEM_CACHE_TTL_MS', 60 * 1000), 60 * 1000), // max 1 minute
|
|
692
693
|
};
|
|
693
694
|
|
|
694
695
|
// File and path configurations
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GitHub merge target helpers for `/merge`.
|
|
4
|
+
*
|
|
5
|
+
* Repository-wide merge queues still use github-merge.lib.mjs. This module
|
|
6
|
+
* handles the narrower targets introduced in issue #2013: a single PR URL, an
|
|
7
|
+
* issue URL with linked PRs, or a GitHub URL found in a replied message.
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2013
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import { exec as execCallback } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
import { githubLimits } from './config.lib.mjs';
|
|
16
|
+
import { parseGitHubUrl } from './github.lib.mjs';
|
|
17
|
+
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
18
|
+
import { getLinkedPRsFromTimeline } from './github-merge-ready-sync.lib.mjs';
|
|
19
|
+
|
|
20
|
+
const execRaw = promisify(execCallback);
|
|
21
|
+
const exec = (cmd, opts = {}) =>
|
|
22
|
+
ghWithRateLimitRetry(() => execRaw(cmd, { maxBuffer: githubLimits.bufferMaxSize, ...opts }), {
|
|
23
|
+
label: `gh exec (${cmd.split(/\s+/).slice(0, 3).join(' ')})`,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SUPPORTED_REPOSITORY_TYPES = new Set(['repo', 'issues_list', 'pulls_list']);
|
|
27
|
+
const SUPPORTED_TARGET_TYPES = new Set([...SUPPORTED_REPOSITORY_TYPES, 'issue', 'pull']);
|
|
28
|
+
|
|
29
|
+
function buildIssueUrl(owner, repo, issueNumber) {
|
|
30
|
+
return `https://github.com/${owner}/${repo}/issues/${issueNumber}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildPullUrl(owner, repo, prNumber) {
|
|
34
|
+
return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse a `/merge` target URL.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} url
|
|
41
|
+
* @returns {{valid: boolean, mode?: 'repository'|'issue'|'pull', owner: string|null, repo: string|null, issueNumber?: number, prNumber?: number, url?: string, error: string|null}}
|
|
42
|
+
*/
|
|
43
|
+
export function parseMergeTargetUrl(url) {
|
|
44
|
+
const parsed = parseGitHubUrl(url);
|
|
45
|
+
|
|
46
|
+
if (!parsed.valid) {
|
|
47
|
+
return { valid: false, owner: null, repo: null, error: parsed.error };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (SUPPORTED_REPOSITORY_TYPES.has(parsed.type)) {
|
|
51
|
+
return {
|
|
52
|
+
valid: true,
|
|
53
|
+
mode: 'repository',
|
|
54
|
+
owner: parsed.owner,
|
|
55
|
+
repo: parsed.repo,
|
|
56
|
+
url: `https://github.com/${parsed.owner}/${parsed.repo}`,
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (parsed.type === 'issue') {
|
|
62
|
+
return {
|
|
63
|
+
valid: true,
|
|
64
|
+
mode: 'issue',
|
|
65
|
+
owner: parsed.owner,
|
|
66
|
+
repo: parsed.repo,
|
|
67
|
+
issueNumber: parsed.number,
|
|
68
|
+
url: buildIssueUrl(parsed.owner, parsed.repo, parsed.number),
|
|
69
|
+
error: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (parsed.type === 'pull') {
|
|
74
|
+
return {
|
|
75
|
+
valid: true,
|
|
76
|
+
mode: 'pull',
|
|
77
|
+
owner: parsed.owner,
|
|
78
|
+
repo: parsed.repo,
|
|
79
|
+
prNumber: parsed.number,
|
|
80
|
+
url: buildPullUrl(parsed.owner, parsed.repo, parsed.number),
|
|
81
|
+
error: null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (parsed.type === 'user' || parsed.type === 'organization') {
|
|
86
|
+
return {
|
|
87
|
+
valid: false,
|
|
88
|
+
owner: parsed.owner,
|
|
89
|
+
repo: null,
|
|
90
|
+
error: 'URL points to a user/organization. Please provide a repository, issue, or pull request URL.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
valid: false,
|
|
96
|
+
owner: parsed.owner || null,
|
|
97
|
+
repo: parsed.repo || null,
|
|
98
|
+
error: `URL type '${parsed.type}' is not supported for /merge. Please provide a repository, issue, or pull request URL.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function trimCandidateUrl(candidate) {
|
|
103
|
+
return String(candidate || '')
|
|
104
|
+
.trim()
|
|
105
|
+
.replace(/^[<([{'"]+/, '')
|
|
106
|
+
.replace(/[>)\]}'".,;:!?]+$/, '');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract a single `/merge` target URL from arbitrary text, such as a replied
|
|
111
|
+
* `/codex https://github.com/owner/repo/issues/123 --think max` message.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {{valid: boolean, url: string|null, target: Object|null, error: string|null}}
|
|
115
|
+
*/
|
|
116
|
+
export function extractMergeTargetUrlFromText(text) {
|
|
117
|
+
if (!text || typeof text !== 'string') {
|
|
118
|
+
return { valid: false, url: null, target: null, error: 'No text to inspect for GitHub links' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pattern = /(?:https?:\/\/)?github\.com\/[^\s<>)\]]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:issues|pull)\/\d+/g;
|
|
122
|
+
const matches = text.match(pattern) || [];
|
|
123
|
+
const targetsByUrl = new Map();
|
|
124
|
+
|
|
125
|
+
for (const match of matches) {
|
|
126
|
+
const candidate = trimCandidateUrl(match);
|
|
127
|
+
const target = parseMergeTargetUrl(candidate);
|
|
128
|
+
if (target.valid && SUPPORTED_TARGET_TYPES.has(target.mode === 'repository' ? 'repo' : target.mode)) {
|
|
129
|
+
targetsByUrl.set(target.url, target);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const targets = [...targetsByUrl.values()];
|
|
134
|
+
if (targets.length === 0) {
|
|
135
|
+
return { valid: false, url: null, target: null, error: 'No GitHub repository, issue, or pull request link found' };
|
|
136
|
+
}
|
|
137
|
+
if (targets.length > 1) {
|
|
138
|
+
return { valid: false, url: null, target: null, error: `Expected one GitHub merge target, found ${targets.length}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const [target] = targets;
|
|
142
|
+
return { valid: true, url: target.url, target, error: null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch a PR in the shape expected by MergeQueueItem.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} owner
|
|
149
|
+
* @param {string} repo
|
|
150
|
+
* @param {number} prNumber
|
|
151
|
+
* @param {Object|null} issue
|
|
152
|
+
* @param {boolean} verbose
|
|
153
|
+
* @returns {Promise<{pr: Object, issue: Object|null, sortDate: Date}>}
|
|
154
|
+
*/
|
|
155
|
+
export async function fetchMergeTargetPullRequest(owner, repo, prNumber, issue = null, verbose = false) {
|
|
156
|
+
const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus,isDraft,state`);
|
|
157
|
+
const pr = JSON.parse(stdout.trim());
|
|
158
|
+
if (verbose) {
|
|
159
|
+
console.log(`[VERBOSE] /merge: Loaded target PR #${pr.number} from ${owner}/${repo}`);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
pr,
|
|
163
|
+
issue,
|
|
164
|
+
sortDate: new Date(pr.createdAt),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a non-repository merge target into queue item data.
|
|
170
|
+
*
|
|
171
|
+
* @param {{mode: string, owner: string, repo: string, issueNumber?: number, prNumber?: number, url?: string}} target
|
|
172
|
+
* @param {boolean} verbose
|
|
173
|
+
* @returns {Promise<Array<{pr: Object, issue: Object|null, sortDate: Date}>|null>}
|
|
174
|
+
*/
|
|
175
|
+
export async function resolveMergeTargetItems(target, verbose = false) {
|
|
176
|
+
if (!target || target.mode === 'repository') {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (target.mode === 'pull') {
|
|
181
|
+
return [await fetchMergeTargetPullRequest(target.owner, target.repo, target.prNumber, null, verbose)];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (target.mode === 'issue') {
|
|
185
|
+
const issue = {
|
|
186
|
+
number: target.issueNumber,
|
|
187
|
+
url: target.url || buildIssueUrl(target.owner, target.repo, target.issueNumber),
|
|
188
|
+
};
|
|
189
|
+
const linkedPRs = await getLinkedPRsFromTimeline(target.owner, target.repo, target.issueNumber, verbose);
|
|
190
|
+
const items = [];
|
|
191
|
+
for (const linkedPR of linkedPRs) {
|
|
192
|
+
items.push(await fetchMergeTargetPullRequest(target.owner, target.repo, linkedPR.number, issue, verbose));
|
|
193
|
+
}
|
|
194
|
+
return items.sort((a, b) => a.sortDate - b.sortDate);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(`Unsupported merge target mode: ${target.mode}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default {
|
|
201
|
+
parseMergeTargetUrl,
|
|
202
|
+
extractMergeTargetUrlFromText,
|
|
203
|
+
fetchMergeTargetPullRequest,
|
|
204
|
+
resolveMergeTargetItems,
|
|
205
|
+
};
|
|
@@ -321,9 +321,18 @@ export function generateSessionId() {
|
|
|
321
321
|
export function parseSessionStatusOutput(output) {
|
|
322
322
|
const raw = (output || '').trim();
|
|
323
323
|
if (!raw) {
|
|
324
|
-
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
|
|
324
|
+
return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
const normalizeBooleanField = value => {
|
|
328
|
+
if (typeof value === 'boolean') return value;
|
|
329
|
+
if (value === null || value === undefined) return null;
|
|
330
|
+
const normalized = String(value).trim().toLowerCase();
|
|
331
|
+
if (['true', '1', 'yes'].includes(normalized)) return true;
|
|
332
|
+
if (['false', '0', 'no'].includes(normalized)) return false;
|
|
333
|
+
return null;
|
|
334
|
+
};
|
|
335
|
+
|
|
327
336
|
try {
|
|
328
337
|
const parsed = JSON.parse(raw);
|
|
329
338
|
const data = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
@@ -350,6 +359,7 @@ export function parseSessionStatusOutput(output) {
|
|
|
350
359
|
workingDirectory: data?.workingDirectory || null,
|
|
351
360
|
sessionName: data?.sessionName || data?.options?.sessionName || null,
|
|
352
361
|
processIds,
|
|
362
|
+
oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
|
|
353
363
|
raw,
|
|
354
364
|
};
|
|
355
365
|
} catch {
|
|
@@ -365,6 +375,7 @@ export function parseSessionStatusOutput(output) {
|
|
|
365
375
|
const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
|
|
366
376
|
return match ? match[1].trim() : null;
|
|
367
377
|
};
|
|
378
|
+
const readBooleanField = name => normalizeBooleanField(readField(name));
|
|
368
379
|
|
|
369
380
|
const status = readField('status')?.toLowerCase() || null;
|
|
370
381
|
const exitCodeText = readField('exitCode');
|
|
@@ -396,6 +407,7 @@ export function parseSessionStatusOutput(output) {
|
|
|
396
407
|
workingDirectory: readField('workingDirectory'),
|
|
397
408
|
sessionName: readField('sessionName'),
|
|
398
409
|
processIds,
|
|
410
|
+
oomKilled: readBooleanField('oomKilled'),
|
|
399
411
|
raw,
|
|
400
412
|
};
|
|
401
413
|
}
|
package/src/limits.lib.mjs
CHANGED
|
@@ -1239,12 +1239,12 @@ export function formatCodexLimitsSection(codexLimits, codexError = null, options
|
|
|
1239
1239
|
* Configurable via environment variables:
|
|
1240
1240
|
* - HIVE_MIND_API_CACHE_TTL_MS: General API cache TTL (default: 180000 = 3 minutes)
|
|
1241
1241
|
* - HIVE_MIND_USAGE_API_CACHE_TTL_MS: Claude Usage API cache TTL (default: 780000 = 13 minutes)
|
|
1242
|
-
* - HIVE_MIND_SYSTEM_CACHE_TTL_MS: System metrics cache TTL (default:
|
|
1242
|
+
* - HIVE_MIND_SYSTEM_CACHE_TTL_MS: System metrics cache TTL (default: 60000 = 1 minute, capped at 1 minute)
|
|
1243
1243
|
*/
|
|
1244
1244
|
export const CACHE_TTL = {
|
|
1245
1245
|
API: cacheTtl.api, // 3 minutes for regular API calls (GitHub)
|
|
1246
1246
|
USAGE_API: cacheTtl.usageApi, // 13 minutes for Claude Usage API (rate limited)
|
|
1247
|
-
SYSTEM: cacheTtl.system, //
|
|
1247
|
+
SYSTEM: cacheTtl.system, // max 1 minute for system metrics (RAM, CPU, disk)
|
|
1248
1248
|
};
|
|
1249
1249
|
|
|
1250
1250
|
/**
|
package/src/locales/en.lino
CHANGED
package/src/locales/hi.lino
CHANGED
package/src/locales/ru.lino
CHANGED
|
@@ -623,6 +623,7 @@ ru
|
|
|
623
623
|
executing "⏳ Выполняется..."
|
|
624
624
|
finished "Рабочий сеанс успешно завершен"
|
|
625
625
|
failed "Рабочий сеанс завершился с ошибкой (код выхода: {{exitCode}})"
|
|
626
|
+
killed "Рабочий сеанс остановлен: {{reason}}{{exitSuffix}}"
|
|
626
627
|
duration
|
|
627
628
|
label "Длительность"
|
|
628
629
|
session
|
package/src/locales/zh.lino
CHANGED
package/src/queue-config.lib.mjs
CHANGED
|
@@ -192,6 +192,8 @@ const parseIntWithDefault = (envVar, defaultValue) => {
|
|
|
192
192
|
return isNaN(parsed) ? defaultValue : parsed;
|
|
193
193
|
};
|
|
194
194
|
|
|
195
|
+
const MINIMUM_START_INTERVAL_MS = 10 * 60 * 1000;
|
|
196
|
+
|
|
195
197
|
// Parse links notation config from environment variable (if provided)
|
|
196
198
|
const linoConfig = parseQueueConfig(getenv('HIVE_MIND_QUEUE_CONFIG', ''));
|
|
197
199
|
|
|
@@ -271,9 +273,10 @@ export const QUEUE_CONFIG = {
|
|
|
271
273
|
GITHUB_API_THRESHOLD: getThresholdConfig('githubApi', 'HIVE_MIND_GITHUB_API_THRESHOLD', 'HIVE_MIND_GITHUB_API_STRATEGY', 0.5, 'enqueue').value,
|
|
272
274
|
|
|
273
275
|
// Timing
|
|
274
|
-
// MIN_START_INTERVAL_MS:
|
|
275
|
-
//
|
|
276
|
-
|
|
276
|
+
// MIN_START_INTERVAL_MS: Minimum global spacing between task startups.
|
|
277
|
+
// Issue #2015: after resource thresholds clear, starting a backlog in a burst
|
|
278
|
+
// can kill the next batch before host metrics have time to settle.
|
|
279
|
+
MIN_START_INTERVAL_MS: Math.max(parseIntWithDefault('HIVE_MIND_MIN_START_INTERVAL_MS', MINIMUM_START_INTERVAL_MS), MINIMUM_START_INTERVAL_MS), // at least 10 minutes between starts
|
|
277
280
|
CONSUMER_POLL_INTERVAL_MS: parseIntWithDefault('HIVE_MIND_CONSUMER_POLL_INTERVAL_MS', 60000), // 1 minute between queue checks
|
|
278
281
|
MESSAGE_UPDATE_INTERVAL_MS: parseIntWithDefault('HIVE_MIND_MESSAGE_UPDATE_INTERVAL_MS', 60000), // 1 minute between status message updates
|
|
279
282
|
|
|
@@ -24,7 +24,7 @@ import fs from 'fs/promises';
|
|
|
24
24
|
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
|
-
import { classifyExitStatus } from './session-status.lib.mjs';
|
|
27
|
+
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { readLastSessionIdFromLog, findLatestSessionLogId, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
30
30
|
|
|
@@ -504,6 +504,8 @@ function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false)
|
|
|
504
504
|
* this, because a written "Exit Code:" footer is proof the command terminated.
|
|
505
505
|
*/
|
|
506
506
|
export const STALE_EXECUTING_MIN_AGE_MS = 90 * 1000;
|
|
507
|
+
export const DOCKER_BACKEND_GONE_GRACE_MS = 2 * 60 * 1000;
|
|
508
|
+
const DOCKER_BACKEND_GONE_FIRST_SEEN_FIELD = 'dockerBackendGoneFirstSeenAt';
|
|
507
509
|
|
|
508
510
|
function sessionStartMs(sessionInfo) {
|
|
509
511
|
const start = sessionInfo?.startTime;
|
|
@@ -513,6 +515,23 @@ function sessionStartMs(sessionInfo) {
|
|
|
513
515
|
return Number.isFinite(ms) ? ms : null;
|
|
514
516
|
}
|
|
515
517
|
|
|
518
|
+
function isDockerIsolation(sessionInfo, statusResult) {
|
|
519
|
+
return sessionInfo?.isolationBackend === 'docker' || statusResult?.isolation === 'docker';
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function getDockerBackendGoneFirstSeenMs(sessionInfo) {
|
|
523
|
+
const raw = sessionInfo?.[DOCKER_BACKEND_GONE_FIRST_SEEN_FIELD];
|
|
524
|
+
if (!raw) return null;
|
|
525
|
+
const ms = new Date(raw).getTime();
|
|
526
|
+
return Number.isFinite(ms) ? ms : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function clearDockerBackendGoneMarker(sessionName, sessionInfo) {
|
|
530
|
+
if (!sessionInfo?.[DOCKER_BACKEND_GONE_FIRST_SEEN_FIELD]) return;
|
|
531
|
+
delete sessionInfo[DOCKER_BACKEND_GONE_FIRST_SEEN_FIELD];
|
|
532
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
533
|
+
}
|
|
534
|
+
|
|
516
535
|
/**
|
|
517
536
|
* Cross-check whether a session that `$ --status` still reports as `executing`
|
|
518
537
|
* has actually terminated. Issue #1927: start-command's status can get stuck on
|
|
@@ -552,13 +571,60 @@ async function resolveStaleExecutingState(sessionName, sessionInfo, statusResult
|
|
|
552
571
|
// Only `false` (definitively gone) counts as killed; `null` (unknown backend)
|
|
553
572
|
// is treated as "no signal" so we don't kill on an indeterminate probe.
|
|
554
573
|
if (alive === false) {
|
|
574
|
+
if (isDockerIsolation(sessionInfo, statusResult)) {
|
|
575
|
+
const nowMs = Date.now();
|
|
576
|
+
const firstSeenMs = getDockerBackendGoneFirstSeenMs(sessionInfo);
|
|
577
|
+
if (firstSeenMs === null) {
|
|
578
|
+
sessionInfo[DOCKER_BACKEND_GONE_FIRST_SEEN_FIELD] = new Date(nowMs).toISOString();
|
|
579
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
580
|
+
if (verbose) {
|
|
581
|
+
console.log(`[VERBOSE] Session ${sessionName} docker backend is gone but no terminal status/footer is available yet; deferring killed classification for ${DOCKER_BACKEND_GONE_GRACE_MS}ms`);
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
if (nowMs - firstSeenMs < DOCKER_BACKEND_GONE_GRACE_MS) {
|
|
586
|
+
if (verbose) {
|
|
587
|
+
console.log(`[VERBOSE] Session ${sessionName} docker backend is still gone; waiting for terminal status/footer before reporting killed`);
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
555
592
|
return { exitCode: null, status: 'killed', reason: 'backend-gone' };
|
|
556
593
|
}
|
|
594
|
+
if (alive === true) {
|
|
595
|
+
clearDockerBackendGoneMarker(sessionName, sessionInfo);
|
|
596
|
+
}
|
|
557
597
|
}
|
|
558
598
|
|
|
559
599
|
return null;
|
|
560
600
|
}
|
|
561
601
|
|
|
602
|
+
function resolveOomKilledState(sessionName, sessionInfo, statusResult, { verbose, runner, exitFromLog }) {
|
|
603
|
+
const logPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
604
|
+
let footer = null;
|
|
605
|
+
if (logPath) {
|
|
606
|
+
const readFooter = exitFromLog || runner.readSessionExitFromLog;
|
|
607
|
+
footer = readFooter ? readFooter(logPath, { verbose }) : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const statusExitCode = normalizeExitCode(statusResult?.exitCode);
|
|
611
|
+
const footerExitCode = footer?.finished ? normalizeExitCode(footer.exitCode) : null;
|
|
612
|
+
let exitCode = 137;
|
|
613
|
+
if (statusExitCode !== null && statusExitCode > 0) {
|
|
614
|
+
exitCode = statusExitCode;
|
|
615
|
+
} else if (footerExitCode !== null && footerExitCode > 0) {
|
|
616
|
+
exitCode = footerExitCode;
|
|
617
|
+
}
|
|
618
|
+
const endTime = statusResult?.endTime || footer?.endTime || statusResult?.currentTime || null;
|
|
619
|
+
const corrected = { ...statusResult, status: 'oom-killed', exitCode, endTime };
|
|
620
|
+
|
|
621
|
+
if (verbose) {
|
|
622
|
+
console.log(`[VERBOSE] Session ${sessionName} status includes oomKilled=true; treating it as terminal oom-killed (exit ${exitCode})`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { running: false, exitCode, status: 'oom-killed', statusResult: corrected, stale: true };
|
|
626
|
+
}
|
|
627
|
+
|
|
562
628
|
async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
|
|
563
629
|
const { verbose = false, statusProvider = null, exitFromLog = null, backendAlive = null, sessionRunning = null } = options;
|
|
564
630
|
const sessionId = sessionInfo.sessionId || sessionName;
|
|
@@ -568,6 +634,9 @@ async function getIsolationSessionState(sessionName, sessionInfo, options = {})
|
|
|
568
634
|
const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
|
|
569
635
|
|
|
570
636
|
if (statusResult?.exists && statusResult.status) {
|
|
637
|
+
if (statusResult.oomKilled === true) {
|
|
638
|
+
return resolveOomKilledState(sessionName, sessionInfo, statusResult, { verbose, runner, exitFromLog });
|
|
639
|
+
}
|
|
571
640
|
if (runner.isExecutingSessionStatus(statusResult.status)) {
|
|
572
641
|
// Issue #1927: an `executing` status is not trusted blindly — verify the
|
|
573
642
|
// process is really alive. start-command can keep reporting `executing`
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* @see https://github.com/link-assistant/hive-mind/issues/1143
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
|
|
22
|
+
import { extractMergeTargetUrlFromText, parseMergeTargetUrl } from './github-merge-targets.lib.mjs';
|
|
22
23
|
import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
|
|
23
24
|
import { executeStartScreen } from './telegram-command-execution.lib.mjs';
|
|
24
25
|
|
|
@@ -129,6 +130,81 @@ export function parseMergeArgs(args) {
|
|
|
129
130
|
return { positionals, flags };
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A real user reply can carry the issue/PR URL for `/merge`; forwarded
|
|
135
|
+
* commands still must not be replayed by the bot.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} ctx - Telegraf context
|
|
138
|
+
* @param {Object} filters - Message filter callbacks
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function shouldIgnoreMergeCommand(ctx, filters = {}) {
|
|
142
|
+
if (filters.isOldMessage && filters.isOldMessage(ctx)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (typeof filters.isForwarded === 'function') {
|
|
146
|
+
return filters.isForwarded(ctx);
|
|
147
|
+
}
|
|
148
|
+
if (typeof filters.isForwardedOrReply === 'function') {
|
|
149
|
+
return filters.isForwardedOrReply(ctx);
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getMergeReplyText(message) {
|
|
155
|
+
const reply = message?.reply_to_message;
|
|
156
|
+
if (!reply || reply.forum_topic_created) {
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
return reply.text || reply.caption || '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getMergeUsageMessage() {
|
|
163
|
+
return "Missing merge target\\.\n\nUsage: `/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]`\n\nYou can also reply with `/merge` to a message containing one GitHub repository, issue, or pull request link\\.\n\nExamples:\n`/merge https://github.com/owner/repo`\n`/merge https://github.com/owner/repo/issues/123`\n`/merge https://github.com/owner/repo/pull/456`\n\nRepository targets merge all PRs with the 'ready' label\\. Issue and pull request targets wait until the target PR is mergeable, then merge it\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getTargetFoundText(target, count) {
|
|
167
|
+
if (target.mode === 'repository') {
|
|
168
|
+
return `Found ${count} PRs with 'ready' label\\.`;
|
|
169
|
+
}
|
|
170
|
+
const plural = count === 1 ? '' : 's';
|
|
171
|
+
return `Found ${count} target PR${plural} to merge\\.`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the merge target from explicit args, or from a replied message when
|
|
176
|
+
* `/merge` is sent without a URL.
|
|
177
|
+
*
|
|
178
|
+
* @param {string[]} positionals
|
|
179
|
+
* @param {Object} message
|
|
180
|
+
* @returns {{target: Object|null, targetUrl: string|null, fromReply: boolean, error: string|null}}
|
|
181
|
+
*/
|
|
182
|
+
export function resolveMergeCommandTarget(positionals, message) {
|
|
183
|
+
if (positionals.length > 0) {
|
|
184
|
+
const targetUrl = positionals[0];
|
|
185
|
+
const target = parseMergeTargetUrl(targetUrl);
|
|
186
|
+
return {
|
|
187
|
+
target,
|
|
188
|
+
targetUrl,
|
|
189
|
+
fromReply: false,
|
|
190
|
+
error: target.valid ? null : target.error,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const replyText = getMergeReplyText(message);
|
|
195
|
+
if (!replyText) {
|
|
196
|
+
return { target: null, targetUrl: null, fromReply: false, error: null };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const extracted = extractMergeTargetUrlFromText(replyText);
|
|
200
|
+
return {
|
|
201
|
+
target: extracted.target,
|
|
202
|
+
targetUrl: extracted.url,
|
|
203
|
+
fromReply: true,
|
|
204
|
+
error: extracted.valid ? null : extracted.error,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
132
208
|
/**
|
|
133
209
|
* Issue #1805: Spawner used by the merge queue's auto-resolve pass. For each
|
|
134
210
|
* skipped PR we dispatch a `solve <pr-url> --auto-merge` session through
|
|
@@ -209,7 +285,7 @@ function formatUserError(error, verbose) {
|
|
|
209
285
|
* @param {Function} [options.getStoppedChatRejectMessage] - Function to get stopped chat rejection message
|
|
210
286
|
*/
|
|
211
287
|
export function registerMergeCommand(bot, options) {
|
|
212
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
|
|
288
|
+
const { VERBOSE = false, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
|
|
213
289
|
|
|
214
290
|
bot.command(/^merge$/i, async ctx => {
|
|
215
291
|
VERBOSE && console.log('[VERBOSE] /merge command received');
|
|
@@ -222,7 +298,7 @@ export function registerMergeCommand(bot, options) {
|
|
|
222
298
|
});
|
|
223
299
|
|
|
224
300
|
// Standard checks
|
|
225
|
-
if (
|
|
301
|
+
if (shouldIgnoreMergeCommand(ctx, { isOldMessage, isForwarded, isForwardedOrReply })) return;
|
|
226
302
|
|
|
227
303
|
if (!isGroupChat(ctx)) {
|
|
228
304
|
return await ctx.reply('The /merge command only works in group chats. Please add this bot to a group and make it an admin.', {
|
|
@@ -252,24 +328,25 @@ export function registerMergeCommand(bot, options) {
|
|
|
252
328
|
const { positionals, flags } = parseMergeArgs(args);
|
|
253
329
|
const autoResolve = flags['auto-resolve'] === true;
|
|
254
330
|
|
|
255
|
-
|
|
256
|
-
return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url> [--auto-resolve]`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
|
|
257
|
-
}
|
|
331
|
+
const targetResult = resolveMergeCommandTarget(positionals, ctx.message);
|
|
258
332
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
333
|
+
if (!targetResult.target && !targetResult.error) {
|
|
334
|
+
return await ctx.reply(getMergeUsageMessage(), { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
|
|
335
|
+
}
|
|
262
336
|
|
|
263
|
-
if (!
|
|
264
|
-
|
|
337
|
+
if (!targetResult.target || !targetResult.target.valid) {
|
|
338
|
+
const invalidPrefix = targetResult.fromReply ? 'Invalid merge target in replied message' : 'Invalid merge target';
|
|
339
|
+
return await ctx.reply(`${invalidPrefix}: ${escapeMarkdownV2(targetResult.error || targetResult.target?.error || 'Unknown target error')}\n\nPlease provide a valid GitHub repository, issue, or pull request URL\\.`, {
|
|
265
340
|
parse_mode: 'MarkdownV2',
|
|
266
341
|
reply_to_message_id: ctx.message.message_id,
|
|
267
342
|
});
|
|
268
343
|
}
|
|
269
344
|
|
|
270
|
-
const
|
|
345
|
+
const target = targetResult.target;
|
|
346
|
+
const targetUrl = targetResult.targetUrl || target.url;
|
|
347
|
+
const { owner, repo } = target;
|
|
271
348
|
const repoKey = getRepoKey(owner, repo);
|
|
272
|
-
VERBOSE && console.log(`[VERBOSE] /merge: Processing
|
|
349
|
+
VERBOSE && console.log(`[VERBOSE] /merge: Processing ${target.mode} target ${targetUrl} in ${owner}/${repo}`);
|
|
273
350
|
|
|
274
351
|
// Check if a merge operation is already running for this repository (per-repository concurrency)
|
|
275
352
|
if (activeMergeOperations.has(repoKey)) {
|
|
@@ -310,6 +387,7 @@ export function registerMergeCommand(bot, options) {
|
|
|
310
387
|
// Create the merge queue processor
|
|
311
388
|
const processor = await createMergeQueueProcessor(owner, repo, {
|
|
312
389
|
verbose: VERBOSE,
|
|
390
|
+
target,
|
|
313
391
|
// Issue #1805: forward the --auto-resolve flag and inject the spawner.
|
|
314
392
|
// The processor only sees the callback, so unit tests can stub it
|
|
315
393
|
// without spawning real screen sessions.
|
|
@@ -384,14 +462,14 @@ export function registerMergeCommand(bot, options) {
|
|
|
384
462
|
|
|
385
463
|
if (initResult.message) {
|
|
386
464
|
// No PRs to merge
|
|
387
|
-
await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${escapeMarkdownV2(initResult.message)}\n\nTo use the merge queue:\n1\\. Add the \`ready\` label to PRs
|
|
465
|
+
await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${escapeMarkdownV2(initResult.message)}\n\nTo use the merge queue:\n1\\. Add the \`ready\` label to repository PRs, or ensure the issue has a linked open PR\n2\\. Run \`/merge ${escapeMarkdownV2(targetUrl)}\` again`, { parse_mode: 'MarkdownV2' });
|
|
388
466
|
return;
|
|
389
467
|
}
|
|
390
468
|
|
|
391
469
|
// Update message with PR list and cancel button, start processing
|
|
392
470
|
const truncatedMsg = initResult.truncated ? `\n\n_Note: Only processing first ${MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION} PRs_` : '';
|
|
393
471
|
|
|
394
|
-
await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\
|
|
472
|
+
await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${getTargetFoundText(target, initResult.count)}${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
|
|
395
473
|
parse_mode: 'MarkdownV2',
|
|
396
474
|
reply_markup: {
|
|
397
475
|
inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]],
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus, syncReadyTags, closeLinkedIssueIfNotAutoClosed } from './github-merge.lib.mjs';
|
|
20
|
+
import { resolveMergeTargetItems } from './github-merge-targets.lib.mjs';
|
|
21
|
+
import { waitForPRReady as waitForPRReadyHelper } from './telegram-merge-wait.lib.mjs';
|
|
20
22
|
import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
|
|
21
23
|
import { getProgressBar } from './limits.lib.mjs';
|
|
22
24
|
|
|
@@ -38,6 +40,7 @@ export const MergeStatus = {
|
|
|
38
40
|
export const MergeItemStatus = {
|
|
39
41
|
PENDING: 'pending',
|
|
40
42
|
CHECKING_CI: 'checking_ci',
|
|
43
|
+
WAITING_READY: 'waiting_ready',
|
|
41
44
|
WAITING_CI: 'waiting_ci',
|
|
42
45
|
READY_TO_MERGE: 'ready_to_merge',
|
|
43
46
|
MERGING: 'merging',
|
|
@@ -144,6 +147,8 @@ class MergeQueueItem {
|
|
|
144
147
|
return '⏳';
|
|
145
148
|
case MergeItemStatus.CHECKING_CI:
|
|
146
149
|
return '🔍';
|
|
150
|
+
case MergeItemStatus.WAITING_READY:
|
|
151
|
+
return '⏱️';
|
|
147
152
|
case MergeItemStatus.WAITING_CI:
|
|
148
153
|
return '⏱️';
|
|
149
154
|
case MergeItemStatus.READY_TO_MERGE:
|
|
@@ -185,6 +190,8 @@ export class MergeQueueProcessor {
|
|
|
185
190
|
// tests can run without touching the screen runtime.
|
|
186
191
|
this.autoResolve = options.autoResolve === true;
|
|
187
192
|
this.spawnSolveSession = typeof options.spawnSolveSession === 'function' ? options.spawnSolveSession : null;
|
|
193
|
+
this.target = options.target || { mode: 'repository', owner: this.owner, repo: this.repo, url: `https://github.com/${this.owner}/${this.repo}` };
|
|
194
|
+
this.waitForUnfinished = options.waitForUnfinished !== false;
|
|
188
195
|
|
|
189
196
|
// State
|
|
190
197
|
this.items = [];
|
|
@@ -208,6 +215,17 @@ export class MergeQueueProcessor {
|
|
|
208
215
|
this.getPRStatus = typeof options.getPRStatus === 'function' ? options.getPRStatus : getPRStatus;
|
|
209
216
|
// Same idea for `getMergeCommitSha` so tests don't need to stub gh.
|
|
210
217
|
this.getMergeCommitSha = typeof options.getMergeCommitSha === 'function' ? options.getMergeCommitSha : getMergeCommitSha;
|
|
218
|
+
this.checkPRMergeable = typeof options.checkPRMergeable === 'function' ? options.checkPRMergeable : checkPRMergeable;
|
|
219
|
+
this.checkPRCIStatus = typeof options.checkPRCIStatus === 'function' ? options.checkPRCIStatus : checkPRCIStatus;
|
|
220
|
+
this.waitForCI = typeof options.waitForCI === 'function' ? options.waitForCI : waitForCI;
|
|
221
|
+
this.mergePullRequest = typeof options.mergePullRequest === 'function' ? options.mergePullRequest : mergePullRequest;
|
|
222
|
+
this.closeLinkedIssueIfNotAutoClosed = typeof options.closeLinkedIssueIfNotAutoClosed === 'function' ? options.closeLinkedIssueIfNotAutoClosed : closeLinkedIssueIfNotAutoClosed;
|
|
223
|
+
this.resolveMergeTargetItems = typeof options.resolveMergeTargetItems === 'function' ? options.resolveMergeTargetItems : resolveMergeTargetItems;
|
|
224
|
+
this.ensureReadyLabel = typeof options.ensureReadyLabel === 'function' ? options.ensureReadyLabel : ensureReadyLabel;
|
|
225
|
+
this.syncReadyTags = typeof options.syncReadyTags === 'function' ? options.syncReadyTags : syncReadyTags;
|
|
226
|
+
this.getAllReadyPRs = typeof options.getAllReadyPRs === 'function' ? options.getAllReadyPRs : getAllReadyPRs;
|
|
227
|
+
this.targetItemsTimeoutMs = options.targetItemsTimeoutMs ?? MERGE_QUEUE_CONFIG.CI_TIMEOUT_MS;
|
|
228
|
+
this.targetItemsPollIntervalMs = options.targetItemsPollIntervalMs ?? MERGE_QUEUE_CONFIG.CI_POLL_INTERVAL_MS;
|
|
211
229
|
|
|
212
230
|
// Statistics
|
|
213
231
|
this.stats = {
|
|
@@ -241,7 +259,7 @@ export class MergeQueueProcessor {
|
|
|
241
259
|
this.log(`Initializing merge queue for ${this.owner}/${this.repo}`);
|
|
242
260
|
|
|
243
261
|
// Ensure ready label exists
|
|
244
|
-
const labelResult = await ensureReadyLabel(this.owner, this.repo, this.verbose);
|
|
262
|
+
const labelResult = await this.ensureReadyLabel(this.owner, this.repo, this.verbose);
|
|
245
263
|
if (!labelResult.success) {
|
|
246
264
|
return { success: false, error: labelResult.error };
|
|
247
265
|
}
|
|
@@ -249,21 +267,27 @@ export class MergeQueueProcessor {
|
|
|
249
267
|
this.log("Created 'ready' label in repository");
|
|
250
268
|
}
|
|
251
269
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
270
|
+
let readyPRs;
|
|
271
|
+
if (this.target?.mode && this.target.mode !== 'repository') {
|
|
272
|
+
readyPRs = await this.resolveMergeTargetItemsWithWait();
|
|
273
|
+
} else {
|
|
274
|
+
// Issue #1367: Sync 'ready' tags between linked PRs and issues before collecting the queue
|
|
275
|
+
// This ensures the final list reflects all ready work regardless of where the tag was applied
|
|
276
|
+
const syncResult = await this.syncReadyTags(this.owner, this.repo, this.verbose);
|
|
277
|
+
if (syncResult.synced > 0) {
|
|
278
|
+
this.log(`Synced 'ready' tag: ${syncResult.synced} item(s) updated`);
|
|
279
|
+
}
|
|
280
|
+
if (syncResult.errors > 0) {
|
|
281
|
+
this.log(`Tag sync had ${syncResult.errors} error(s) (non-fatal, proceeding)`);
|
|
282
|
+
}
|
|
261
283
|
|
|
262
|
-
|
|
263
|
-
|
|
284
|
+
// Fetch all ready PRs
|
|
285
|
+
readyPRs = await this.getAllReadyPRs(this.owner, this.repo, this.verbose);
|
|
286
|
+
}
|
|
264
287
|
|
|
265
288
|
if (readyPRs.length === 0) {
|
|
266
|
-
|
|
289
|
+
const message = this.target?.mode === 'issue' ? `No open PRs linked to issue #${this.target.issueNumber} found` : this.target?.mode === 'pull' ? `Pull request #${this.target.prNumber} was not found` : "No PRs with 'ready' label found";
|
|
290
|
+
return { success: true, error: null, message };
|
|
267
291
|
}
|
|
268
292
|
|
|
269
293
|
// Limit to max PRs per session
|
|
@@ -279,6 +303,7 @@ export class MergeQueueProcessor {
|
|
|
279
303
|
success: true,
|
|
280
304
|
error: null,
|
|
281
305
|
count: this.items.length,
|
|
306
|
+
targetMode: this.target?.mode || 'repository',
|
|
282
307
|
truncated: readyPRs.length > MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION,
|
|
283
308
|
};
|
|
284
309
|
} catch (error) {
|
|
@@ -419,6 +444,46 @@ export class MergeQueueProcessor {
|
|
|
419
444
|
}
|
|
420
445
|
}
|
|
421
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Resolve targeted PR data, waiting for issue-linked PRs that may be created
|
|
449
|
+
* shortly after a replied `/codex <issue>` message.
|
|
450
|
+
*
|
|
451
|
+
* @returns {Promise<Array<{pr: Object, issue: Object|null, sortDate: Date}>>}
|
|
452
|
+
*/
|
|
453
|
+
async resolveMergeTargetItemsWithWait() {
|
|
454
|
+
const maxPollInterval = Math.max(1, this.targetItemsPollIntervalMs || 1);
|
|
455
|
+
const maxAttempts = Math.max(1, Math.ceil((this.targetItemsTimeoutMs || 0) / maxPollInterval));
|
|
456
|
+
|
|
457
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
458
|
+
if (this.isCancelled) {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const items = (await this.resolveMergeTargetItems(this.target, this.verbose)) || [];
|
|
463
|
+
if (items.length > 0 || this.target?.mode !== 'issue') {
|
|
464
|
+
return items;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (attempt === maxAttempts) {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.log(`Waiting for a linked PR to appear for issue #${this.target.issueNumber}`);
|
|
472
|
+
await this.sleep(this.targetItemsPollIntervalMs);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async waitForPRReady(item, initialCheck) {
|
|
479
|
+
return waitForPRReadyHelper(this, item, initialCheck, {
|
|
480
|
+
MergeItemStatus,
|
|
481
|
+
conflictSkipReason: MERGE_CONFLICT_SKIP_REASON,
|
|
482
|
+
timeoutMs: MERGE_QUEUE_CONFIG.CI_TIMEOUT_MS,
|
|
483
|
+
pollIntervalMs: MERGE_QUEUE_CONFIG.CI_POLL_INTERVAL_MS,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
422
487
|
/**
|
|
423
488
|
* Process a single merge queue item
|
|
424
489
|
* @param {MergeQueueItem} item
|
|
@@ -430,7 +495,7 @@ export class MergeQueueProcessor {
|
|
|
430
495
|
try {
|
|
431
496
|
// Step 1: Check if PR is mergeable
|
|
432
497
|
item.status = MergeItemStatus.CHECKING_CI;
|
|
433
|
-
const mergeableCheck = await checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
|
|
498
|
+
const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
|
|
434
499
|
|
|
435
500
|
if (mergeableCheck.terminal) {
|
|
436
501
|
item.status = MergeItemStatus.FAILED;
|
|
@@ -441,15 +506,34 @@ export class MergeQueueProcessor {
|
|
|
441
506
|
}
|
|
442
507
|
|
|
443
508
|
if (!mergeableCheck.mergeable) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
509
|
+
if (!this.waitForUnfinished || mergeableCheck.reason === MERGE_CONFLICT_SKIP_REASON) {
|
|
510
|
+
item.status = MergeItemStatus.SKIPPED;
|
|
511
|
+
item.error = mergeableCheck.reason;
|
|
512
|
+
this.stats.skipped++;
|
|
513
|
+
this.log(`Skipped PR #${item.pr.number}: ${mergeableCheck.reason}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const waitForReadyResult = await this.waitForPRReady(item, mergeableCheck);
|
|
518
|
+
if (!waitForReadyResult.success) {
|
|
519
|
+
if (waitForReadyResult.status === 'cancelled' || waitForReadyResult.status === 'conflict') {
|
|
520
|
+
item.status = MergeItemStatus.SKIPPED;
|
|
521
|
+
item.error = waitForReadyResult.error;
|
|
522
|
+
this.stats.skipped++;
|
|
523
|
+
this.log(`Skipped PR #${item.pr.number}: ${waitForReadyResult.error}`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
item.status = MergeItemStatus.FAILED;
|
|
528
|
+
item.error = waitForReadyResult.error;
|
|
529
|
+
this.stats.failed++;
|
|
530
|
+
this.log(`Failed PR #${item.pr.number}: ${waitForReadyResult.error}`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
449
533
|
}
|
|
450
534
|
|
|
451
535
|
// Step 2: Check CI status
|
|
452
|
-
const ciStatus = await checkPRCIStatus(this.owner, this.repo, item.pr.number, this.verbose);
|
|
536
|
+
const ciStatus = await this.checkPRCIStatus(this.owner, this.repo, item.pr.number, this.verbose);
|
|
453
537
|
item.ciStatus = ciStatus;
|
|
454
538
|
|
|
455
539
|
if (ciStatus.status === 'failure') {
|
|
@@ -472,7 +556,7 @@ export class MergeQueueProcessor {
|
|
|
472
556
|
if (ciStatus.status === 'pending') {
|
|
473
557
|
item.status = MergeItemStatus.WAITING_CI;
|
|
474
558
|
|
|
475
|
-
const waitResult = await waitForCI(
|
|
559
|
+
const waitResult = await this.waitForCI(
|
|
476
560
|
this.owner,
|
|
477
561
|
this.repo,
|
|
478
562
|
item.pr.number,
|
|
@@ -512,7 +596,7 @@ export class MergeQueueProcessor {
|
|
|
512
596
|
// Step 4: Merge the PR
|
|
513
597
|
// Issue #1269: Pass the configured merge method to prevent "not running interactively" error
|
|
514
598
|
item.status = MergeItemStatus.MERGING;
|
|
515
|
-
const mergeResult = await mergePullRequest(this.owner, this.repo, item.pr.number, { mergeMethod: MERGE_QUEUE_CONFIG.MERGE_METHOD }, this.verbose);
|
|
599
|
+
const mergeResult = await this.mergePullRequest(this.owner, this.repo, item.pr.number, { mergeMethod: MERGE_QUEUE_CONFIG.MERGE_METHOD }, this.verbose);
|
|
516
600
|
|
|
517
601
|
if (!mergeResult.success) {
|
|
518
602
|
item.status = MergeItemStatus.FAILED;
|
|
@@ -531,7 +615,7 @@ export class MergeQueueProcessor {
|
|
|
531
615
|
// Issue #1895: GitHub does not auto-close linked issues for PRs merged into
|
|
532
616
|
// a non-default branch. Close the linked issue explicitly in that case.
|
|
533
617
|
try {
|
|
534
|
-
const closeResult = await closeLinkedIssueIfNotAutoClosed(this.owner, this.repo, item.pr.number, this.verbose);
|
|
618
|
+
const closeResult = await this.closeLinkedIssueIfNotAutoClosed(this.owner, this.repo, item.pr.number, this.verbose);
|
|
535
619
|
if (closeResult.closed) {
|
|
536
620
|
this.log(`Closed linked issue #${closeResult.issueNumber} for PR #${item.pr.number} (merged into non-default branch)`);
|
|
537
621
|
}
|
|
@@ -542,7 +626,7 @@ export class MergeQueueProcessor {
|
|
|
542
626
|
// Issue #1341: Get the merge commit SHA for post-merge CI tracking
|
|
543
627
|
// Need a small delay to allow GitHub to update the PR state
|
|
544
628
|
await this.sleep(5000);
|
|
545
|
-
const mergeCommitResult = await getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
|
|
629
|
+
const mergeCommitResult = await this.getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
|
|
546
630
|
if (mergeCommitResult.sha) {
|
|
547
631
|
item.mergeCommitSha = mergeCommitResult.sha;
|
|
548
632
|
this.log(`PR #${item.pr.number} merge commit: ${mergeCommitResult.sha.substring(0, 7)}`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Wait helpers for Telegram merge queue.
|
|
4
|
+
*
|
|
5
|
+
* Split from telegram-merge-queue.lib.mjs to keep that file under the
|
|
6
|
+
* repository line limit.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2013
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wait for an unfinished PR to become mergeable before CI/merge processing.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} processor - MergeQueueProcessor-like object
|
|
15
|
+
* @param {Object} item - Merge queue item
|
|
16
|
+
* @param {{mergeable: boolean, terminal?: boolean, reason?: string}} initialCheck
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {Object} options.MergeItemStatus
|
|
19
|
+
* @param {string} options.conflictSkipReason
|
|
20
|
+
* @param {number} options.timeoutMs
|
|
21
|
+
* @param {number} options.pollIntervalMs
|
|
22
|
+
* @returns {Promise<{success: boolean, status: string, error: string|null}>}
|
|
23
|
+
*/
|
|
24
|
+
export async function waitForPRReady(processor, item, initialCheck, options) {
|
|
25
|
+
const { MergeItemStatus, conflictSkipReason, timeoutMs, pollIntervalMs } = options;
|
|
26
|
+
const startedAt = Date.now();
|
|
27
|
+
let latestCheck = initialCheck;
|
|
28
|
+
|
|
29
|
+
while (true) {
|
|
30
|
+
if (processor.isCancelled) {
|
|
31
|
+
return { success: false, status: 'cancelled', error: 'Cancelled' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (latestCheck?.terminal) {
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
status: 'terminal',
|
|
38
|
+
error: latestCheck.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (latestCheck?.mergeable) {
|
|
43
|
+
item.status = MergeItemStatus.CHECKING_CI;
|
|
44
|
+
item.error = null;
|
|
45
|
+
return { success: true, status: 'ready', error: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (latestCheck?.reason === conflictSkipReason) {
|
|
49
|
+
return { success: false, status: 'conflict', error: conflictSkipReason };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
53
|
+
const reason = latestCheck?.reason || 'PR did not become mergeable';
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
status: 'timeout',
|
|
57
|
+
error: `Timed out waiting for PR #${item.pr.number} to become mergeable: ${reason}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
item.status = MergeItemStatus.WAITING_READY;
|
|
62
|
+
item.error = latestCheck?.reason || 'Waiting for PR to become mergeable';
|
|
63
|
+
processor.log(`Waiting for PR #${item.pr.number} to become mergeable: ${item.error}`);
|
|
64
|
+
if (processor.onProgress) {
|
|
65
|
+
await processor.onProgress(processor.getProgressUpdate());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await processor.sleep(pollIntervalMs);
|
|
69
|
+
latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default {
|
|
74
|
+
waitForPRReady,
|
|
75
|
+
};
|
|
@@ -374,9 +374,11 @@ export class SolveQueue {
|
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
/**
|
|
377
|
-
* Find startable
|
|
378
|
-
*
|
|
379
|
-
*
|
|
377
|
+
* Find the next startable item across all tool queues.
|
|
378
|
+
* With separate queues, each tool is checked independently so tool-specific
|
|
379
|
+
* limits do not block unrelated tools. Issue #2015 adds a global startup
|
|
380
|
+
* interval, so even when multiple tools are startable this returns only the
|
|
381
|
+
* oldest startable item to prevent burst launches.
|
|
380
382
|
*
|
|
381
383
|
* Also immediately rejects all queued items when a 'reject' strategy threshold
|
|
382
384
|
* is exceeded, instead of leaving them waiting indefinitely.
|
|
@@ -417,7 +419,8 @@ export class SolveQueue {
|
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
420
|
-
|
|
422
|
+
startableItems.sort((a, b) => a.item.createdAt - b.item.createdAt);
|
|
423
|
+
return startableItems.slice(0, 1);
|
|
421
424
|
}
|
|
422
425
|
|
|
423
426
|
/**
|
|
@@ -571,10 +574,10 @@ export class SolveQueue {
|
|
|
571
574
|
let rejected = false;
|
|
572
575
|
let rejectReason = null;
|
|
573
576
|
|
|
574
|
-
// Check minimum interval since last start
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
const lastStartTime = this.
|
|
577
|
+
// Check minimum interval since the last task start globally.
|
|
578
|
+
// Issue #2015: do not let another tool queue bypass startup pacing; host
|
|
579
|
+
// CPU/RAM/disk metrics need time to settle before any next task starts.
|
|
580
|
+
const lastStartTime = this.lastStartTime || null;
|
|
578
581
|
if (lastStartTime) {
|
|
579
582
|
const timeSinceLastStart = Date.now() - lastStartTime;
|
|
580
583
|
if (timeSinceLastStart < QUEUE_CONFIG.MIN_START_INTERVAL_MS) {
|
|
@@ -765,7 +768,6 @@ export class SolveQueue {
|
|
|
765
768
|
}
|
|
766
769
|
|
|
767
770
|
// Check CPU using 5-minute load average (more stable than 1-minute)
|
|
768
|
-
// Cache TTL is 2 minutes, which is appropriate for this metric
|
|
769
771
|
const cpuResult = await getCachedCpuInfo(this.verbose);
|
|
770
772
|
if (cpuResult.success) {
|
|
771
773
|
// Use loadAvg5 (5-minute average) instead of usagePercentage (1-minute based)
|
|
@@ -145,7 +145,7 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
145
145
|
const showCode = finalExitCode !== null && !(!signal && finalExitCode === 1);
|
|
146
146
|
const exitSuffix = showCode ? ` (exit code: ${finalExitCode})` : '';
|
|
147
147
|
const reason = signal ? signal.reason : 'killed';
|
|
148
|
-
statusText = text(messageLocale, 'telegram.work_session_killed', `Work session ${reason}${exitSuffix}`, { reason, exitCode: finalExitCode ?? '', signal: signal?.signal ?? '' });
|
|
148
|
+
statusText = text(messageLocale, 'telegram.work_session_killed', `Work session ${reason}${exitSuffix}`, { reason, exitCode: finalExitCode ?? '', signal: signal?.signal ?? '', exitSuffix });
|
|
149
149
|
} else if (failed) {
|
|
150
150
|
statusText = text(messageLocale, 'telegram.work_session_failed', `Work session failed (exit code: ${finalExitCode})`, { exitCode: finalExitCode });
|
|
151
151
|
} else {
|