@link-assistant/hive-mind 1.74.3 → 1.74.5

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
+ ## 1.74.5
4
+
5
+ ### Patch Changes
6
+
7
+ - c20c2ec: Stop auto-restart-until-mergeable from restarting on CodeRabbit review quota/credit failures, and report them as Ready for review with skipped checks instead.
8
+
9
+ ## 1.74.4
10
+
11
+ ### Patch Changes
12
+
13
+ - 9b88700: Fix Telegram Docker isolation to use Hive Mind images with scoped GitHub, Claude, and Codex auth mounts.
14
+
3
15
  ## 1.74.3
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.3",
3
+ "version": "1.74.5",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Helpers for external review services whose checks fail because the service
5
+ * could not run, not because repository code failed.
6
+ */
7
+
8
+ const EXTERNAL_REVIEW_SERVICE_PATTERNS = [/coderabbit/i, /code\s*rabbit/i];
9
+
10
+ const EXTERNAL_REVIEW_LIMIT_PATTERNS = [/insufficient\s+(?:review\s+)?credits?/i, /review\s+limit\s+reached/i, /rate\s+limit(?:ed|s)?/i, /run\s+out\s+of\s+usage\s+credits?/i, /out\s+of\s+usage\s+credits?/i, /usage\s+credits?\s+(?:exhausted|reached|insufficient)/i, /insufficient\s+(?:balance|limits?)/i, /no\s+(?:credits?|balance)\s+(?:left|remaining)/i];
11
+
12
+ const checkText = check => {
13
+ if (!check || typeof check !== 'object') return '';
14
+ return [check.name, check.context, check.description, check.summary, check.text, check.html_url, check.details_url].filter(Boolean).join('\n');
15
+ };
16
+
17
+ export const isExternalReviewLimitCheck = check => {
18
+ const text = checkText(check);
19
+ if (!text) return false;
20
+ return EXTERNAL_REVIEW_SERVICE_PATTERNS.some(pattern => pattern.test(text)) && EXTERNAL_REVIEW_LIMIT_PATTERNS.some(pattern => pattern.test(text));
21
+ };
22
+
23
+ export const splitExternalReviewLimitChecks = checks => {
24
+ const limitedChecks = [];
25
+ const actionableFailedChecks = [];
26
+
27
+ for (const check of checks || []) {
28
+ if (isExternalReviewLimitCheck(check)) {
29
+ limitedChecks.push(check);
30
+ } else {
31
+ actionableFailedChecks.push(check);
32
+ }
33
+ }
34
+
35
+ return { limitedChecks, actionableFailedChecks };
36
+ };
37
+
38
+ export const formatExternalReviewLimitCheck = check => {
39
+ const name = check?.name || check?.context || 'External review';
40
+ const description = check?.description && check.description !== name ? ` — ${check.description}` : '';
41
+ const url = check?.html_url || check?.details_url;
42
+ return `${name}${description}${url ? ` — ${url}` : ''}`;
43
+ };
44
+
45
+ const formatList = items => items.map(item => `- ${item}`).join('\n');
46
+
47
+ export const buildReadyForReviewComment = ({ blocker, ciStatus } = {}) => {
48
+ const skippedChecks = blocker?.details?.length ? blocker.details : (blocker?.checks || []).map(formatExternalReviewLimitCheck);
49
+ const skippedList = skippedChecks.length > 0 ? skippedChecks : ['External review check — blocked by service credits/rate limits'];
50
+ const passedChecks = (ciStatus?.passedChecks || []).map(formatExternalReviewLimitCheck);
51
+ const passedSection = passedChecks.length > 0 ? `\n\n**Checks completed successfully:**\n${formatList(passedChecks)}` : '';
52
+
53
+ return `## 🟡 Ready for review
54
+
55
+ Hive Mind stopped automatic restart because the remaining failed check is an external review quota/credit limit, not a code failure it can fix.
56
+
57
+ **Checks not executed:**
58
+ ${formatList(skippedList)}${passedSection}
59
+
60
+ **Action required:**
61
+ - Restore the external review credits/rate limit and rerun the review, or decide manually whether this PR can proceed.
62
+ - No new AI session was started for this blocker.
63
+
64
+ ---
65
+ *Monitored by hive-mind with --auto-restart-until-mergeable flag.*`;
66
+ };
@@ -346,12 +346,14 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
346
346
  status: check.status,
