@link-assistant/hive-mind 2.7.2 → 2.7.4

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.7.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 4bf1734: Cancel `/merge` immediately at every stage. Poll delays were uninterruptible `setTimeout` calls, so a cancel was only noticed once the delay expired — up to 5 minutes. Every merge wait now sleeps in short steps and aborts within ~100ms of the Cancel button, including the repo-wide actions wait, which previously had no cancellation support at all.
8
+
9
+ ## 2.7.3
10
+
11
+ ### Patch Changes
12
+
13
+ - e6c67c0: Reset Anthropic cost accounting between fresh auto-restart working sessions while preserving cumulative totals for true session resumes.
14
+
3
15
  ## 2.7.2
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": "2.7.2",
3
+ "version": "2.7.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -29,12 +29,11 @@
29
29
  * shown next to the full-session public estimate covers the same scope. This
30
30
  * module is the single source of truth for that running total:
31
31
  *
32
- * - Each `solve` process seeds the accumulator once from
33
- * `--previous-anthropic-cost` (0 for the first run; the carried-forward
34
- * total for an auto-resumed run).
35
- * - Every finished Claude process adds its own `total_cost_usd` via
36
- * `addAnthropicRunCost`, which also covers the in-process auto-merge loop
37
- * (each iteration is a separate Claude process in the same node process).
32
+ * - Each `executeClaude` call starts a scope. Fresh sessions reset it; true
33
+ * resumes retain it or seed it from `--previous-anthropic-cost` when the
34
+ * resume crosses a process boundary.
35
+ * - Every finished Claude process in that logical session adds its own
36
+ * `total_cost_usd` via `addAnthropicRunCost`.
38
37
  * - The display and the cross-process spawn both read the cumulative total,
39
38
  * so "Calculated by Anthropic" tracks the full session.
40
39
  *
@@ -43,12 +42,12 @@
43
42
  * future model. See docs/case-studies/issue-1886/ for the full analysis.
44
43
  */
45
44
 
46
- // Module-level singleton: the cumulative Anthropic cost for the current logical
47
- // session (this node process plus everything seeded from prior processes).
45
+ // Module-level singleton: the cumulative Anthropic cost for the active logical
46
+ // session (including anything seeded by a true resume from a prior process).
48
47
  let cumulativeAnthropicCostUSD = 0;
49
- // Seeding must happen exactly once per node process. The auto-merge loop calls
50
- // runClaude (and therefore the seed helper) repeatedly within a single process;
51
- // re-seeding from the same CLI flag each time would wipe out accumulation.
48
+ // Seeding must happen exactly once per active scope. Resume retries may call the
49
+ // seed helper repeatedly; re-seeding from the same CLI flag would wipe out the
50
+ // costs already accumulated in this process.
52
51
  let seeded = false;
53
52
 
54
53
  /**
@@ -61,10 +60,34 @@ const toCostAmount = value => {
61
60
  return Number.isFinite(n) && n > 0 ? n : 0;
62
61
  };
63
62
 
63
+ /**
64
+ * Start accounting for one logical Claude session.
65
+ *
66
+ * A fresh execution owns a new transcript and therefore a new cost scope. A
67
+ * true `--resume` execution continues the active scope (or restores it from
68
+ * `--previous-anthropic-cost` when the resume happens in a child process).
69
+ * Calling this at the public `executeClaude` boundary keeps retries inside one
70
+ * scope while preventing watch-mode and mergeability auto-restarts from
71
+ * inheriting the preceding working session's cost.
72
+ *
73
+ * @param {Object} [options]
74
+ * @param {boolean|string} [options.resume=false] - whether this execution resumes an existing Claude session
75
+ * @param {number|string|null} [options.previousAnthropicCost=0] - cumulative cost restored by a resumed child process
76
+ * @returns {number} the cumulative cost at the start of this execution
77
+ */
78
+ export const beginAnthropicCostScope = ({ resume = false, previousAnthropicCost = 0 } = {}) => {
79
+ if (!resume) {
80
+ cumulativeAnthropicCostUSD = 0;
81
+ seeded = true;
82
+ return cumulativeAnthropicCostUSD;
83
+ }
84
+ return seedCumulativeAnthropicCost(previousAnthropicCost);
85
+ };
86
+
64
87
  /**
65
88
  * Seed the accumulator from the carried-forward previous-run cost, exactly once
66
- * per node process. Subsequent calls are no-ops so the in-process auto-merge
67
- * loop does not reset the running total.
89
+ * per active scope. Subsequent calls are no-ops so resume retries do not reset
90
+ * the running total.
68
91
  * @param {number|string|null|undefined} previousAnthropicCostUSD
69
92
  * @returns {number} the cumulative total after seeding
70
93
  */
@@ -98,8 +121,8 @@ export const getCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD;
98
121
  export const hasCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD > 0;
99
122
 
100
123
  /**
101
- * Reset the accumulator. Intended for tests production code seeds once and
102
- * accumulates for the lifetime of the process.
124
+ * Reset the accumulator. Intended for tests; production code starts scopes via
125
+ * `beginAnthropicCostScope`.
103
126
  */
