@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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
- system: parseIntWithDefault('HIVE_MIND_SYSTEM_CACHE_TTL_MS', 2 * 60 * 1000), // 2 minutes
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
  }
@@ -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: 120000 = 2 minutes)
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, // 2 minutes for system metrics (RAM, CPU, disk)
1247
+ SYSTEM: cacheTtl.system, // max 1 minute for system metrics (RAM, CPU, disk)
1248
1248
  };
1249
1249
 
1250
1250
  /**
@@ -623,6 +623,7 @@ en
623
623
  executing "⏳ Executing..."
624
624
  finished "Work session finished successfully"
625
625
  failed "Work session failed (exit code: {{exitCode}})"
626
+ killed "Work session {{reason}}{{exitSuffix}}"
626
627
  duration
627
628
  label "Duration"
628
629
  session
@@ -623,6 +623,7 @@ hi
623
623
  executing "⏳ चल रहा है..."
624
624
  finished "कार्य सत्र सफलतापूर्वक पूरा हुआ"
625
625
  failed "कार्य सत्र विफल हुआ (exit code: {{exitCode}})"
626
+ killed "कार्य सत्र रोका गया: {{reason}}{{exitSuffix}}"
626
627
  duration
627
628
  label "अवधि"
628
629
  session
@@ -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
@@ -623,6 +623,7 @@ zh
623
623
  executing "⏳ 正在执行..."
624
624
  finished "工作会话已成功完成"
625
625
  failed "工作会话失败(退出代码:{{exitCode}})"
626
+ killed "工作会话已终止:{{reason}}{{exitSuffix}}"
626
627
  duration
627
628
  label "耗时"
628
629
  session
@@ -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: Time to allow solve command to start actual claude process
275
- // This ensures that when API limits are checked, the running process is counted
276
- MIN_START_INTERVAL_MS: parseIntWithDefault('HIVE_MIND_MIN_START_INTERVAL_MS', 60000), // 1 minute between starts
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 { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
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 (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
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
- if (positionals.length === 0) {
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
- // Parse and validate repository URL
260
- const repoUrl = positionals[0];
261
- const parsedUrl = parseRepositoryUrl(repoUrl);
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 (!parsedUrl.valid) {
264
- return await ctx.reply(`Invalid repository URL: ${escapeMarkdownV2(parsedUrl.error)}\n\nPlease provide a valid GitHub repository URL\\.`, {
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 { owner, repo } = parsedUrl;
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 repository ${owner}/${repo}`);
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 you want to merge\n2\\. Run \`/merge ${escapeMarkdownV2(repoUrl)}\` again`, { parse_mode: 'MarkdownV2' });
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\nFound ${initResult.count} PRs with 'ready' label\\.${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
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
- // Issue #1367: Sync 'ready' tags between linked PRs and issues before collecting the queue
253
- // This ensures the final list reflects all ready work regardless of where the tag was applied
254
- const syncResult = await syncReadyTags(this.owner, this.repo, this.verbose);
255
- if (syncResult.synced > 0) {
256
- this.log(`Synced 'ready' tag: ${syncResult.synced} item(s) updated`);
257
- }
258
- if (syncResult.errors > 0) {
259
- this.log(`Tag sync had ${syncResult.errors} error(s) (non-fatal, proceeding)`);
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
- // Fetch all ready PRs
263
- const readyPRs = await getAllReadyPRs(this.owner, this.repo, this.verbose);
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
- return { success: true, error: null, message: "No PRs with 'ready' label found" };
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
- item.status = MergeItemStatus.SKIPPED;
445
- item.error = mergeableCheck.reason;
446
- this.stats.skipped++;
447
- this.log(`Skipped PR #${item.pr.number}: ${mergeableCheck.reason}`);
448
- return;
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 items from each tool queue
378
- * Returns the first item from each tool queue that can start.
379
- * With separate queues, each tool is checked independently so they don't block each other.
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
- return startableItems;
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 FOR THIS TOOL
575
- // Each tool queue has independent timing to prevent cross-blocking
576
- // See: https://github.com/link-assistant/hive-mind/issues/1159
577
- const lastStartTime = this.lastStartTimeByTool[tool] || null;
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 {