347
347
  conclusion: check.conclusion,
348
348
  type: 'check_run',
349
+ description: check.output?.title || check.output?.summary || null,
349
350
  })),
350
351
  ...statuses.map(status => ({
351
352
  name: status.context,
352
353
  status: status.state === 'pending' ? 'in_progress' : 'completed',
353
354
  conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
354
355
  type: 'status',
356
+ description: status.description || null,
355
357
  })),
356
358
  ];
357
359
 
@@ -375,7 +377,7 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
375
377
 
376
378
  const hasPending = allChecks.some(c => c.status !== 'completed' || c.conclusion === null);
377
379
  const allPassed = !hasPending && allChecks.every(c => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral');
378
- const hasFailed = allChecks.some(c => c.conclusion === 'failure' || c.conclusion === 'cancelled' || c.conclusion === 'timed_out');
380
+ const hasFailed = allChecks.some(c => c.conclusion === 'failure' || c.conclusion === 'error' || c.conclusion === 'cancelled' || c.conclusion === 'timed_out');
379
381
 
380
382
  let status;
381
383
  if (hasPending) {
@@ -1126,6 +1128,7 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1126
1128
  conclusion: check.conclusion, // success, failure, cancelled, timed_out, skipped, neutral, action_required, stale, null
1127
1129
  type: 'check_run',
1128
1130
  id: check.id,
1131
+ description: check.output?.title || check.output?.summary || null,
1129
1132
  html_url: check.html_url || check.details_url || null,
1130
1133
  })),
1131
1134
  ...statuses.map(status => ({
@@ -1134,6 +1137,7 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1134
1137
  conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
1135
1138
  type: 'status',
1136
1139
  id: null,
1140
+ description: status.description || null,
1137
1141
  html_url: status.target_url || null,
1138
1142
  })),
1139
1143
  ];
@@ -1173,7 +1177,7 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1173
1177
  // neutral, action_required, stale, null (not yet completed)
1174
1178
  // GitHub check run statuses include: queued, in_progress, completed, waiting, requested, pending