104
127
  export const resetCumulativeAnthropicCost = () => {
105
128
  cumulativeAnthropicCostUSD = 0;
@@ -17,7 +17,7 @@ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
17
17
  import Decimal from 'decimal.js-light';
18
18
  import { createEmptySubSessionUsage, accumulateModelUsage, mergeResultModelUsage, createSubAgentCallEntry, accumulateSubAgentUsage, getRawRequestInputTokens, displaySessionTokenUsage } from './claude.budget-stats.lib.mjs';
19
19
  import { buildClaudeResumeCommand, buildClaudeAutonomousResumeCommand } from './claude.command-builder.lib.mjs';
20
- import { seedCumulativeAnthropicCost, addAnthropicRunCost } from './anthropic-cost-accumulator.lib.mjs'; // Issue #1886
20
+ import { beginAnthropicCostScope, seedCumulativeAnthropicCost, addAnthropicRunCost } from './anthropic-cost-accumulator.lib.mjs'; // Issues #1886, #2056
21
21
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
22
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
23
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
@@ -236,6 +236,8 @@ export const resolveThinkingSettings = async (argv, log) => {
236
236
  export const checkPlaywrightMcpAvailability = ensureClaudePlaywrightMcpServer;
237
237
  export const executeClaude = async params => {
238
238
  const { issueUrl, issueNumber, prNumber, prUrl, branchName, tempDir, workspaceTmpDir, isContinueMode, mergeStateStatus, forkedRepo, feedbackLines, forkActionsUrl, owner, repo, argv, log, setLogFile, getLogFile, formatAligned, getResourceSnapshot, claudePath, $ } = params;
239
+ // Issue #2056: reset fresh sessions while retaining issue #1886's true-resume accumulation.
240
+ beginAnthropicCostScope({ resume: argv.resume, previousAnthropicCost: argv.previousAnthropicCost });
239
241
  if (argv.promptSubagentsViaAgentCommander) {
240
242
  try {
241
243
  await $`which start-agent`;
@@ -304,11 +306,9 @@ export const executeClaude = async params => {
304
306
  }
305
307
  const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
306
308
  const escapedSystemPrompt = systemPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
307
-
308
309
  // Issue #1877: deploy the experimental HANDOFF.md Agent Skill so Claude loads
309
310
  // it natively from .claude/skills/handoff/SKILL.md (no-op unless --use-handoff).
310
311
  await deployHandoffSkill({ tempDir, argv, log, $ });
311
-
312
312
  return await withAgentsMdAsClaudeMd({ tempDir, branchName, argv, prompt, fs, path, $, log, formatAligned }, () =>
313
313
  executeClaudeCommand({
314
314
  tempDir,
@@ -1176,6 +1176,7 @@ export const executeClaudeCommand = async params => {
1176
1176
  // logs the failure and returns false; we fall through to the normal commandFailed return below
1177
1177
  // (the 400 is not a transient pattern, so it is not retried).
1178
1178
  if (commandFailed && retryableLastError.requiresFreshSession && (await tryThinkingBlockRecovery({ classified: retryableLastError, source: 'result', sessionId }))) {
1179
+ beginAnthropicCostScope({ resume: argv.resume, previousAnthropicCost: argv.previousAnthropicCost });
1179
1180
  return await executeWithRetry();
1180
1181
  }
1181
1182
  // Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
@@ -1376,6 +1377,7 @@ export const executeClaudeCommand = async params => {
1376
1377
  // Issue #1834: Corrupted extended-thinking blocks surfaced as a thrown exception. Same recovery
1377
1378
  // as the streamed-result path: resume the session first, then fall back to a fresh restart.
1378
1379
  if (retryableException.requiresFreshSession && (await tryThinkingBlockRecovery({ classified: retryableException, source: 'exception', sessionId }))) {
1380
+ beginAnthropicCostScope({ resume: argv.resume, previousAnthropicCost: argv.previousAnthropicCost });
1379
1381
  retryCount++;
1380
1382
  return await executeWithRetry();
1381
1383
  }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Merge CI Wait Loops
4
+ *
5
+ * The long-running CI polling loops used by the /merge command.
6
+ * Split from github-merge.lib.mjs to maintain file size limits.
7
+ *
8
+ * Every wait here is cancellable: the poll delay is slept in short steps via
9
+ * `cancellableSleep`, so pressing the Cancel button takes effect within ~100ms
10
+ * instead of after a full poll interval.
11
+ *
12
+ * @see https://github.com/link-assistant/hive-mind/issues/2072
13
+ */
14
+
15
+ import { checkPRCIStatus, getActiveBranchRuns } from './github-merge.lib.mjs';
16
+ import { cancellableSleep } from './interruptible-sleep.lib.mjs';
17
+
18
+ /**
19
+ * Wait for CI/CD to complete with polling
20
+ * @param {string} owner - Repository owner
21
+ * @param {string} repo - Repository name
22
+ * @param {number} prNumber - Pull request number
23
+ * @param {Object} options - Wait options
24
+ * @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
25
+ * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
26
+ * @param {Function} options.onStatusUpdate - Callback for status updates
27
+ * @param {boolean} verbose - Whether to log verbose output
28
+ * @returns {Promise<{success: boolean, status: string, error: string|null}>}
29
+ */
30
+ export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
31
+ const {
32
+ timeout = 30 * 60 * 1000,
33
+ pollInterval = 30 * 1000,
34
+ onStatusUpdate = null,
35
+ // Issue #1269: Add timeout for callback to prevent infinite blocking
36
+ callbackTimeout = 60 * 1000, // 1 minute max for callback
37
+ isCancelled = null, // Issue #1407: Support early exit when cancellation is requested
38
+ } = options;
39
+
40
+ const startTime = Date.now();
41
+
42
+ while (Date.now() - startTime < timeout) {
43
+ // Issue #1407: Check for cancellation before each poll to allow early exit
44
+ if (isCancelled?.()) return { success: false, status: 'cancelled', error: 'Operation was cancelled' };
45
+
46
+ let ciStatus;
47
+ try {
48
+ ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
49
+ } catch (error) {
50
+ // Issue #1269: Log and continue on CI check errors instead of crashing
51
+ console.error(`[ERROR] /merge: Error checking CI status for PR #${prNumber}: ${error.message}`);
52
+ verbose && console.error(`[VERBOSE] /merge: CI check error details:`, error);
53
+ // Wait and retry
54
+ await cancellableSleep(pollInterval, isCancelled);
55
+ continue;
56
+ }
57
+
58
+ if (onStatusUpdate) {
59
+ // Issue #1269: Wrap callback with timeout to prevent infinite blocking; #1346: capture and clear timeout handle to prevent dangling timer
60
+ try {
61
+ let callbackTimeoutId;
62
+ await Promise.race([
63
+ onStatusUpdate(ciStatus),
64
+ new Promise((_, reject) => {
65
+ callbackTimeoutId = setTimeout(() => reject(new Error(`Callback timeout after ${callbackTimeout}ms`)), callbackTimeout);
66
+ }),
67
+ ]).finally(() => clearTimeout(callbackTimeoutId));
68
+ } catch (callbackError) {
69
+ // Issue #1269: Log callback errors but continue processing
70
+ console.error(`[ERROR] /merge: Status update callback failed for PR #${prNumber}: ${callbackError.message}`);
71
+ verbose && console.error(`[VERBOSE] /merge: Callback error details:`, callbackError);
72
+ // Continue processing even if callback fails - don't let UI issues block merging
73
+ }
74
+ }
75
+
76
+ if (ciStatus.status === 'success') {
77
+ return { success: true, status: 'success', error: null };
78
+ }
79
+
80
+ if (ciStatus.status === 'failure') {
81
+ return { success: false, status: 'failure', error: 'CI checks failed' };
82
+ }
83
+
84
+ if (ciStatus.status === 'terminal_github_entity_error') {
85
+ return {
86
+ success: false,
87
+ status: 'terminal_github_entity_error',
88
+ error: ciStatus.error || 'GitHub repository, pull request, issue, or branch is no longer accessible',
89
+ };
90
+ }
91
+
92
+ if (ciStatus.status === 'pending') {
93
+ if (verbose) {
94
+ console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
95
+ }
96
+ await cancellableSleep(pollInterval, isCancelled);
97
+ continue;
98
+ }
99
+
100
+ // Unknown status - wait and retry
101
+ await cancellableSleep(pollInterval, isCancelled);
102
+ }
103
+
104
+ return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
105
+ }
106
+
107
+ /**
108
+ * Wait for all active workflow runs on a branch to complete
109
+ * Issue #1307: Ensures all CI runs on target branch are complete before merging
110
+ * @param {string} owner - Repository owner
111
+ * @param {string} repo - Repository name
112
+ * @param {string} branch - Branch name (default: main)
113
+ * @param {Object} options - Wait options
114
+ * @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
115
+ * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
116
+ * @param {Function} options.onStatusUpdate - Callback for status updates
117
+ * @param {boolean} verbose - Whether to log verbose output
118
+ * @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
119
+ */
120
+ export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
121
+ const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null, isCancelled = null } = options;
122
+
123
+ const startTime = Date.now();
124
+ let totalWaitedRuns = 0;
125
+
126
+ if (verbose) {
127
+ console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
128
+ }
129
+
130
+ while (Date.now() - startTime < timeout) {
131
+ if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
132
+ let activeRuns;
133
+ try {
134
+ activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
135
+ } catch (error) {
136
+ // Log and continue on errors
137
+ console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
138
+ await cancellableSleep(pollInterval, isCancelled);
139
+ continue;
140
+ }
141
+
142
+ if (onStatusUpdate) {
143
+ try {
144
+ await onStatusUpdate({
145
+ hasActiveRuns: activeRuns.hasActiveRuns,
146
+ count: activeRuns.count,
147
+ runs: activeRuns.runs,
148
+ elapsedMs: Date.now() - startTime,
149
+ });
150
+ } catch (callbackError) {
151
+ // Log callback errors but continue
152
+ console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
153
+ }
154
+ }
155
+
156
+ if (!activeRuns.hasActiveRuns) {
157
+ if (verbose) {
158
+ console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
159
+ }
160
+ return {
161
+ success: true,
162
+ waitedForRuns: totalWaitedRuns > 0,
163
+ completedRuns: totalWaitedRuns,
164
+ error: null,
165
+ };
166
+ }
167
+
168
+ totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
169
+
170
+ if (verbose) {
171
+ const elapsedSec = Math.round((Date.now() - startTime) / 1000);
172
+ console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
173
+ }
174
+
175
+ await cancellableSleep(pollInterval, isCancelled);
176
+ }
177
+
178
+ // Issue #2072: a cancel landing as the timeout expires must still report 'cancelled'
179
+ // rather than falling through to the extra API round-trip below.
180
+ if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
181
+
182
+ // Timeout reached
183
+ // Issue #1722: if the final check throws, do NOT silently report "ready".
184
+ // Treat it the same as still-active (force a timeout failure), so /merge
185
+ // waits/retries instead of merging on top of a still-running CI run.
186
+ let finalCheck;
187
+ try {
188
+ finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
189
+ } catch (error) {
190
+ return {
191
+ success: false,
192
+ waitedForRuns: true,
193
+ completedRuns: totalWaitedRuns,
194
+ error: `Timeout reached and final CI check failed on ${branch}: ${error.message}`,
195
+ };
196
+ }
197
+ if (finalCheck.hasActiveRuns) {
198
+ return {
199
+ success: false,
200
+ waitedForRuns: true,
201
+ completedRuns: totalWaitedRuns - finalCheck.count,
202
+ error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
203
+ };
204
+ }
205
+
206
+ return {
207
+ success: true,
208
+ waitedForRuns: totalWaitedRuns > 0,
209
+ completedRuns: totalWaitedRuns,
210
+ error: null,
211
+ };
212
+ }
213
+
214
+ export default {
215
+ waitForCI,
216
+ waitForBranchCI,
217
+ };
@@ -12,6 +12,7 @@ import { getWorkflowRunsForSha } from './github-merge.lib.mjs';
12
12
  import { promisify } from 'util';
13
13
  import { exec as execCallback } from 'child_process';
14
14
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
15
+ import { cancellableSleep } from './interruptible-sleep.lib.mjs';
15
16
 
16
17
  const execRaw = promisify(execCallback);
17
18
  // Issue #1726: every gh call must be rate-limit safe.
@@ -54,7 +55,7 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
54
55
  runs = await getWorkflowRunsForSha(owner, repo, sha, verbose);
55
56
  } catch (error) {
56
57
  console.error(`[ERROR] /merge: Error checking commit CI: ${error.message}`);
57
- await new Promise(resolve => setTimeout(resolve, pollInterval));
58
+ await cancellableSleep(pollInterval, isCancelled);
58
59
  continue;
59
60
  }
60
61
 
@@ -71,7 +72,7 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
71
72
  if (verbose) {
72
73
  console.log(`[VERBOSE] /merge: No CI runs yet for commit ${sha.substring(0, 7)} (attempt ${noRunsIterations}/${MAX_NO_RUNS_ITERATIONS}). Waiting...`);
73
74
  }
74
- await new Promise(resolve => setTimeout(resolve, pollInterval));
75
+ await cancellableSleep(pollInterval, isCancelled);
75
76
  continue;
76
77
  }
77
78
 
@@ -134,7 +135,13 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
134
135
  console.log(`[VERBOSE] /merge: Waiting for ${inProgressRuns.length} CI run(s) to complete... (${elapsedSec}s elapsed)`);
135
136
  }
136
137
 
137
- await new Promise(resolve => setTimeout(resolve, pollInterval));
138
+ await cancellableSleep(pollInterval, isCancelled);
139
+ }
140
+
141
+ // Issue #2072: a cancel that lands while the timeout is expiring must still report
142
+ // 'cancelled' rather than falling through to an extra API round-trip below.
143
+ if (isCancelled?.()) {
144
+ return { success: false, status: 'cancelled', runs: [], failedRuns: [], error: 'Operation was cancelled' };
138
145
  }
139
146
 
140
147
  // Timeout reached
@@ -13,6 +13,7 @@ import { promisify } from 'util';
13
13
  import { exec as execCallback } from 'child_process';
14
14
  import { githubLimits } from './config.lib.mjs';
15
15
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
16
+ import { cancellableSleep } from './interruptible-sleep.lib.mjs';
16
17
  const execRaw = promisify(execCallback);
17
18
  // Issue #1722: raise exec maxBuffer above Node's 1 MB default for paginated gh
18
19
  // API responses (workflow runs can easily exceed that on busy repos).
@@ -70,15 +71,21 @@ export async function getAllActiveRepoRuns(owner, repo, verbose = false) {
70
71
  * @param {string} owner - Repository owner
71
72
  * @param {string} repo - Repository name
72
73
  * @param {Object} options - Wait options (timeout, pollInterval, onStatusUpdate)
74
+ * @param {Function} [options.isCancelled] - Issue #2072: polled during the poll delay so a
75
+ * cancel (or SIGINT/SIGTERM) aborts the wait instead of sleeping out a full 5-minute interval
73
76
  * @param {boolean} verbose - Whether to log verbose output
74
77
  * @returns {Promise<{success: boolean, waitedForRuns: boolean, timedOut: boolean, remainingRuns: Array}>}
75
78
  */
76
79
  export async function waitForAllRepoActions(owner, repo, options = {}, verbose = false) {
77
- const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null } = options;
80
+ const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null, isCancelled = null } = options;
78
81
  const startTime = Date.now();
79
82
  let peakRunCount = 0;
80
83
 
81
84
  while (Date.now() - startTime < timeout) {
85
+ // Issue #2072: no stage of the merge flow may hold up a cancel.
86
+ if (isCancelled?.()) {
87
+ return { success: false, waitedForRuns: peakRunCount > 0, timedOut: false, cancelled: true, remainingRuns: [] };
88
+ }
82
89
  let active;
83
90
  try {
84
91
  active = await getAllActiveRepoRuns(owner, repo, verbose);
@@ -86,7 +93,7 @@ export async function waitForAllRepoActions(owner, repo, options = {}, verbose =
86
93
  // Issue #1722: do not silently treat fetch errors as "no active runs".
87
94
  // Log and retry on the next poll instead.
88
95
  console.error(`[ERROR] repo-actions: Error checking repo CI: ${error.message}`);
89
- await new Promise(resolve => setTimeout(resolve, pollInterval));
96
+ await cancellableSleep(pollInterval, isCancelled);
90
97
  continue;
91
98
  }
92
99
  if (onStatusUpdate) {
@@ -100,7 +107,11 @@ export async function waitForAllRepoActions(owner, repo, options = {}, verbose =
100
107
  return { success: true, waitedForRuns: peakRunCount > 0, timedOut: false, remainingRuns: [] };
101
108
  }
102
109
  peakRunCount = Math.max(peakRunCount, active.count);
103
- await new Promise(resolve => setTimeout(resolve, pollInterval));
110
+ await cancellableSleep(pollInterval, isCancelled);
111
+ }
112
+ // Issue #2072: a cancel landing as the timeout expires must not trigger another API round-trip.
113
+ if (isCancelled?.()) {
114
+ return { success: false, waitedForRuns: peakRunCount > 0, timedOut: false, cancelled: true, remainingRuns: [] };
104
115
  }
105
116
  // Issue #1722: if the timeout-final check throws, surface that as an error
106
117
  // rather than reporting "no remaining runs".
@@ -20,6 +20,7 @@ import { parseGitHubUrl } from './github.lib.mjs';
20
20
  import { githubLimits } from './config.lib.mjs';
21
21
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
22
22
  import { getTerminalGitHubEntityErrorMessage, isTerminalGitHubEntityError } from './github-terminal-state.lib.mjs';
23
+ import { cancellableSleep } from './interruptible-sleep.lib.mjs';
23
24
 
24
25
  // Issue #1722: gh api `--paginate --slurp` responses for repos with many
25
26
  // historical workflow runs can easily exceed Node's default 1 MB exec buffer
@@ -49,6 +50,11 @@ export { syncReadyTags, getLinkedPRsFromTimeline, READY_LABEL };
49
50
  import { closeLinkedIssueIfNotAutoClosed } from './github-merge-issue-close.lib.mjs';
50
51
  export { closeLinkedIssueIfNotAutoClosed };
51
52
 
53
+ // Issue #2072: the long CI polling loops live in their own module (file size limit).
54
+ // Re-exported here so existing importers keep working.
55
+ import { waitForCI, waitForBranchCI } from './github-merge-ci-wait.lib.mjs';
56
+ export { waitForCI, waitForBranchCI };
57
+
52
58
  /**
53
59
  * Check if 'ready' label exists in repository
54
60
  * @param {string} owner - Repository owner
@@ -446,15 +452,19 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
446
452
  * @param {string} repo - Repository name
447
453
  * @param {number} prNumber - Pull request number
448
454
  * @param {boolean} verbose - Whether to log verbose output
449
- * @returns {Promise<{mergeable: boolean, mergeableState?: string|null, mergeStateStatus?: string|null, reason: string|null, terminal?: boolean}>}
455
+ * @param {Object} [options] - Extra options
456
+ * @param {Function} [options.isCancelled] - Issue #2072: polled during the retry delay so a cancel aborts the wait
457
+ * @returns {Promise<{mergeable: boolean, mergeableState?: string|null, mergeStateStatus?: string|null, reason: string|null, terminal?: boolean, cancelled?: boolean}>}
450
458
  */
451
- export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
459
+ export async function checkPRMergeable(owner, repo, prNumber, verbose = false, options = {}) {
460
+ const { isCancelled = null } = options;
452
461
  // Issue #1339: GitHub computes mergeability asynchronously. When mergeStateStatus is
453
462
  // 'UNKNOWN', it means GitHub hasn't calculated the merge state yet. Retry a few times.
454
463
  const MAX_UNKNOWN_RETRIES = 3;
455
464
  const UNKNOWN_RETRY_DELAY_MS = 5000;
456
465
 
457
466
  for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
467
+ if (isCancelled?.()) return { mergeable: false, reason: 'Operation was cancelled', cancelled: true };
458
468
  try {
459
469
  const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
460
470
  const pr = JSON.parse(stdout.trim());
@@ -466,7 +476,7 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
466
476
  if (verbose) {
467
477
  console.log(`[VERBOSE] /merge: PR #${prNumber} mergeability is UNKNOWN (attempt ${attempt + 1}/${MAX_UNKNOWN_RETRIES}), retrying in ${UNKNOWN_RETRY_DELAY_MS / 1000}s...`);
468
478
  }
469
- await new Promise(resolve => setTimeout(resolve, UNKNOWN_RETRY_DELAY_MS));
479
+ await cancellableSleep(UNKNOWN_RETRY_DELAY_MS, isCancelled);
470
480
  continue;
471
481
  }
472
482
  // All retries exhausted, still UNKNOWN - treat as not mergeable
@@ -604,95 +614,6 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
604
614
  }
605
615
  }
606
616
 
607
- /**
608
- * Wait for CI/CD to complete with polling
609
- * @param {string} owner - Repository owner
610
- * @param {string} repo - Repository name
611
- * @param {number} prNumber - Pull request number
612
- * @param {Object} options - Wait options
613
- * @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
614
- * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
615
- * @param {Function} options.onStatusUpdate - Callback for status updates
616
- * @param {boolean} verbose - Whether to log verbose output
617
- * @returns {Promise<{success: boolean, status: string, error: string|null}>}
618
- */
619
- export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
620
- const {
621
- timeout = 30 * 60 * 1000,
622
- pollInterval = 30 * 1000,
623
- onStatusUpdate = null,
624
- // Issue #1269: Add timeout for callback to prevent infinite blocking
625
- callbackTimeout = 60 * 1000, // 1 minute max for callback
626
- isCancelled = null, // Issue #1407: Support early exit when cancellation is requested
627
- } = options;
628
-
629
- const startTime = Date.now();
630
-
631
- while (Date.now() - startTime < timeout) {
632
- // Issue #1407: Check for cancellation before each poll to allow early exit
633
- if (isCancelled?.()) return { success: false, status: 'cancelled', error: 'Operation was cancelled' };
634
-
635
- let ciStatus;
636
- try {
637
- ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
638
- } catch (error) {
639
- // Issue #1269: Log and continue on CI check errors instead of crashing
640
- console.error(`[ERROR] /merge: Error checking CI status for PR #${prNumber}: ${error.message}`);
641
- verbose && console.error(`[VERBOSE] /merge: CI check error details:`, error);
642
- // Wait and retry
643
- await new Promise(resolve => setTimeout(resolve, pollInterval));
644
- continue;
645
- }
646
-
647
- if (onStatusUpdate) {
648
- // Issue #1269: Wrap callback with timeout to prevent infinite blocking; #1346: capture and clear timeout handle to prevent dangling timer
649
- try {
650
- let callbackTimeoutId;
651
- await Promise.race([
652
- onStatusUpdate(ciStatus),
653
- new Promise((_, reject) => {
654
- callbackTimeoutId = setTimeout(() => reject(new Error(`Callback timeout after ${callbackTimeout}ms`)), callbackTimeout);
655
- }),
656
- ]).finally(() => clearTimeout(callbackTimeoutId));
657
- } catch (callbackError) {
658
- // Issue #1269: Log callback errors but continue processing
659
- console.error(`[ERROR] /merge: Status update callback failed for PR #${prNumber}: ${callbackError.message}`);
660
- verbose && console.error(`[VERBOSE] /merge: Callback error details:`, callbackError);
661
- // Continue processing even if callback fails - don't let UI issues block merging
662
- }
663
- }
664
-
665
- if (ciStatus.status === 'success') {
666
- return { success: true, status: 'success', error: null };
667
- }
668
-
669
- if (ciStatus.status === 'failure') {
670
- return { success: false, status: 'failure', error: 'CI checks failed' };
671
- }
672
-
673
- if (ciStatus.status === 'terminal_github_entity_error') {
674
- return {
675
- success: false,
676
- status: 'terminal_github_entity_error',
677
- error: ciStatus.error || 'GitHub repository, pull request, issue, or branch is no longer accessible',
678
- };
679
- }
680
-
681
- if (ciStatus.status === 'pending') {
682
- if (verbose) {
683
- console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
684
- }
685
- await new Promise(resolve => setTimeout(resolve, pollInterval));
686
- continue;
687
- }
688
-
689
- // Unknown status - wait and retry
690
- await new Promise(resolve => setTimeout(resolve, pollInterval));
691
- }
692
-
693
- return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
694
- }
695
-
696
617
  /**
697
618
  * Parse and validate a repository URL for the merge command
698
619
  * @param {string} url - Repository URL
@@ -782,109 +703,6 @@ export async function getActiveBranchRuns(owner, repo, branch = 'main', verbose
782
703
  };
783
704
  }
784
705
 
785
- /**
786
- * Wait for all active workflow runs on a branch to complete
787
- * Issue #1307: Ensures all CI runs on target branch are complete before merging
788
- * @param {string} owner - Repository owner
789
- * @param {string} repo - Repository name
790
- * @param {string} branch - Branch name (default: main)
791
- * @param {Object} options - Wait options
792
- * @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
793
- * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
794
- * @param {Function} options.onStatusUpdate - Callback for status updates
795
- * @param {boolean} verbose - Whether to log verbose output
796
- * @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
797
- */
798
- export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
799
- const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null, isCancelled = null } = options;
800
-
801
- const startTime = Date.now();
802
- let totalWaitedRuns = 0;
803
-
804
- if (verbose) {
805
- console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
806
- }
807
-
808
- while (Date.now() - startTime < timeout) {
809
- if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
810
- let activeRuns;
811
- try {
812
- activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
813
- } catch (error) {
814
- // Log and continue on errors
815
- console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
816
- await new Promise(resolve => setTimeout(resolve, pollInterval));
817
- continue;
818
- }
819
-
820
- if (onStatusUpdate) {
821
- try {
822
- await onStatusUpdate({
823
- hasActiveRuns: activeRuns.hasActiveRuns,
824
- count: activeRuns.count,
825
- runs: activeRuns.runs,
826
- elapsedMs: Date.now() - startTime,
827
- });
828
- } catch (callbackError) {
829
- // Log callback errors but continue
830
- console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
831
- }
832
- }
833
-
834
- if (!activeRuns.hasActiveRuns) {
835
- if (verbose) {
836
- console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
837
- }
838
- return {
839
- success: true,
840
- waitedForRuns: totalWaitedRuns > 0,
841
- completedRuns: totalWaitedRuns,
842
- error: null,
843
- };
844
- }
845
-
846
- totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
847
-
848
- if (verbose) {
849
- const elapsedSec = Math.round((Date.now() - startTime) / 1000);
850
- console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
851
- }
852
-
853
- await new Promise(resolve => setTimeout(resolve, pollInterval));
854
- }
855
-
856
- // Timeout reached
857
- // Issue #1722: if the final check throws, do NOT silently report "ready".
858
- // Treat it the same as still-active (force a timeout failure), so /merge
859
- // waits/retries instead of merging on top of a still-running CI run.
860
- let finalCheck;
861
- try {
862
- finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
863
- } catch (error) {
864
- return {
865
- success: false,
866
- waitedForRuns: true,
867
- completedRuns: totalWaitedRuns,
868
- error: `Timeout reached and final CI check failed on ${branch}: ${error.message}`,
869
- };
870
- }
871
- if (finalCheck.hasActiveRuns) {
872
- return {
873
- success: false,
874
- waitedForRuns: true,
875
- completedRuns: totalWaitedRuns - finalCheck.count,
876
- error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
877
- };
878
- }
879
-
880
- return {
881
- success: true,
882
- waitedForRuns: totalWaitedRuns > 0,
883
- completedRuns: totalWaitedRuns,
884
- error: null,
885
- };
886
- }
887
-
888
706
  /**
889
707
  * Get the default branch for a repository
890
708
  * @param {string} owner - Repository owner
@@ -49,4 +49,37 @@ export function interruptibleSleep(ms) {
49
49
  });
50
50
  }
51
51
 
52
- export default { interruptibleSleep };
52
+ /**
53
+ * Sleep for `ms` milliseconds, resolving early if SIGINT/SIGTERM arrives or if
54
+ * `isCancelled()` starts returning true.
55
+ *
56
+ * Issue #2072: polling loops used to `await new Promise(r => setTimeout(r, pollInterval))`
57
+ * and only re-check cancellation on the next iteration. With a 30s poll interval that
58
+ * made `/merge` keep running for up to a full interval after the Cancel button was
59
+ * pressed. Sleeping in short steps lets cancellation take effect within `stepMs`.
60
+ *
61
+ * @param {number} ms - Duration in milliseconds
62
+ * @param {Function|null} isCancelled - Predicate polled during the sleep
63
+ * @param {Object} [options]
64
+ * @param {number} [options.stepMs=100] - Granularity at which `isCancelled` is polled
65
+ * @returns {Promise<{interrupted: boolean, cancelled: boolean}>}
66
+ */
67
+ export async function cancellableSleep(ms, isCancelled = null, options = {}) {
68
+ const { stepMs = 100 } = options;
69
+
70
+ if (!isCancelled) {
71
+ const { interrupted } = await interruptibleSleep(ms);
72
+ return { interrupted, cancelled: false };
73
+ }
74
+
75
+ const deadline = Date.now() + ms;
76
+ while (Date.now() < deadline) {
77
+ if (isCancelled()) return { interrupted: false, cancelled: true };
78
+ const { interrupted } = await interruptibleSleep(Math.min(stepMs, deadline - Date.now()));
79
+ if (interrupted) return { interrupted: true, cancelled: isCancelled() };
80
+ }
81
+
82
+ return { interrupted: false, cancelled: isCancelled() };
83
+ }
84
+
85
+ export default { interruptibleSleep, cancellableSleep };
@@ -181,7 +181,11 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
181
181
  // cost folded in at the runClaude return, plus anything carried from prior
182
182
  // iterations via --previous-anthropic-cost.
183
183
  const carriedAnthropicCost = getCumulativeAnthropicCost();
184
- if (carriedAnthropicCost > 0) {
184
+ // Issue #2056: only a real resume reads the same transcript and belongs to
185
+ // the same cost scope. A configured auto-restart deliberately starts a
186
+ // fresh Claude session, so carrying the old cost would recreate the exact
187
+ // cross-session overcount reported in the issue.
188
+ if (!isRestart && carriedAnthropicCost > 0) {
185
189
  resumeArgs.push('--previous-anthropic-cost', String(carriedAnthropicCost));
186
190
  await log(`💰 Carrying forward cumulative Anthropic cost: $${carriedAnthropicCost.toFixed(6)} (issue #1886)`, { verbose: true });
187
191
  }
@@ -21,6 +21,7 @@ import { resolveMergeTargetItems } from './github-merge-targets.lib.mjs';
21
21
  import { waitForPRReady as waitForPRReadyHelper } from './telegram-merge-wait.lib.mjs';
22
22
  import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
23
23
  import { getProgressBar } from './limits.lib.mjs';
24
+ import { cancellableSleep as cancellableSleepUntil } from './interruptible-sleep.lib.mjs';
24
25
 
25
26
  /**
26
27
  * Status enum for merge queue operations
@@ -495,7 +496,17 @@ export class MergeQueueProcessor {
495
496
  try {
496
497
  // Step 1: Check if PR is mergeable
497
498
  item.status = MergeItemStatus.CHECKING_CI;
498
- const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
499
+ // Issue #2072: pass cancellation down so the UNKNOWN-mergeability retry delay aborts early
500
+ const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose, { isCancelled: () => this.isCancelled });
501
+
502
+ // Issue #2072: a cancel during the mergeability check must skip the PR, not fail it.
503
+ if (mergeableCheck.cancelled || this.isCancelled) {
504
+ item.status = MergeItemStatus.SKIPPED;
505
+ item.error = 'Cancelled';
506
+ this.stats.skipped++;
507
+ this.log(`Skipped PR #${item.pr.number}: cancelled during mergeability check`);
508
+ return;
509
+ }
499
510
 
500
511
  if (mergeableCheck.terminal) {
501
512
  item.status = MergeItemStatus.FAILED;
@@ -893,7 +904,7 @@ export class MergeQueueProcessor {
893
904
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
894
905
  return { outcome: 'error', error: error.message };
895
906
  }
896
- await this.cancellableSleep(pollInterval);
907
+ await this.sleep(pollInterval);
897
908
  continue;
898
909
  }
899
910
 
@@ -912,27 +923,12 @@ export class MergeQueueProcessor {
912
923
  }
913
924
  }
914
925
 
915
- await this.cancellableSleep(pollInterval);
926
+ await this.sleep(pollInterval);
916
927
  }
917
928
 
918
929
  return { outcome: 'timeout' };
919
930
  }
920
931
 
921
- /**
922
- * Issue #1807: Sleep helper that bails out as soon as cancellation is
923
- * requested. Used by the auto-resolve poll loop so a `cancel()` call
924
- * doesn't have to wait a full polling interval before taking effect.
925
- */
926
- async cancellableSleep(ms) {
927
- const step = Math.min(ms, 1000);
928
- const deadline = Date.now() + ms;
929
- while (Date.now() < deadline) {
930
- if (this.isCancelled) return;
931
- const remaining = deadline - Date.now();
932
- await this.sleep(Math.min(step, remaining));
933
- }
934
- }
935
-
936
932
  /**
937
933
  * Wait for any active CI runs on the target branch to complete
938
934
  * Issue #1307: Prevents merging while post-merge CI from previous merges is still running
@@ -1456,10 +1452,17 @@ export class MergeQueueProcessor {
1456
1452
  }
1457
1453
 
1458
1454
  /**
1459
- * Sleep helper
1455
+ * Sleep helper.
1456
+ *
1457
+ * Issue #2072: this is the single sleep primitive for the queue, and it is
1458
+ * cancellable — it returns as soon as `cancel()` is called (checked every
1459
+ * 100ms) or SIGINT/SIGTERM arrives, instead of sleeping out the full delay.
1460
+ * Every wait in the queue routes through here, so no stage of `/merge` can
1461
+ * hold up a cancel by more than ~100ms. This supersedes the separate
1462
+ * `cancellableSleep` helper added for #1807.
1460
1463
  */
1461
1464
  sleep(ms) {
1462
- return new Promise(resolve => setTimeout(resolve, ms));
1465
+ return cancellableSleepUntil(ms, () => this.isCancelled);
1463
1466
  }
1464
1467
  }
1465
1468
 
@@ -65,8 +65,14 @@ export async function waitForPRReady(processor, item, initialCheck, options) {
65
65
  await processor.onProgress(processor.getProgressUpdate());
66
66
  }
67
67
 
68
+ // Issue #2072: `processor.sleep` returns early on cancel. The poll interval defaults
69
+ // to 5 minutes, which previously kept `/merge` running long after Cancel was pressed.
68
70
  await processor.sleep(pollIntervalMs);
69
- latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose);
71
+ if (processor.isCancelled) {
72
+ return { success: false, status: 'cancelled', error: 'Cancelled' };
73
+ }
74
+
75
+ latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose, { isCancelled: () => processor.isCancelled });
70
76
  }
71
77
  }
72
78