@link-assistant/hive-mind 1.38.2 → 1.39.0

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.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b162658: Migrate to sandbox 1.5.0 with /workspace shared directory, replacing user rename approach with group-based access (issue #1499)
8
+
9
+ ## 1.38.3
10
+
11
+ ### Patch Changes
12
+
13
+ - deb31bf: fix: add multi-mechanism CI consensus, repo-wide action monitoring, and 5-min minimum CI check interval to prevent false positive "Ready to merge"
14
+
3
15
  ## 1.38.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -197,22 +197,22 @@ docker attach hive-mind
197
197
 
198
198
  # Extract auth data from a running (or stopped) container to the host:
199
199
  mkdir -p ~/.hive-mind
200
- docker cp hive-mind:/home/hive/.claude ~/.hive-mind/claude
201
- docker cp hive-mind:/home/hive/.claude.json ~/.hive-mind/claude.json
202
- docker cp hive-mind:/home/hive/.config/gh ~/.hive-mind/gh
200
+ docker cp hive-mind:/workspace/.claude ~/.hive-mind/claude
201
+ docker cp hive-mind:/workspace/.claude.json ~/.hive-mind/claude.json
202
+ docker cp hive-mind:/workspace/.config/gh ~/.hive-mind/gh
203
203
 
204
- # Fix ownership to match the hive user inside the container:
205
- HIVE_UID=$(docker exec hive-mind id -u hive)
206
- chown -R $HIVE_UID:$HIVE_UID ~/.hive-mind/claude ~/.hive-mind/gh
207
- chown $HIVE_UID:$HIVE_UID ~/.hive-mind/claude.json
204
+ # Fix ownership to match the sandbox user inside the container:
205
+ SANDBOX_UID=$(docker exec hive-mind id -u sandbox)
206
+ chown -R $SANDBOX_UID:$SANDBOX_UID ~/.hive-mind/claude ~/.hive-mind/gh
207
+ chown $SANDBOX_UID:$SANDBOX_UID ~/.hive-mind/claude.json
208
208
 
209
209
  # On subsequent runs, mount the auth data to keep it between restarts:
210
210
  docker run -dit \
211
211
  --name hive-mind \
212
212
  --restart unless-stopped \
213
- -v /root/.hive-mind/claude:/home/hive/.claude \
214
- -v /root/.hive-mind/claude.json:/home/hive/.claude.json \
215
- -v /root/.hive-mind/gh:/home/hive/.config/gh \
213
+ -v /root/.hive-mind/claude:/workspace/.claude \
214
+ -v /root/.hive-mind/claude.json:/workspace/.claude.json \
215
+ -v /root/.hive-mind/gh:/workspace/.config/gh \
216
216
  konard/hive-mind:latest
217
217
  ```
218
218
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.38.2",
3
+ "version": "1.39.0",
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,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Repository-Wide Actions Monitoring
4
+ *
5
+ * Issue #1503: Functions to check and wait for ALL active GitHub Actions
6
+ * workflow runs across the entire repository. This is the "absolute safety
7
+ * mechanism" modeled after the /merge command's waitForBranchCI pattern.
8
+ *
9
+ * @see https://github.com/link-assistant/hive-mind/issues/1503
10
+ */
11
+
12
+ import { promisify } from 'util';
13
+ import { exec as execCallback } from 'child_process';
14
+ const exec = promisify(execCallback);
15
+
16
+ /**
17
+ * Get ALL active workflow runs across the entire repository (no branch filter).
18
+ * @param {string} owner - Repository owner
19
+ * @param {string} repo - Repository name
20
+ * @param {boolean} verbose - Whether to log verbose output
21
+ * @returns {Promise<{runs: Array, hasActiveRuns: boolean, count: number}>}
22
+ */
23
+ export async function getAllActiveRepoRuns(owner, repo, verbose = false) {
24
+ try {
25
+ const activeFilter = '.workflow_runs[] | select(.status=="in_progress" or .status=="queued" or .status=="waiting" or .status=="requested" or .status=="pending")';
26
+ const fields = '{id: .id, name: .name, status: .status, head_branch: .head_branch, head_sha: (.head_sha[:7])}';
27
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?per_page=100" --jq '[${activeFilter}] | map(${fields})'`);
28
+ const runs = JSON.parse(stdout.trim() || '[]');
29
+ if (verbose && runs.length > 0) {
30
+ console.log(`[VERBOSE] repo-actions: ${runs.length} active run(s) in ${owner}/${repo}`);
31
+ for (const r of runs) console.log(`[VERBOSE] repo-actions: ${r.name} (${r.status}) on ${r.head_branch}`);
32
+ }
33
+ return { runs, hasActiveRuns: runs.length > 0, count: runs.length };
34
+ } catch {
35
+ return { runs: [], hasActiveRuns: false, count: 0 };
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Wait for ALL active workflow runs in the repository to complete.
41
+ * Blocks until every in-progress/queued run across ALL branches finishes.
42
+ * @param {string} owner - Repository owner
43
+ * @param {string} repo - Repository name
44
+ * @param {Object} options - Wait options (timeout, pollInterval, onStatusUpdate)
45
+ * @param {boolean} verbose - Whether to log verbose output
46
+ * @returns {Promise<{success: boolean, waitedForRuns: boolean, timedOut: boolean, remainingRuns: Array}>}
47
+ */
48
+ export async function waitForAllRepoActions(owner, repo, options = {}, verbose = false) {
49
+ const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null } = options;
50
+ const startTime = Date.now();
51
+ let peakRunCount = 0;
52
+
53
+ while (Date.now() - startTime < timeout) {
54
+ const active = await getAllActiveRepoRuns(owner, repo, verbose);
55
+ if (onStatusUpdate) {
56
+ try {
57
+ await onStatusUpdate({ ...active, elapsedMs: Date.now() - startTime });
58
+ } catch {
59
+ // Ignore callback errors — continue monitoring
60
+ }
61
+ }
62
+ if (!active.hasActiveRuns) {
63
+ return { success: true, waitedForRuns: peakRunCount > 0, timedOut: false, remainingRuns: [] };
64
+ }
65
+ peakRunCount = Math.max(peakRunCount, active.count);
66
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
67
+ }
68
+ const finalRuns = await getAllActiveRepoRuns(owner, repo, verbose);
69
+ return { success: false, waitedForRuns: true, timedOut: true, remainingRuns: finalRuns.runs };
70
+ }
71
+
72
+ /**
73
+ * Multi-mechanism CI consensus check. Requires Check Runs API, Workflow Runs API,
74
+ * and optionally repo-wide active runs to ALL agree before concluding CI is complete.
75
+ * @param {Object} params
76
+ * @returns {Promise<{allAgree: boolean, mechanisms: Object, ciStatus: Object, workflowRuns: Array}>}
77
+ */
78
+ export async function checkCIConsensus({ owner, repo, prNumber, sha, waitForAllRepoActionsFlag, verbose, getDetailedCIStatus, getWorkflowRunsForSha }) {
79
+ const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
80
+ const checkRunsOK = ciStatus.status === 'success' || ciStatus.status === 'no_checks';
81
+
82
+ const workflowRuns = await getWorkflowRunsForSha(owner, repo, sha, verbose);
83
+ const workflowsOK = workflowRuns.length === 0 || workflowRuns.every(r => r.status === 'completed');
84
+
85
+ let repoOK = true;
86
+ let repoInfo = null;
87
+ if (waitForAllRepoActionsFlag) {
88
+ repoInfo = await getAllActiveRepoRuns(owner, repo, verbose);
89
+ repoOK = !repoInfo.hasActiveRuns;
90
+ }
91
+
92
+ const allAgree = checkRunsOK && workflowsOK && repoOK;
93
+ const mechanisms = {
94
+ checkRunsAPI: { complete: checkRunsOK, status: ciStatus.status },
95
+ workflowRunsAPI: { complete: workflowsOK, total: workflowRuns.length, inProgress: workflowRuns.filter(r => r.status !== 'completed').length },
96
+ repoActions: waitForAllRepoActionsFlag ? { complete: repoOK, count: repoInfo?.count ?? 0 } : { skipped: true },
97
+ };
98
+
99
+ if (verbose) {
100
+ console.log(`[VERBOSE] consensus: CheckRuns=${checkRunsOK}(${ciStatus.status}), WorkflowRuns=${workflowsOK}(${workflowRuns.length}), RepoActions=${waitForAllRepoActionsFlag ? repoOK : 'skip'} → ${allAgree ? 'AGREE' : 'DISAGREE'}`);
101
+ }
102
+ return { allAgree, mechanisms, ciStatus, workflowRuns };
103
+ }
@@ -1385,31 +1385,30 @@ export async function checkPreviousPRCommitsHadCI(owner, repo, prNumber, headSha
1385
1385
  * @param {boolean} verbose - Whether to log verbose output
1386
1386
  * @returns {Promise<{hasPRTriggers: boolean, hasWorkflowFiles: boolean, workflows: Array<{name: string, triggers: string[]}>}>}
1387
1387
  */
1388
- export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false) {
1388
+ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false, ref = null) {
1389
1389
  try {
1390
- // List workflow files in .github/workflows/
1391
- const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows" --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
1390
+ // Issue #1503: Support querying workflow files from a specific branch (ref)
1391
+ const refParam = ref ? `?ref=${encodeURIComponent(ref)}` : '';
1392
+ // List workflow files in .github/workflows/ (uses ref if provided, otherwise default branch)
1393
+ const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows${refParam}" --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
1392
1394
  const files = JSON.parse(listJson.trim() || '[]');
1393
1395
 
1394
1396
  if (files.length === 0) {
1395
- if (verbose) {
1396
- console.log(`[VERBOSE] /merge: No workflow files found in ${owner}/${repo}/.github/workflows/ — no CI/CD will execute`);
1397
- }
1398
- // Issue #1480: hasWorkflowFiles=false is a strong signal that no CI/CD is configured at the file level
1397
+ if (verbose) console.log(`[VERBOSE] /merge: No workflow files in ${owner}/${repo}/.github/workflows/`);
1399
1398
  return { hasPRTriggers: false, hasWorkflowFiles: false, workflows: [] };
1400
1399
  }
1401
1400
 
1402
1401
  const prTriggerPatterns = [/\bon:\s*\n\s+pull_request/m, /\bon:\s*\[.*pull_request.*\]/m, /\bon:\s*pull_request\b/m, /\bpull_request_target\b/m];
1403
-
1404
- // Also check for push triggers (push to PR branches triggers CI)
1405
1402
  const pushTriggerPatterns = [/\bon:\s*\n\s+push/m, /\bon:\s*\[.*push.*\]/m, /\bon:\s*push\b/m];
1403
+ // Issue #1503: Non-PR triggers for diagnostics (won't produce check-runs on PRs)
1404
+ const nonPROnlyTriggerPatterns = [/\bworkflow_dispatch\b/m, /\bschedule\b/m, /\brepository_dispatch\b/m, /\bworkflow_call\b/m];
1406
1405
 
1407
1406
  const results = [];
1408
1407
 
1409
1408
  for (const file of files) {
1410
1409
  try {
1411
- // Fetch file content (use raw content from the API)
1412
- const { stdout: contentJson } = await exec(`gh api "repos/${owner}/${repo}/contents/${file.path}" --jq '.content'`);
1410
+ // Issue #1503: Fetch file content using same ref parameter for branch-specific workflows
1411
+ const { stdout: contentJson } = await exec(`gh api "repos/${owner}/${repo}/contents/${file.path}${refParam}" --jq '.content'`);
1413
1412
  const content = Buffer.from(contentJson.trim().replace(/"/g, ''), 'base64').toString('utf-8');
1414
1413
 
1415
1414
  const triggers = [];
@@ -1419,13 +1418,15 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false)
1419
1418
  if (pushTriggerPatterns.some(p => p.test(content))) {
1420
1419
  triggers.push('push');
1421
1420
  }
1421
+ // Issue #1503: Track non-PR triggers for diagnostics
1422
+ const nonPRTriggers = nonPROnlyTriggerPatterns.filter(p => p.test(content)).map(p => p.source.replace(/\\b/g, ''));
1422
1423
 
1423
1424
  if (triggers.length > 0) {
1424
1425
  results.push({ name: file.name, triggers });
1425
1426
  }
1426
1427
 
1427
1428
  if (verbose) {
1428
- console.log(`[VERBOSE] /merge: Workflow ${file.name}: triggers=[${triggers.join(', ')}]`);
1429
+ console.log(`[VERBOSE] /merge: Workflow ${file.name}: pr_triggers=[${triggers.join(', ')}], non_pr_triggers=[${nonPRTriggers.join(', ')}]`);
1429
1430
  }
1430
1431
  } catch (fileError) {
1431
1432
  if (verbose) {
@@ -1454,6 +1455,9 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false)
1454
1455
  import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1455
1456
  export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
1456
1457
 
1458
+ import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus } from './github-merge-repo-actions.lib.mjs'; // Issue #1503
1459
+ export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus };
1460
+
1457
1461
  export default {
1458
1462
  READY_LABEL,
1459
1463
  checkReadyLabelExists,
@@ -1482,15 +1486,15 @@ export default {
1482
1486
  rerunWorkflowRun,
1483
1487
  rerunFailedJobs,
1484
1488
  getWorkflowRunsForSha,
1485
- // Issue #1341: Post-merge CI waiting; Issue #1363: Detect active workflows
1486
1489
  waitForCommitCI,
1487
1490
  checkBranchCIHealth,
1488
1491
  getMergeCommitSha,
1489
1492
  getActiveRepoWorkflows,
1490
- // Issue #1480: Commit date, workflow PR triggers, and previous commit CI history for race condition detection
1491
1493
  getCommitDate,
1492
1494
  checkPreviousPRCommitsHadCI,
1493
1495
  checkWorkflowsHavePRTriggers,
1494
- // Issue #1413: Use issue timeline to find genuinely linked PRs (avoids false positives from text search)
1495
1496
  getLinkedPRsFromTimeline,
1497
+ getAllActiveRepoRuns,
1498
+ waitForAllRepoActions,
1499
+ checkCIConsensus, // Issue #1503
1496
1500
  };
@@ -33,7 +33,7 @@ const { reportError } = sentryLib;
33
33
 
34
34
  // Import GitHub merge functions
35
35
  const githubMergeLib = await import('./github-merge.lib.mjs');
36
- const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers } = githubMergeLib;
36
+ const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
37
37
 
38
38
  // Import GitHub functions for log attachment
39
39
  const githubLib = await import('./github.lib.mjs');
@@ -187,7 +187,7 @@ const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCh
187
187
  * - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
188
188
  * - no_checks: No CI checks yet (race condition) → wait
189
189
  */
190
- const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1) => {
190
+ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
191
191
  const blockers = [];
192
192
 
193
193
  // Use detailed CI status to distinguish between all possible states
@@ -239,7 +239,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
239
239
  // Treat the same as "CI not triggered" to avoid infinite waiting.
240
240
  const conclusions = [...new Set(workflowRuns.map(r => r.conclusion))].join(', ');
241
241
  if (verbose) {
242
- console.log(`[VERBOSE] /merge: PR #${prNumber} has ${workflowRuns.length} workflow run(s) for SHA ${ciStatus.sha.substring(0, 7)}, but all completed without executing (conclusions: ${conclusions}) — check-runs will never appear`);
242
+ await log(`[VERBOSE] /merge: PR #${prNumber} has ${workflowRuns.length} workflow run(s) for SHA ${ciStatus.sha.substring(0, 7)}, but all completed without executing (conclusions: ${conclusions}) — check-runs will never appear`);
243
243
  }
244
244
  await log(formatAligned('ℹ️', 'CI workflows completed without executing:', `${conclusions} (${workflowRuns.map(r => r.name).join(', ')})`, 2));
245
245
  return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true, workflowRunConclusions: conclusions };
@@ -247,7 +247,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
247
247
 
248
248
  // Some workflow runs are still in progress or produced results — genuine race condition
249
249
  if (verbose) {
250
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI check-runs yet, but ${workflowRuns.length} workflow run(s) were triggered for SHA ${ciStatus.sha.substring(0, 7)} - genuine race condition (waiting for check-runs to appear)`);
250
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI check-runs yet, but ${workflowRuns.length} workflow run(s) were triggered for SHA ${ciStatus.sha.substring(0, 7)} - genuine race condition (waiting for check-runs to appear)`);
251
251
  }
252
252
  blockers.push({
253
253
  type: 'ci_pending',
@@ -269,13 +269,13 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
269
269
  const commitInfo = await getCommitDate(owner, repo, ciStatus.sha, verbose);
270
270
 
271
271
  // Issue #1480: Parse workflow files for PR triggers (used in both grace period and post-grace checks)
272
- const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose);
272
+ const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
273
273
 
274
274
  // Issue #1480: If .github/workflows folder doesn't exist or has no workflow files,
275
275
  // that's a definitive signal — no CI/CD will execute, skip grace period entirely
276
276
  if (!prTriggers.hasWorkflowFiles) {
277
277
  if (verbose) {
278
- console.log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
278
+ await log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
279
279
  }
280
280
  return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
281
281
  }
@@ -295,15 +295,32 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
295
295
  // changed files, conditional workflows that don't match, etc.
296
296
  const MAX_NO_RUNS_CHECKS = 5;
297
297
  if (checkCount >= MAX_NO_RUNS_CHECKS) {
298
- // We've waited long enough CI was genuinely not triggered
298
+ // Issue #1503 (enhanced): Before concluding CI was not triggered, check if
299
+ // previous commits in this PR had CI runs. If they did, CI should be expected
300
+ // for the current commit too — extend waiting with a higher threshold.
301
+ const MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY = 10;
302
+ if (checkCount < MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY) {
303
+ const previousCI = await checkPreviousPRCommitsHadCI(owner, repo, prNumber, ciStatus.sha, verbose);
304
+ if (previousCI.hadPreviousCI) {
305
+ // Previous commits had CI — this commit should too, keep waiting
306
+ await log(formatAligned('⚠️', 'CI history signal:', `${previousCI.previousCommitsWithCI} previous commit(s) had CI runs — extending wait (check ${checkCount}/${MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY})`, 2));
307
+ blockers.push({
308
+ type: 'ci_pending',
309
+ message: `CI/CD workflow runs have not appeared yet — previous commits had CI runs, extending wait (check ${checkCount}/${MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY})`,
310
+ details: prTriggers.workflows.map(w => w.name),
311
+ });
312
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
313
+ }
314
+ }
315
+ // We've waited long enough (and no CI history signal) — CI was genuinely not triggered
299
316
  if (verbose) {
300
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no workflow runs after ${checkCount} consecutive checks — concluding CI was not triggered despite PR triggers existing`);
317
+ await log(formatAligned('ℹ️', 'CI not triggered:', `No workflow runs after ${checkCount} consecutive checks — concluding CI was not triggered`, 2));
301
318
  }
302
319
  return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
303
320
  }
304
321
 
305
322
  if (verbose) {
306
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}) — waiting for workflow runs to appear (check ${checkCount}/${MAX_NO_RUNS_CHECKS}, commit age: ${commitInfo.ageSeconds ?? 'unknown'}s)`);
323
+ await log(formatAligned('⏳', 'Waiting for CI:', `No workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}) — check ${checkCount}/${MAX_NO_RUNS_CHECKS}, commit age: ${commitInfo.ageSeconds ?? 'unknown'}s`, 2));
307
324
  }
308
325
  blockers.push({
309
326
  type: 'ci_pending',
@@ -313,7 +330,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
313
330
  } else if (commitInfo.ageSeconds !== null && commitInfo.ageSeconds < WORKFLOW_RUN_GRACE_PERIOD_SECONDS) {
314
331
  // No PR triggers found in workflow files, but commit is still recent — be safe and wait
315
332
  if (verbose) {
316
- console.log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
333
+ await log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
317
334
  }
318
335
  blockers.push({
319
336
  type: 'ci_pending',
@@ -325,7 +342,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
325
342
  // Issue #1442: Fork PRs needing maintainer approval, paths-ignore filtering,
326
343
  // workflow conditions not matching, etc. all result in zero workflow runs.
327
344
  if (verbose) {
328
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and no workflow runs for SHA ${ciStatus.sha.substring(0, 7)} (commit age: ${commitInfo.ageSeconds ?? 'unknown'}s, no PR/push triggers in workflow files) — CI was not triggered`);
345
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and no workflow runs for SHA ${ciStatus.sha.substring(0, 7)} (commit age: ${commitInfo.ageSeconds ?? 'unknown'}s, no PR/push triggers in workflow files) — CI was not triggered`);
329
346
  }
330
347
  return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
331
348
  }
@@ -336,7 +353,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
336
353
  // Do NOT add a ci_pending blocker. The mergeability check below will also
337
354
  // confirm this is mergeable, so blockers will be empty → PR IS MERGEABLE path.
338
355
  if (verbose) {
339
- console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and repo has no active workflows - no CI/CD configured`);
356
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and repo has no active workflows - no CI/CD configured`);
340
357
  }
341
358
  // Return early with no CI blocker, mergeability already confirmed
342
359
  return { blockers, ciStatus, noCiConfigured: true };
@@ -362,7 +379,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
362
379
  if (incompleteRuns.length > 0) {
363
380
  // Some workflow runs are still in progress — more check-runs may appear
364
381
  if (verbose) {
365
- console.log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} checks passed), but ${incompleteRuns.length} workflow run(s) still in progress — waiting for completion`);
382
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} checks passed), but ${incompleteRuns.length} workflow run(s) still in progress — waiting for completion`);
366
383
  }
367
384
  blockers.push({
368
385
  type: 'ci_pending',
@@ -376,7 +393,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
376
393
  // (e.g., CodeFactor, Codecov). Check if the repo has workflows that should produce runs.
377
394
  const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
378
395
  if (repoWorkflows.hasWorkflows) {
379
- const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose);
396
+ const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
380
397
  if (prTriggers.hasPRTriggers) {
381
398
  // Repo has workflows with PR triggers but no runs yet — CI hasn't started
382
399
  // This is the exact scenario from Case 2 of Issue #1480
@@ -385,12 +402,12 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
385
402
  const MAX_NO_RUNS_CHECKS = 5;
386
403
  if (checkCount >= MAX_NO_RUNS_CHECKS) {
387
404
  if (verbose) {
388
- console.log(`[VERBOSE] /merge: PR #${prNumber} CI 'success' with ${ciStatus.passedChecks.length} external checks, no workflow runs after ${checkCount} checks — trusting external checks`);
405
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI 'success' with ${ciStatus.passedChecks.length} external checks, no workflow runs after ${checkCount} checks — trusting external checks`);
389
406
  }
390
407
  // Fall through — trust the success status from external checks
391
408
  } else {
392
409
  if (verbose) {
393
- console.log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} external checks), but repo has PR-triggered workflows with 0 workflow runs — likely race condition (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`);
410
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} external checks), but repo has PR-triggered workflows with 0 workflow runs — likely race condition (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`);
394
411
  }
395
412
  // Wait for GitHub Actions to register workflow runs
396
413
  blockers.push({
@@ -492,8 +509,14 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
492
509
  export const watchUntilMergeable = async params => {
493
510
  const { issueUrl, owner, repo, issueNumber, prNumber, prBranch, branchName, tempDir, argv } = params;
494
511
 
495
- const watchInterval = argv.watchInterval || 60; // seconds
512
+ const rawWatchInterval = argv.watchInterval || 60; // seconds
513
+ // Issue #1503: Enforce minimum 5-minute (300s) CI check interval to conserve GitHub API rate limits.
514
+ // This prevents excessive API calls during long-running CI pipelines.
515
+ const MIN_CI_CHECK_INTERVAL_SECONDS = 300;
516
+ const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
496
517
  const isAutoMerge = argv.autoMerge || false;
518
+ // Issue #1503: --wait-for-all-actions-in-repository-before-mergable (default: true)
519
+ const waitForAllRepoActionsFlag = argv.waitForAllActionsInRepositoryBeforeMergable ?? argv['wait-for-all-actions-in-repository-before-mergable'] ?? true;
497
520
 
498
521
  // Track latest session data across all iterations for accurate pricing
499
522
  let latestSessionId = null;
@@ -513,11 +536,25 @@ export const watchUntilMergeable = async params => {
513
536
 
514
537
  let currentBackoffSeconds = watchInterval;
515
538
 
539
+ // Issue #1503: Track consecutive "no workflow runs" checks per-SHA separately from iteration count.
540
+ // The `checkCount` parameter in getMergeBlockers is a safety valve that triggers after
541
+ // MAX_NO_RUNS_CHECKS (5) consecutive checks with zero workflow runs, concluding CI was
542
+ // genuinely not triggered (paths-ignore, fork PRs, etc.). Previously, `iteration` (total
543
+ // loop count) was passed as `checkCount`, which meant after 5 iterations (regardless of
544
+ // CI state), any new push would immediately trigger the safety valve because checkCount
545
+ // was already >= 5. This caused false positive "Ready to merge" when a new commit was
546
+ // pushed and CI hadn't registered yet.
547
+ //
548
+ // Fix: Track the HEAD SHA and reset the counter when it changes (new push detected).
549
+ let consecutiveNoRunsChecks = 0;
550
+ let lastKnownHeadSha = null;
551
+
516
552
  await log('');
517
553
  await log(formatAligned('🔄', 'AUTO-RESTART-UNTIL-MERGEABLE MODE ACTIVE', ''));
518
554
  await log(formatAligned('', 'Monitoring PR:', `#${prNumber}`, 2));
519
555
  await log(formatAligned('', 'Mode:', isAutoMerge ? 'Auto-merge (will merge when ready)' : 'Auto-restart-until-mergeable (will NOT auto-merge)', 2));
520
- await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds`, 2));
556
+ await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds (minimum: ${MIN_CI_CHECK_INTERVAL_SECONDS}s)`, 2));
557
+ await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (absolute safety)' : 'No', 2));
521
558
  await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
522
559
  await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
523
560
  await log('');
@@ -554,8 +591,47 @@ export const watchUntilMergeable = async params => {
554
591
  await log(formatAligned('🔍', `Check #${iteration}:`, currentTime.toLocaleTimeString()));
555
592
 
556
593
  try {
594
+ // Issue #1503: Get the current HEAD SHA to detect new pushes and reset the
595
+ // consecutive no-runs counter. This prevents false positives where the counter
596
+ // from a previous commit's checks carries over to a new commit.
597
+ let currentHeadSha = null;
598
+ try {
599
+ const shaResult = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid --jq .headRefOid`;
600
+ if (shaResult.code === 0) {
601
+ currentHeadSha = shaResult.stdout.toString().trim();
602
+ }
603
+ } catch {
604
+ // If SHA check fails, proceed with current counter (safe: doesn't reset)
605
+ }
606
+ if (currentHeadSha && currentHeadSha !== lastKnownHeadSha) {
607
+ if (lastKnownHeadSha !== null) {
608
+ await log(formatAligned('🔄', 'New commit detected:', `${lastKnownHeadSha.substring(0, 7)} → ${currentHeadSha.substring(0, 7)} (resetting CI check counter)`, 2));
609
+ }
610
+ lastKnownHeadSha = currentHeadSha;
611
+ consecutiveNoRunsChecks = 0;
612
+ // Issue #1503: Also reset the readyToMergeCommentPosted flag when SHA changes,
613
+ // so a new "Ready to merge" comment can be posted for the new commit's CI results.
614
+ readyToMergeCommentPosted = false;
615
+ }
616
+
617
+ // Issue #1503: Increment counter; getMergeBlockers will use it as a safety valve.
618
+ // If getMergeBlockers sees no workflow runs on this check, the counter stays incremented.
619
+ // If it sees workflow runs or checks, the counter is irrelevant (different code paths).
620
+ consecutiveNoRunsChecks++;
621
+
557
622
  // Get merge blockers
558
- const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, iteration);
623
+ const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
624
+
625
+ // Issue #1503: Reset consecutive counter when CI checks or workflow runs were found.
626
+ // This ensures the safety valve only fires after truly consecutive "no runs" checks,
627
+ // not after interleaved pending/success/failure states that happened to reach the count.
628
+ if (ciStatus && ciStatus.status !== 'no_checks') {
629
+ // CI checks exist (pending, success, failure, etc.) — the "no runs" counter is irrelevant
630
+ consecutiveNoRunsChecks = 0;
631
+ } else if (noCiConfigured || noCiTriggered) {
632
+ // CI was definitively determined: either not configured or not triggered.
633
+ // Keep the counter as-is (it reached the safety valve or wasn't needed).
634
+ }
559
635
 
560
636
  // Check for new comments from non-bot users
561
637
  const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose);
@@ -576,6 +652,54 @@ export const watchUntilMergeable = async params => {
576
652
 
577
653
  // If PR is mergeable, no blockers, no new comments, and no uncommitted changes
578
654
  if (blockers.length === 0 && !hasNewComments && !hasUncommittedChanges) {
655
+ // Issue #1503 (enhanced): Multi-mechanism consensus + repo-wide action check.
656
+ // Before declaring PR mergeable, run multiple independent CI detection mechanisms
657
+ // and require all to agree. This catches race conditions where CI starts between
658
+ // checks or where interacting CI/CD pipelines affect mergeability.
659
+ if (!noCiConfigured) {
660
+ const DOUBLE_CHECK_DELAY_MS = 10000; // 10 seconds
661
+ await log(formatAligned('🔍', 'Multi-mechanism CI consensus check:', `Waiting ${DOUBLE_CHECK_DELAY_MS / 1000}s then verifying...`, 2));
662
+ await new Promise(resolve => setTimeout(resolve, DOUBLE_CHECK_DELAY_MS));
663
+
664
+ // Run multi-mechanism consensus: Check Runs API + Workflow Runs API + Repo-wide actions
665
+ const consensus = await checkCIConsensus({
666
+ owner,
667
+ repo,
668
+ prNumber,
669
+ sha: currentHeadSha || ciStatus?.sha,
670
+ waitForAllRepoActionsFlag,
671
+ verbose: argv.verbose,
672
+ getDetailedCIStatus,
673
+ getWorkflowRunsForSha,
674
+ });
675
+
676
+ if (!consensus.allAgree) {
677
+ const m = consensus.mechanisms;
678
+ await log(formatAligned('🔄', 'CI mechanisms DISAGREE:', `CheckRuns=${m.checkRunsAPI.status}, WorkflowRuns=${m.workflowRunsAPI.inProgress} in-progress, RepoActions=${m.repoActions.skipped ? 'skipped' : m.repoActions.count + ' active'}`, 2));
679
+ await log(formatAligned('⏳', 'Continuing to monitor...', 'Mechanisms must agree before declaring mergeable', 2));
680
+ consecutiveNoRunsChecks = 0;
681
+ lastCheckTime = currentTime;
682
+ const actualWaitSeconds = currentBackoffSeconds;
683
+ await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
684
+ await log('');
685
+ await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
686
+ continue;
687
+ }
688
+ await log(formatAligned('✅', 'All CI mechanisms agree:', `CheckRuns=${consensus.mechanisms.checkRunsAPI.status}, WorkflowRuns=complete(${consensus.mechanisms.workflowRunsAPI.total}), RepoActions=${consensus.mechanisms.repoActions.skipped ? 'skipped' : 'clear'}`, 2));
689
+ } else if (waitForAllRepoActionsFlag) {
690
+ // Even with no CI configured, check repo-wide actions for absolute safety
691
+ const repoRuns = await getAllActiveRepoRuns(owner, repo, argv.verbose);
692
+ if (repoRuns.hasActiveRuns) {
693
+ await log(formatAligned('⏳', 'Waiting for repo-wide actions:', `${repoRuns.count} active run(s) in repository`, 2));
694
+ lastCheckTime = currentTime;
695
+ const actualWaitSeconds = currentBackoffSeconds;
696
+ await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
697
+ await log('');
698
+ await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
699
+ continue;
700
+ }
701
+ }
702
+
579
703
  await log(formatAligned('✅', 'PR IS MERGEABLE!', ''));
580
704
 
581
705
  if (isAutoMerge) {
@@ -186,6 +186,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
186
186
  description: 'Auto-restart until PR becomes mergeable (no iteration limit). Restarts on new comments from non-bot users, CI failures, merge conflicts, or other issues. Does NOT auto-merge.',
187
187
  default: true,
188
188
  },
189
+ 'wait-for-all-actions-in-repository-before-mergable': {
190
+ type: 'boolean',
191
+ description: 'Wait for ALL active GitHub Actions workflow runs in the entire repository to complete before declaring PR mergeable. Provides absolute safety against interacting CI/CD pipelines. Enabled by default.',
192
+ default: true,
193
+ },
189
194
  'auto-restart-on-non-updated-pull-request-description': {
190
195
  type: 'boolean',
191
196
  description: 'Automatically restart if PR title or description still contains auto-generated placeholder text after agent execution. Restarts with a hint about what was not updated.',