1175
1179
  const passedChecks = allChecks.filter(c => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral');
1176
- const failedChecks = allChecks.filter(c => c.conclusion === 'failure' || c.conclusion === 'timed_out' || c.conclusion === 'action_required');
1180
+ const failedChecks = allChecks.filter(c => c.conclusion === 'failure' || c.conclusion === 'error' || c.conclusion === 'timed_out' || c.conclusion === 'action_required');
1177
1181
  const cancelledChecks = allChecks.filter(c => c.conclusion === 'cancelled');
1178
1182
  const staleChecks = allChecks.filter(c => c.conclusion === 'stale');
1179
1183
  const pendingChecks = allChecks.filter(c => (c.status === 'in_progress' || c.status === 'waiting' || c.status === 'requested' || c.status === 'pending') && c.conclusion === null);
@@ -13,6 +13,10 @@
13
13
  */
14
14
 
15
15
  import crypto from 'crypto';
16
+ import { spawn } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
16
20
 
17
21
  if (typeof use === 'undefined') {
18
22
  globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
@@ -24,6 +28,11 @@ const { $ } = await use('command-stream');
24
28
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
29
  const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
26
30
  const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
31
+ const DEFAULT_HIVE_MIND_IMAGE = 'konard/hive-mind:latest';
32
+ const DEFAULT_HIVE_MIND_DIND_IMAGE = 'konard/hive-mind-dind:latest';
33
+ const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
34
+ const DOCKER_CONTAINER_HOME = '/home/box';
35
+ const DOCKER_CONTAINER_PREFIX = 'hive-mind-isolation';
27
36
 
28
37
  function normalizeProcessIds(value) {
29
38
  if (!value || typeof value !== 'object') return {};
@@ -35,6 +44,149 @@ function normalizeProcessIds(value) {
35
44
  return out;
36
45
  }
37
46
 
47
+ function normalizeTool(tool) {
48
+ return String(tool || 'claude')
49
+ .trim()
50
+ .toLowerCase();
51
+ }
52
+
53
+ function shellQuote(value) {
54
+ const stringValue = String(value);
55
+ if (stringValue === '') return "''";
56
+ return `'${stringValue.replaceAll("'", "'\\''")}'`;
57
+ }
58
+
59
+ function shellDoubleQuote(value) {
60
+ return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
61
+ }
62
+
63
+ function buildShellCommand(command, args = []) {
64
+ return [command, ...args].map(shellQuote).join(' ');
65
+ }
66
+
67
+ function makeDockerContainerName(sessionId) {
68
+ const normalizedSession = String(sessionId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/g, '-');
69
+ return `${DOCKER_CONTAINER_PREFIX}-${normalizedSession}`;
70
+ }
71
+
72
+ function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
73
+ return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
74
+ }
75
+
76
+ function maybeAddMount(mounts, source, target, existsSync) {
77
+ if (!source) return;
78
+ if (!existsSync(source)) return;
79
+ mounts.push({ source, target });
80
+ }
81
+
82
+ /**
83
+ * Pick the Docker image used for `--isolation docker`.
84
+ *
85
+ * start-command defaults its Docker backend to a base OS image. Hive Mind needs
86
+ * an image with the same CLI/tooling baseline as the parent process instead.
87
+ */
88
+ export function getDockerIsolationImage({ env = process.env } = {}) {
89
+ if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
90
+ return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? DEFAULT_HIVE_MIND_DIND_IMAGE : DEFAULT_HIVE_MIND_IMAGE;
91
+ }
92
+
93
+ /**
94
+ * Build host auth mounts for a Docker-isolated task.
95
+ *
96
+ * GitHub auth is mounted for every task because solve/hive/task need gh. Tool
97
+ * credentials are deliberately scoped: Codex sessions do not receive Claude
98
+ * files and Claude sessions do not receive Codex files.
99
+ */
100
+ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
101
+ const mounts = [];
102
+ const normalizedTool = normalizeTool(tool);
103
+
104
+ maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
105
+
106
+ if (normalizedTool === 'codex') {
107
+ maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
108
+ } else if (normalizedTool === 'claude') {
109
+ maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
110
+ maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
111
+ }
112
+
113
+ return mounts;
114
+ }
115
+
116
+ /**
117
+ * Build the shell command executed inside a start-command wrapper session for
118
+ * Docker isolation. The wrapper remains a start-command session so Telegram can
119
+ * keep using the same status/log lifecycle while Hive Mind controls image and
120
+ * auth mounts directly.
121
+ */
122
+ export function buildDockerIsolationCommand(command, args = [], options = {}) {
123
+ const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
124
+ const image = getDockerIsolationImage({ env });
125
+ const innerCommand = buildShellCommand(command, args);
126
+ const dockerArgs = ['docker', 'run', '--rm', '--name', makeDockerContainerName(sessionId), '--workdir', DOCKER_CONTAINER_HOME, '-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`];
127
+
128
+ if (shouldRunPrivilegedDockerIsolation(image, env)) {
129
+ dockerArgs.push('--privileged');
130
+ }
131
+
132
+ const imageVariant = image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
133
+ dockerArgs.push('-e', `HIVE_MIND_IMAGE_VARIANT=${imageVariant}`);
134
+
135
+ for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
136
+ dockerArgs.push('--volume', `${mount.source}:${mount.target}`);
137
+ }
138
+
139
+ dockerArgs.push(image, 'bash', '-lc');
140
+
141
+ return [...dockerArgs.map(shellQuote), shellDoubleQuote(innerCommand)].join(' ');
142
+ }
143
+
144
+ export function buildStartCommandArgs(command, args = [], options = {}) {
145
+ const { backend, sessionId } = options;
146
+ if (backend === 'docker') {
147
+ return ['--isolated', DOCKER_ISOLATION_TRACKING_BACKEND, '--detached', '--session', sessionId, '--', buildDockerIsolationCommand(command, args, options)];
148
+ }
149
+ return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
150
+ }
151
+
152
+ async function runStartCommand(binPath, startCommandArgs) {
153
+ return await new Promise(resolve => {
154
+ const child = spawn(binPath, startCommandArgs, {
155
+ stdio: ['ignore', 'pipe', 'pipe'],
156
+ env: process.env,
157
+ });
158
+
159
+ let stdout = '';
160
+ let stderr = '';
161
+
162
+ child.stdout.on('data', data => {
163
+ stdout += data.toString();
164
+ });
165
+ child.stderr.on('data', data => {
166
+ stderr += data.toString();
167
+ });
168
+ child.on('error', error => {
169
+ resolve({
170
+ success: false,
171
+ output: (stdout + stderr).trim(),
172
+ error: error.message,
173
+ });
174
+ });
175
+ child.on('close', code => {
176
+ const output = (stdout + (stderr ? `\n${stderr}` : '')).trim();
177
+ if (code === 0) {
178
+ resolve({ success: true, output, error: null });
179
+ } else {
180
+ resolve({
181
+ success: false,
182
+ output,
183
+ error: stderr.trim() || `start-command exited with code ${code}`,
184
+ });
185
+ }
186
+ });
187
+ });
188
+ }
189
+
38
190
  /**
39
191
  * Generate a UUID v4 for unique session identification
40
192
  * @returns {string} UUID v4 string
@@ -169,6 +321,7 @@ async function findStartCommandBinary() {
169
321
  * @param {Object} options - Isolation options
170
322
  * @param {string} options.backend - Isolation backend: 'screen', 'tmux', or 'docker'
171
323
  * @param {string} [options.sessionId] - UUID for session tracking (auto-generated if not provided)
324
+ * @param {string} [options.tool] - AI tool selected for the task; used to scope Docker auth mounts
172
325
  * @param {boolean} [options.verbose] - Enable verbose logging
173
326
  * @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string}>}
174
327
  */
@@ -201,47 +354,40 @@ export async function executeWithIsolation(command, args, options = {}) {
201
354
  console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
202
355
  }
203
356
 
204
- // Build arguments as array for the $ CLI:
205
- // $ --isolated <backend> --detached --session <sessionId> -- <command> <args...>
206
- const argsStr = args.join(' ');
357
+ const startCommandArgs = buildStartCommandArgs(command, args, { ...options, sessionId });
207
358
 
208
359
  if (verbose) {
209
- console.log(`[VERBOSE] isolation-runner: $ --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`);
360
+ console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
361
+ if (backend === 'docker') {
362
+ const image = getDockerIsolationImage({ env: options.env || process.env });
363
+ const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env: options.env || process.env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
364
+ console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
365
+ console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
366
+ }
210
367
  }
211
368
 
212
- try {
213
- const result = await $({ mirror: false })`${binPath} --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`;
214
-
215
- const stdout = result.stdout?.toString() || '';
216
- const stderr = result.stderr?.toString() || '';
217
- const output = stdout + (stderr ? '\n' + stderr : '');
369
+ const result = await runStartCommand(binPath, startCommandArgs);
218
370
 
219
- if (verbose) {
220
- console.log(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
221
- }
371
+ if (verbose) {
372
+ const stream = result.success ? console.log : console.error;
373
+ stream(`[VERBOSE] isolation-runner: Output: ${result.output.substring(0, 500)}`);
374
+ if (result.error) stream(`[VERBOSE] isolation-runner: Error: ${result.error}`);
375
+ }
222
376
 
377
+ if (result.success) {
223
378
  return {
224
379
  success: true,
225
380
  sessionId,
226
- output: output.trim(),
227
- };
228
- } catch (error) {
229
- const stdout = error.stdout?.toString() || '';
230
- const stderr = error.stderr?.toString() || '';
231
- const output = stdout + stderr;
232
-
233
- if (verbose) {
234
- console.error(`[VERBOSE] isolation-runner: Error: ${error.message}`);
235
- console.error(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
236
- }
237
-
238
- return {
239
- success: false,
240
- sessionId,
241
- output: output.trim(),
242
- error: error.message,
381
+ output: result.output,
243
382
  };
244
383
  }
384
+
385
+ return {
386
+ success: false,
387
+ sessionId,
388
+ output: result.output,
389
+ error: result.error,
390
+ };
245
391
  }
246
392
 
247
393
  /**
@@ -378,12 +524,14 @@ export async function isSessionRunning(sessionId, options = {}) {
378
524
  }
379
525
  }
380
526
 
381
- // Fallback: for screen backend, check screen -ls directly.
527
+ // Fallback: for screen-backed sessions, check screen -ls directly.
528
+ // Docker isolation is also tracked through a screen wrapper so Hive Mind can
529
+ // control image selection and credential mounts while preserving logs/status.
382
530
  // Only use this when $ --status has no usable record. This works around
383
531
  // older start-command bugs where:
384
532
  // 1. $ --status can't find session by --session name (only by internal UUID)
385
533
  // See: https://github.com/link-assistant/hive-mind/issues/1545
386
- if (backend === 'screen' && shouldFallbackToScreenStatus(result)) {
534
+ if ((backend === 'screen' || backend === 'docker') && shouldFallbackToScreenStatus(result)) {
387
535
  const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
388
536
  if (screenRunning && verbose) {
389
537
  console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
@@ -78,6 +78,9 @@ const formatRunLine = run => {
78
78
  const toolComments = await import('./tool-comments.lib.mjs');
79
79
  const { SESSION_ENDING_MARKERS, isToolGeneratedComment, isToolTrackedCommentId, trackToolCommentId } = toolComments;
80
80
 
81
+ const externalReviewLimitLib = await import('./external-review-limit.lib.mjs');
82
+ const { formatExternalReviewLimitCheck, splitExternalReviewLimitChecks } = externalReviewLimitLib;
83
+
81
84
  /**
82
85
  * Issue #1323: Check if a comment with specific content already exists on the PR
83
86
  * This prevents duplicate status comments when multiple processes or restarts occur
@@ -798,11 +801,22 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
798
801
  sha: ciStatus.sha,
799
802
  });
800
803
  }
801
- blockers.push({
802
- type: 'ci_failure',
803
- message: 'CI/CD checks are failing',
804
- details: ciStatus.failedChecks.map(c => c.name),
805
- });
804
+ const { limitedChecks, actionableFailedChecks } = splitExternalReviewLimitChecks(ciStatus.failedChecks);
805
+ if (limitedChecks.length > 0) {
806
+ blockers.push({
807
+ type: 'external_review_limit',
808
+ message: 'External review check was not executed because credits/rate limits are exhausted',
809
+ details: limitedChecks.map(formatExternalReviewLimitCheck),
810
+ checks: limitedChecks,
811
+ });
812
+ }
813
+ if (actionableFailedChecks.length > 0) {
814
+ blockers.push({
815
+ type: 'ci_failure',
816
+ message: 'CI/CD checks are failing',
817
+ details: actionableFailedChecks.map(c => c.name),
818
+ });
819
+ }
806
820
  }
807
821
  } else if (ciStatus.status === 'unknown') {
808
822
  // Unable to determine CI status - treat as pending to be safe
@@ -61,7 +61,10 @@ const { buildCancelledCIReviewComment, getRetriggerableWorkflowRuns, shouldStopF
61
61
 
62
62
  // Issue #1625: Shared marker constants + posting/tracking helpers
63
63
  const toolComments = await import('./tool-comments.lib.mjs');
64
- const { READY_TO_MERGE_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
64
+ const { READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_RESTART_MARKER, AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
65
+
66
+ const externalReviewLimitLib = await import('./external-review-limit.lib.mjs');
67
+ const { buildReadyForReviewComment } = externalReviewLimitLib;
65
68
 
66
69
  // Issue #1728: Per-iteration working session summary attachment helper
67
70
  // Issue #1763: Per-iteration PR ↔ issue link verification (so a clobbered
@@ -517,7 +520,44 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
517
520
 
518
521
  // Reason 2: CI failures (only if NOT a billing limit issue and NOT just cancelled)
519
522
  // Only restart AI when we have genuine code failures (real feedback to act on)
523
+ const externalReviewLimitBlocker = blockers.find(b => b.type === 'external_review_limit');
520
524
  const ciBlocker = blockers.find(b => b.type === 'ci_failure');
525
+ const hasMergeConflictBlocker = blockers.some(b => b.type === 'not_mergeable' && b.message?.includes('conflicts'));
526
+ if (externalReviewLimitBlocker && !ciBlocker && !billingBlocker && !cancelledBlocker && !hasNewComments && !hasUncommittedChanges && !hasMergeConflictBlocker) {
527
+ await log('');
528
+ await log(formatAligned('🟡', 'READY FOR REVIEW', 'External review quota/credit limit requires human decision'));
529
+ for (const detail of externalReviewLimitBlocker.details || []) {
530
+ await log(formatAligned('', 'Check not executed:', detail, 2));
531
+ }
532
+ await log(formatAligned('', 'Action:', 'Stopping auto-restart without starting another AI session', 2));
533
+
534
+ try {
535
+ const commentSignature = `## 🟡 ${READY_FOR_REVIEW_MARKER}`;
536
+ const hasExistingReadyForReviewComment = await checkForExistingComment(owner, repo, prNumber, commentSignature, argv.verbose);
537
+ if (hasExistingReadyForReviewComment) {
538
+ await log(formatAligned('', `Skipping duplicate "${READY_FOR_REVIEW_MARKER}" comment (already posted by another process)`, '', 2));
539
+ } else {
540
+ const commentBody = buildReadyForReviewComment({
541
+ blocker: externalReviewLimitBlocker,
542
+ ciStatus,
543
+ });
544
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
545
+ await log(formatAligned('', '💬 Posted ready-for-review notification to PR', '', 2));
546
+ }
547
+ } catch (commentError) {
548
+ reportError(commentError, {
549
+ context: 'post_external_review_limit_comment',
550
+ owner,
551
+ repo,
552
+ prNumber,
553
+ operation: 'comment_on_pr',
554
+ });
555
+ await log(formatAligned('', '⚠️ Could not post ready-for-review comment to PR', '', 2));
556
+ }
557
+
558
+ return { success: false, reason: 'external_review_limit', latestSessionId, latestAnthropicCost };
559
+ }
560
+
521
561
  if (ciBlocker && !billingBlocker) {
522
562
  shouldRestart = true;
523
563
  restartReason = restartReason ? `${restartReason}; CI failures` : 'CI failures detected';
@@ -115,7 +115,7 @@ export function buildExecuteAndUpdateMessage(deps) {
115
115
  if (iso) {
116
116
  session = iso.runner.generateSessionId();
117
117
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
118
- result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
118
+ result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
119
119
  if (result.success) {
120
120
  sessionInfo = { ...baseSessionInfo, isolationBackend: iso.backend, sessionId: session };
121
121
  trackSession(session, sessionInfo, VERBOSE);
@@ -79,7 +79,8 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
79
79
  const iso = await resolveIsolation(item.perCommandIsolation, botIsolationBackend, botIsolationRunner, verbose);
80
80
  if (iso) {
81
81
  const sid = iso.runner.generateSessionId();
82
- const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
82
+ const tool = item.tool || 'claude';
83
+ const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, tool, verbose });
83
84
  if (r.success)
84
85
  trackSession(
85
86
  sid,
@@ -91,7 +92,7 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
91
92
  command: item.command || 'solve',
92
93
  isolationBackend: iso.backend,
93
94
  sessionId: sid,
94
- tool: item.tool || 'claude',
95
+ tool,
95
96
  infoBlock: item.infoBlock,
96
97
  // Issue #1688: propagate URL context + requester through the queue so the
97
98
  // completion notification can append a 'Pull request:' line and skip
@@ -109,8 +109,9 @@ export function resolveLogPath({ statusResult, isolationBackend }) {
109
109
  if (statusResult?.logPath) return statusResult.logPath;
110
110
  const uuid = statusResult?.uuid;
111
111
  if (!uuid) return null;
112
- if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
113
- return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
112
+ const logBackend = statusResult?.isolation || isolationBackend;
113
+ if (logBackend && ISOLATION_BACKENDS.has(logBackend)) {
114
+ return path.join('/tmp/start-command/logs/isolation', logBackend, `${uuid}.log`);
114
115
  }
115
116
  return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
116
117
  }
@@ -49,6 +49,9 @@ export const AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER = 'Auto-restart-until-merge
49
49
  // solve.auto-merge.lib.mjs — "ready to merge" status comments
50
50
  export const READY_TO_MERGE_MARKER = 'Ready to merge';
51
51
 
52
+ // solve.auto-merge.lib.mjs — external review quota/credit stop comments
53
+ export const READY_FOR_REVIEW_MARKER = 'Ready for review';
54
+
52
55
  // solve.auto-merge.lib.mjs — "auto-merged successfully" status comments
53
56
  export const AUTO_MERGED_MARKER = 'Auto-merged';
54
57
 
@@ -107,7 +110,7 @@ export const USAGE_LIMIT_REACHED_MARKER = 'Usage Limit Reached';
107
110
  * named constants above so that adding a new marker only requires adding
108
111
  * the constant and appending it here.
109
112
  */
110
- export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, CANCELLED_CI_REVIEW_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_AUTOMATION_MARKER];
113
+ export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, CANCELLED_CI_REVIEW_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_AUTOMATION_MARKER];
111
114
 
112
115
  /**
113
116
  * Markers that indicate the end of a working session. Used by