@link-assistant/hive-mind 1.50.6 → 1.50.8

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,25 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.50.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 5760755: fix: default to PR-branch-only CI check, add pagination and typo fix (#1573)
8
+ - Fix typo: `--wait-for-all-actions-in-repository-before-mergable` → `--wait-for-all-actions-in-repository-before-mergeable` (deprecated alias kept for backward compatibility)
9
+ - When repo-wide flag is enabled, block on ALL active runs regardless of branch (no branch filtering) to ensure safety when CI/CD pipelines interact
10
+ - Add `--paginate` to `getPRCommitShas()` to load all PR commits (not just first page)
11
+ - Add all-commits CI check: verify CI completes for every commit on the PR branch, not just HEAD
12
+ - Add `getPRCommitShas()` and `checkAllPRCommitsCI()` for per-commit CI verification
13
+
14
+ ## 1.50.7
15
+
16
+ ### Patch Changes
17
+
18
+ - 84b9853: fix: make all long sleeps interruptible so CTRL+C responds immediately (#1574)
19
+ - Replace raw `setTimeout` sleeps with an interruptible sleep utility that listens for SIGINT
20
+ - Ensure CTRL+C during CI polling, auto-merge waits, and auto-continue delays terminates the process immediately
21
+ - Add `interruptible-sleep.lib.mjs` with full test coverage
22
+
3
23
  ## 1.50.6
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.50.6",
3
+ "version": "1.50.8",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -69,9 +69,61 @@ export async function waitForAllRepoActions(owner, repo, options = {}, verbose =
69
69
  return { success: false, waitedForRuns: true, timedOut: true, remainingRuns: finalRuns.runs };
70
70
  }
71
71
 
72
+ /**
73
+ * Get all commit SHAs for a pull request branch.
74
+ * @param {string} owner - Repository owner
75
+ * @param {string} repo - Repository name
76
+ * @param {number} prNumber - Pull request number
77
+ * @param {boolean} verbose - Whether to log verbose output
78
+ * @returns {Promise<string[]>} Array of commit SHAs
79
+ */
80
+ export async function getPRCommitShas(owner, repo, prNumber, verbose = false) {
81
+ try {
82
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/pulls/${prNumber}/commits" --paginate --jq '[.[].sha]'`);
83
+ const shas = JSON.parse(stdout.trim() || '[]');
84
+ if (verbose && shas.length > 1) {
85
+ console.log(`[VERBOSE] pr-commits: ${shas.length} commits on PR #${prNumber}`);
86
+ }
87
+ return shas;
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Check that workflow runs for ALL commits on the PR branch have completed.
95
+ * @param {string} owner - Repository owner
96
+ * @param {string} repo - Repository name
97
+ * @param {number} prNumber - Pull request number
98
+ * @param {boolean} verbose - Whether to log verbose output
99
+ * @param {Function} getWorkflowRunsForSha - Function to get workflow runs for a SHA
100
+ * @returns {Promise<{allComplete: boolean, totalCommits: number, pendingCommits: string[], details: Object[]}>}
101
+ */
102
+ export async function checkAllPRCommitsCI(owner, repo, prNumber, verbose, getWorkflowRunsForSha) {
103
+ const shas = await getPRCommitShas(owner, repo, prNumber, verbose);
104
+ if (shas.length === 0) return { allComplete: true, totalCommits: 0, pendingCommits: [], details: [] };
105
+
106
+ const details = [];
107
+ const pendingCommits = [];
108
+ for (const sha of shas) {
109
+ const runs = await getWorkflowRunsForSha(owner, repo, sha, false);
110
+ const inProgress = runs.filter(r => r.status !== 'completed');
111
+ const complete = inProgress.length === 0;
112
+ details.push({ sha: sha.substring(0, 7), total: runs.length, inProgress: inProgress.length, complete });
113
+ if (!complete) pendingCommits.push(sha.substring(0, 7));
114
+ }
115
+
116
+ if (verbose && pendingCommits.length > 0) {
117
+ console.log(`[VERBOSE] pr-commits: ${pendingCommits.length}/${shas.length} commits have in-progress CI: ${pendingCommits.join(', ')}`);
118
+ }
119
+
120
+ return { allComplete: pendingCommits.length === 0, totalCommits: shas.length, pendingCommits, details };
121
+ }
122
+
72
123
  /**
73
124
  * Multi-mechanism CI consensus check. Requires Check Runs API, Workflow Runs API,
74
125
  * and optionally repo-wide active runs to ALL agree before concluding CI is complete.
126
+ * Issue #1573: Also checks all PR commits' CI (not just head SHA) by default.
75
127
  * @param {Object} params
76
128
  * @returns {Promise<{allAgree: boolean, mechanisms: Object, ciStatus: Object, workflowRuns: Array}>}
77
129
  */
@@ -82,6 +134,15 @@ export async function checkCIConsensus({ owner, repo, prNumber, sha, waitForAllR
82
134
  const workflowRuns = await getWorkflowRunsForSha(owner, repo, sha, verbose);
83
135
  const workflowsOK = workflowRuns.length === 0 || workflowRuns.every(r => r.status === 'completed');
84
136
 
137
+ let allCommitsOK = true;
138
+ let allCommitsInfo = null;
139
+ if (checkRunsOK && workflowsOK && prNumber) {
140
+ allCommitsInfo = await checkAllPRCommitsCI(owner, repo, prNumber, verbose, getWorkflowRunsForSha);
141
+ allCommitsOK = allCommitsInfo.allComplete;
142
+ }
143
+
144
+ // When enabled, block on ANY active CI/CD run in the repository regardless of branch.
145
+ // This ensures safety when CI/CD pipelines interact or depend on each other.
85
146
  let repoOK = true;
86
147
  let repoInfo = null;
87
148
  if (waitForAllRepoActionsFlag) {
@@ -89,15 +150,18 @@ export async function checkCIConsensus({ owner, repo, prNumber, sha, waitForAllR
89
150
  repoOK = !repoInfo.hasActiveRuns;
90
151
  }
91
152
 
92
- const allAgree = checkRunsOK && workflowsOK && repoOK;
153
+ const allAgree = checkRunsOK && workflowsOK && allCommitsOK && repoOK;
93
154
  const mechanisms = {
94
155
  checkRunsAPI: { complete: checkRunsOK, status: ciStatus.status },
95
156
  workflowRunsAPI: { complete: workflowsOK, total: workflowRuns.length, inProgress: workflowRuns.filter(r => r.status !== 'completed').length },
157
+ allCommitsCI: allCommitsInfo ? { complete: allCommitsOK, totalCommits: allCommitsInfo.totalCommits, pendingCommits: allCommitsInfo.pendingCommits } : { skipped: true },
96
158
  repoActions: waitForAllRepoActionsFlag ? { complete: repoOK, count: repoInfo?.count ?? 0 } : { skipped: true },
97
159
  };
98
160
 
99
161
  if (verbose) {
100
- console.log(`[VERBOSE] consensus: CheckRuns=${checkRunsOK}(${ciStatus.status}), WorkflowRuns=${workflowsOK}(${workflowRuns.length}), RepoActions=${waitForAllRepoActionsFlag ? repoOK : 'skip'}${allAgree ? 'AGREE' : 'DISAGREE'}`);
162
+ const repoLabel = waitForAllRepoActionsFlag ? `${repoOK}(${repoInfo?.count ?? 0} active)` : 'skip';
163
+ const commitsLabel = allCommitsInfo ? `${allCommitsOK}(${allCommitsInfo.totalCommits} commits${allCommitsInfo.pendingCommits.length > 0 ? `, ${allCommitsInfo.pendingCommits.length} pending` : ''})` : 'skip';
164
+ console.log(`[VERBOSE] consensus: CheckRuns=${checkRunsOK}(${ciStatus.status}), WorkflowRuns=${workflowsOK}(${workflowRuns.length}), AllCommits=${commitsLabel}, RepoActions=${repoLabel} → ${allAgree ? 'AGREE' : 'DISAGREE'}`);
101
165
  }
102
166
  return { allAgree, mechanisms, ciStatus, workflowRuns };
103
167
  }
@@ -1332,7 +1332,7 @@ export async function getCommitDate(owner, repo, sha, verbose = false) {
1332
1332
  export async function checkPreviousPRCommitsHadCI(owner, repo, prNumber, headSha, verbose = false) {
1333
1333
  try {
1334
1334
  // Get all commits in the PR
1335
- const { stdout: commitsJson } = await exec(`gh api "repos/${owner}/${repo}/pulls/${prNumber}/commits?per_page=100" --jq '[.[].sha]'`);
1335
+ const { stdout: commitsJson } = await exec(`gh api "repos/${owner}/${repo}/pulls/${prNumber}/commits" --paginate --jq '[.[].sha]'`);
1336
1336
  const allShas = JSON.parse(commitsJson.trim() || '[]');
1337
1337
 
1338
1338
  // Exclude the current HEAD SHA
@@ -1455,8 +1455,8 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false,
1455
1455
  import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1456
1456
  export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
1457
1457
 
1458
- import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus } from './github-merge-repo-actions.lib.mjs'; // Issue #1503
1459
- export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus };
1458
+ import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas } from './github-merge-repo-actions.lib.mjs'; // Issue #1503
1459
+ export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas };
1460
1460
 
1461
1461
  export default {
1462
1462
  READY_LABEL,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Interruptible sleep utility for long-running wait loops.
3
+ *
4
+ * Replaces raw `await new Promise(r => setTimeout(r, ms))` with a sleep
5
+ * that resolves immediately on SIGINT, so the process exit handler chain
6
+ * is not blocked by a lingering timer.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1574
9
+ */
10
+
11
+ /**
12
+ * Sleep for `ms` milliseconds, but resolve early if SIGINT is received.
13
+ *
14
+ * When SIGINT fires during the sleep, the timer is cleared and the promise
15
+ * resolves with `{ interrupted: true }`. The existing SIGINT handler (from
16
+ * exit-handler.lib.mjs) continues to run normally — this function does NOT
17
+ * consume or re-emit the signal, it only ensures its own timer doesn't
18
+ * block the event loop.
19
+ *
20
+ * @param {number} ms - Duration in milliseconds
21
+ * @returns {Promise<{interrupted: boolean}>}
22
+ */
23
+ export function interruptibleSleep(ms) {
24
+ return new Promise(resolve => {
25
+ let timer;
26
+
27
+ const onInterrupt = () => {
28
+ clearTimeout(timer);
29
+ process.removeListener('SIGINT', onInterrupt);
30
+ resolve({ interrupted: true });
31
+ };
32
+
33
+ timer = setTimeout(() => {
34
+ process.removeListener('SIGINT', onInterrupt);
35
+ resolve({ interrupted: false });
36
+ }, ms);
37
+
38
+ process.on('SIGINT', onInterrupt);
39
+ });
40
+ }
41
+
42
+ export default { interruptibleSleep };
@@ -49,6 +49,9 @@ const { extractLinkedIssueNumber } = githubLinking;
49
49
  // Import configuration
50
50
  import { autoContinue, limitReset } from './config.lib.mjs';
51
51
 
52
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
53
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
54
+
52
55
  const { calculateWaitTime } = validation;
53
56
 
54
57
  /**
@@ -116,7 +119,7 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
116
119
  }, countdownInterval);
117
120
 
118
121
  // Wait until reset time
119
- await new Promise(resolve => setTimeout(resolve, waitMs));
122
+ await interruptibleSleep(waitMs);
120
123
  clearInterval(countdownTimer);
121
124
 
122
125
  const actionType = isRestart ? 'Restarting' : 'Resuming';
@@ -54,6 +54,9 @@ import { limitReset } from './config.lib.mjs';
54
54
  const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
55
55
  const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
56
56
 
57
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
58
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
59
+
57
60
  /**
58
61
  * Main function: Watch and restart until PR becomes mergeable
59
62
  * This implements --auto-restart-until-mergeable functionality
@@ -66,8 +69,9 @@ export const watchUntilMergeable = async params => {
66
69
  const MIN_CI_CHECK_INTERVAL_SECONDS = 120;
67
70
  const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
68
71
  const isAutoMerge = argv.autoMerge || false;
69
- // Issue #1503: --wait-for-all-actions-in-repository-before-mergable (default: true)
70
- const waitForAllRepoActionsFlag = argv.waitForAllActionsInRepositoryBeforeMergable ?? argv['wait-for-all-actions-in-repository-before-mergable'] ?? true;
72
+ // Issue #1503/#1573: --wait-for-all-actions-in-repository-before-mergeable
73
+ // When enabled (default: true), blocks merge if ANY CI/CD run in the repo is active — ensures safety when pipelines interact.
74
+ const waitForAllRepoActionsFlag = argv.waitForAllActionsInRepositoryBeforeMergeable ?? argv['wait-for-all-actions-in-repository-before-mergeable'] ?? argv.waitForAllActionsInRepositoryBeforeMergable ?? argv['wait-for-all-actions-in-repository-before-mergable'] ?? false;
71
75
 
72
76
  // Track latest session data across all iterations for accurate pricing
73
77
  let latestSessionId = null;
@@ -104,7 +108,7 @@ export const watchUntilMergeable = async params => {
104
108
  // Issue #1567: Wait for initial cooldown before first check.
105
109
  // This gives CI/CD time to start and solution logs time to be posted.
106
110
  await log(formatAligned('⏳', 'Initial cooldown:', `Waiting ${INITIAL_COOLDOWN_SECONDS}s before first check...`));
107
- await new Promise(resolve => setTimeout(resolve, INITIAL_COOLDOWN_SECONDS * 1000));
111
+ await interruptibleSleep(INITIAL_COOLDOWN_SECONDS * 1000);
108
112
  await log(formatAligned('✅', 'Cooldown complete:', 'Starting monitoring loop'));
109
113
  await log('');
110
114
 
@@ -200,7 +204,7 @@ export const watchUntilMergeable = async params => {
200
204
  if (!noCiConfigured) {
201
205
  const DOUBLE_CHECK_DELAY_MS = 10000; // 10 seconds
202
206
  await log(formatAligned('🔍', 'Multi-mechanism CI consensus check:', `Waiting ${DOUBLE_CHECK_DELAY_MS / 1000}s then verifying...`, 2));
203
- await new Promise(resolve => setTimeout(resolve, DOUBLE_CHECK_DELAY_MS));
207
+ await interruptibleSleep(DOUBLE_CHECK_DELAY_MS);
204
208
 
205
209
  // Run multi-mechanism consensus: Check Runs API + Workflow Runs API + Repo-wide actions
206
210
  const consensus = await checkCIConsensus({
@@ -216,17 +220,20 @@ export const watchUntilMergeable = async params => {
216
220
 
217
221
  if (!consensus.allAgree) {
218
222
  const m = consensus.mechanisms;
219
- 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));
223
+ const repoLabel = m.repoActions.skipped ? 'skipped' : `${m.repoActions.count} active`;
224
+ const commitsLabel = m.allCommitsCI.skipped ? 'skipped' : `${m.allCommitsCI.pendingCommits.length} pending of ${m.allCommitsCI.totalCommits}`;
225
+ await log(formatAligned('🔄', 'CI mechanisms DISAGREE:', `CheckRuns=${m.checkRunsAPI.status}, WorkflowRuns=${m.workflowRunsAPI.inProgress} in-progress, AllCommits=${commitsLabel}, RepoActions=${repoLabel}`, 2));
220
226
  await log(formatAligned('⏳', 'Continuing to monitor...', 'Mechanisms must agree before declaring mergeable', 2));
221
227
  consecutiveNoRunsChecks = 0;
222
228
  lastCheckTime = currentTime;
223
229
  const actualWaitSeconds = currentBackoffSeconds;
224
230
  await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
225
231
  await log('');
226
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
232
+ await interruptibleSleep(actualWaitSeconds * 1000);
227
233
  continue;
228
234
  }
229
- 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));
235
+ const acLabel = consensus.mechanisms.allCommitsCI.skipped ? '' : `, AllCommits=complete(${consensus.mechanisms.allCommitsCI.totalCommits})`;
236
+ await log(formatAligned('✅', 'All CI mechanisms agree:', `CheckRuns=${consensus.mechanisms.checkRunsAPI.status}, WorkflowRuns=complete(${consensus.mechanisms.workflowRunsAPI.total})${acLabel}, RepoActions=${consensus.mechanisms.repoActions.skipped ? 'skipped' : 'clear'}`, 2));
230
237
  } else if (waitForAllRepoActionsFlag) {
231
238
  // Even with no CI configured, check repo-wide actions for absolute safety
232
239
  const repoRuns = await getAllActiveRepoRuns(owner, repo, argv.verbose);
@@ -236,7 +243,7 @@ export const watchUntilMergeable = async params => {
236
243
  const actualWaitSeconds = currentBackoffSeconds;
237
244
  await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
238
245
  await log('');
239
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
246
+ await interruptibleSleep(actualWaitSeconds * 1000);
240
247
  continue;
241
248
  }
242
249
  }
@@ -606,7 +613,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
606
613
  }
607
614
 
608
615
  // Wait until the limit resets
609
- await new Promise(resolve => setTimeout(resolve, waitMs));
616
+ await interruptibleSleep(waitMs);
610
617
 
611
618
  await log(formatAligned('✅', 'Usage limit wait complete', 'Resuming session...'));
612
619
  await log('');
@@ -841,7 +848,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
841
848
  const actualWaitSeconds = currentBackoffSeconds;
842
849
  await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
843
850
  await log('');
844
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
851
+ await interruptibleSleep(actualWaitSeconds * 1000);
845
852
  }
846
853
  };
847
854
 
@@ -186,11 +186,16 @@ 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': {
189
+ 'wait-for-all-actions-in-repository-before-mergeable': {
190
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.',
191
+ description: 'Wait for ALL active GitHub Actions workflow runs in the entire repository to complete before declaring PR mergeable. When enabled, blocks merge if ANY CI/CD run in the repository is active, regardless of branch — this ensures safety when CI/CD pipelines interact or depend on each other. Enabled by default.',
192
192
  default: true,
193
193
  },
194
+ 'wait-for-all-actions-in-repository-before-mergable': {
195
+ type: 'boolean',
196
+ description: 'Deprecated alias for --wait-for-all-actions-in-repository-before-mergeable (fixes typo).',
197
+ hidden: true,
198
+ },
194
199
  'auto-restart-on-non-updated-pull-request-description': {
195
200
  type: 'boolean',
196
201
  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.',
@@ -37,6 +37,9 @@ const { detectAndCountFeedback } = feedbackLib;
37
37
  const restartShared = await import('./solve.restart-shared.lib.mjs');
38
38
  const { checkPRMerged, checkForUncommittedChanges, getUncommittedChangesDetails, executeToolIteration, buildUncommittedChangesFeedback, isApiError } = restartShared;
39
39
 
40
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
41
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
42
+
40
43
  /**
41
44
  * Monitor for feedback in a loop and trigger restart when detected
42
45
  */
@@ -446,7 +449,7 @@ export const watchForFeedback = async params => {
446
449
  const actualWaitMs = actualWaitSeconds * 1000;
447
450
  await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
448
451
  await log(''); // Blank line for readability
449
- await new Promise(resolve => setTimeout(resolve, actualWaitMs));
452
+ await interruptibleSleep(actualWaitMs);
450
453
  } else if (isTemporaryWatch && !firstIterationInTemporaryMode) {
451
454
  // In auto-restart mode, check immediately without waiting
452
455
  await log(formatAligned('', 'Checking immediately for uncommitted changes...', '', 2));