@link-assistant/hive-mind 1.71.0 → 1.72.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.72.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fffdfbf: Add experimental `--resume-on-auto-restart` support for resuming Claude auto-restart sessions with a minimal uncommitted-change prompt.
8
+
9
+ ## 1.71.1
10
+
11
+ ### Patch Changes
12
+
13
+ - aae5a08: Serialize merge queue auto-resolve sessions so conflicting pull requests resolve and drain CI one at a time.
14
+
3
15
  ## 1.71.0
4
16
 
5
17
  ### Minor Changes
package/CLAUDE.md CHANGED
@@ -1,5 +1,5 @@
1
- Issue to solve: https://github.com/link-assistant/hive-mind/issues/962
2
- Your prepared branch: issue-962-7502f60e53a0
3
- Your prepared working directory: /tmp/gh-issue-solver-1766423610256
1
+ Issue to solve: undefined
2
+ Your prepared branch: issue-644-cf3b8243
3
+ Your prepared working directory: /tmp/gh-issue-solver-1762010397765
4
4
 
5
5
  Proceed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.71.0",
3
+ "version": "1.72.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",
@@ -17,6 +17,11 @@ import { buildWorkLanguageDirective } from './work-language.prompts.lib.mjs';
17
17
  export const buildUserPrompt = params => {
18
18
  const { issueUrl, issueNumber, prNumber, prUrl, branchName, tempDir, workspaceTmpDir, isContinueMode, forkedRepo, feedbackLines, owner, repo, argv, contributingGuidelines, claudeVersion } = params;
19
19
 
20
+ if (argv?.minimalRestartContext && argv.resume) {
21
+ const lines = feedbackLines && feedbackLines.length > 0 ? feedbackLines : ['Continue the auto-restart from the previous resumed session.'];
22
+ return `${lines.join('\n')}\n`;
23
+ }
24
+
20
25
  const promptLines = [];
21
26
 
22
27
  // Issue or PR reference
@@ -87,6 +92,10 @@ export const buildUserPrompt = params => {
87
92
  export const buildSystemPrompt = params => {
88
93
  const { owner, repo, issueNumber, prNumber, branchName, workspaceTmpDir, argv, modelSupportsVision, forkedRepo } = params;
89
94
 
95
+ if (argv?.minimalRestartContext && argv.resume) {
96
+ return '';
97
+ }
98
+
90
99
  // When in fork mode, screenshots are pushed to the fork, not the original repo
91
100
  const screenshotRepoPath = argv?.fork && forkedRepo ? forkedRepo : `${owner}/${repo}`;
92
101
 
@@ -662,6 +662,15 @@ export const mergeQueue = {
662
662
  // Issue #1341: Polling interval for post-merge CI status (in milliseconds)
663
663
  // Default: 30 seconds (30000ms) - balance between responsiveness and API rate limits
664
664
  postMergeCIPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_POST_MERGE_CI_POLL_INTERVAL_MS', 30 * 1000),
665
+ // Issue #1807: Timeout (ms) the sequential auto-resolve pass will wait for
666
+ // a single `/solve <pr> --auto-merge` session to land its PR. Conflict-
667
+ // resolution sessions can be long-running because Claude has to recompute
668
+ // merges and re-run CI; default is 4 hours.
669
+ autoResolveWaitTimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_AUTO_RESOLVE_WAIT_TIMEOUT_MS', 4 * 60 * 60 * 1000),
670
+ // Issue #1807: Polling interval (ms) for `gh pr view` lifecycle checks
671
+ // during the auto-resolve wait. 60 seconds balances responsiveness with
672
+ // GitHub API rate limits over the timeout window above.
673
+ autoResolvePollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_AUTO_RESOLVE_POLL_INTERVAL_MS', 60 * 1000),
665
674
  };
666
675
 
667
676
  // Helper function to validate configuration values
@@ -289,8 +289,42 @@ export async function getMergeCommitSha(owner, repo, prNumber, verbose = false)
289
289
  }
290
290
  }
291
291
 
292
+ /**
293
+ * Get the lifecycle state of a pull request (OPEN / CLOSED / MERGED) along
294
+ * with its mergeability state. Used by the sequential auto-resolve pass
295
+ * (issue #1807) to poll until a `/solve <pr> --auto-merge` session either
296
+ * lands the PR or fails.
297
+ *
298
+ * @param {string} owner - Repository owner
299
+ * @param {string} repo - Repository name
300
+ * @param {number} prNumber - Pull request number
301
+ * @param {boolean} verbose - Whether to log verbose output
302
+ * @returns {Promise<{state: string|null, mergeStateStatus: string|null, mergeable: string|null, error: string|null}>}
303
+ */
304
+ export async function getPRStatus(owner, repo, prNumber, verbose = false) {
305
+ try {
306
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json state,mergeStateStatus,mergeable`);
307
+ const pr = JSON.parse(stdout.trim());
308
+ if (verbose) {
309
+ console.log(`[VERBOSE] /merge: PR #${prNumber} state=${pr.state}, mergeStateStatus=${pr.mergeStateStatus}, mergeable=${pr.mergeable}`);
310
+ }
311
+ return {
312
+ state: pr.state || null,
313
+ mergeStateStatus: pr.mergeStateStatus || null,
314
+ mergeable: pr.mergeable || null,
315
+ error: null,
316
+ };
317
+ } catch (error) {
318
+ if (verbose) {
319
+ console.log(`[VERBOSE] /merge: Error getting PR #${prNumber} status: ${error.message}`);
320
+ }
321
+ return { state: null, mergeStateStatus: null, mergeable: null, error: error.message };
322
+ }
323
+ }
324
+
292
325
  export default {
293
326
  waitForCommitCI,
294
327
  checkBranchCIHealth,
295
328
  getMergeCommitSha,
329
+ getPRStatus,
296
330
  };
@@ -1389,8 +1389,9 @@ import { getCommitDate, checkPreviousPRCommitsHadCI, checkWorkflowsHavePRTrigger
1389
1389
  export { getCommitDate, checkPreviousPRCommitsHadCI, checkWorkflowsHavePRTriggers };
1390
1390
 
1391
1391
  // Issue #1341: Re-export post-merge CI functions from separate module
1392
- import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1393
- export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
1392
+ // Issue #1807: getPRStatus is used by the sequential auto-resolve pass to poll PR lifecycle state.
1393
+ import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus } from './github-merge-ci.lib.mjs';
1394
+ export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus };
1394
1395
 
1395
1396
  import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas, getActivePRWorkflowRuns } from './github-merge-repo-actions.lib.mjs'; // Issue #1503, #1712
1396
1397
  export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus, checkAllPRCommitsCI, getPRCommitShas, getActivePRWorkflowRuns };
@@ -1427,6 +1428,7 @@ export default {
1427
1428
  waitForCommitCI,
1428
1429
  checkBranchCIHealth,
1429
1430
  getMergeCommitSha,
1431
+ getPRStatus, // Issue #1807: sequential auto-resolve PR lifecycle polling
1430
1432
  getActiveRepoWorkflows,
1431
1433
  getCommitDate,
1432
1434
  checkPreviousPRCommitsHadCI,
@@ -187,6 +187,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
187
187
  description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
188
188
  default: 5,
189
189
  },
190
+ 'resume-on-auto-restart': {
191
+ type: 'boolean',
192
+ description: '[EXPERIMENTAL] Resume the previous Claude session on uncommitted-change auto-restart and send only a minimal restart prompt. Disabled by default.',
193
+ default: false,
194
+ },
190
195
  'auto-resume-max-iterations': {
191
196
  type: 'number',
192
197
  description: 'Maximum number of automatic resume/restart continuations after usage-limit resets (default: 5, 0 = unlimited)',
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Generate minimal prompt for auto-restart with session resume
5
+ * This module provides functions to create lightweight prompts for auto-restart
6
+ * that assume the AI has full context from the previous session
7
+ *
8
+ * Part of the cost optimization feature for issue #661
9
+ * @see case-studies/issue-661-session-resume-cost-optimization/
10
+ */
11
+
12
+ // Note: This module does not import $ directly
13
+ // Functions receive $ as a parameter from the calling module
14
+ // This ensures consistent command executor usage across the codebase
15
+
16
+ /**
17
+ * Generate minimal prompt for auto-restart with session resume
18
+ * This prompt assumes the AI has full context from the previous session
19
+ * Target: ~500 tokens (compared to 50k-200k in full context)
20
+ *
21
+ * @param {string} tempDir - Working directory
22
+ * @param {object} $ - Command executor
23
+ * @returns {Promise<string>} Minimal restart prompt
24
+ */
25
+ export const generateMinimalRestartPrompt = async (tempDir, $) => {
26
+ // Get uncommitted changes
27
+ const gitStatus = await $({ cwd: tempDir })`git status --porcelain`;
28
+ const uncommittedFiles = gitStatus.stdout.toString().trim();
29
+
30
+ // Get brief diff summaries (not full diffs to keep the prompt minimal)
31
+ const gitDiffStat = await $({ cwd: tempDir })`git diff --stat`;
32
+ const unstagedDiffSummary = gitDiffStat.stdout.toString().trim();
33
+ const gitCachedDiffStat = await $({ cwd: tempDir })`git diff --cached --stat`;
34
+ const stagedDiffSummary = gitCachedDiffStat.stdout.toString().trim();
35
+ const summarySections = [];
36
+ if (unstagedDiffSummary) summarySections.push(`Unstaged changes:\n${unstagedDiffSummary}`);
37
+ if (stagedDiffSummary) summarySections.push(`Staged changes:\n${stagedDiffSummary}`);
38
+ const diffSummary = summarySections.join('\n\n') || 'No tracked-file diff summary available.';
39
+
40
+ // Count changes
41
+ const fileCount = uncommittedFiles.split('\n').filter(line => line.trim()).length;
42
+
43
+ return `🔄 Auto-restart: resume the previous session and handle its uncommitted changes.
44
+
45
+ Uncommitted files (${fileCount}):
46
+ ${uncommittedFiles}
47
+
48
+ Changes summary:
49
+ ${diffSummary}
50
+
51
+ Please review these changes and commit them with an appropriate commit message.
52
+ Follow the repository's commit message conventions from previous commits.`;
53
+ };
54
+
55
+ /**
56
+ * Generate full context prompt (fallback when resume fails or not enabled)
57
+ * This is used when session resume is not available or failed
58
+ *
59
+ * @param {string} issueUrl - Issue URL
60
+ * @param {string} issueBody - Issue description
61
+ * @param {number} prNumber - PR number
62
+ * @param {Array<string>} feedbackLines - Feedback from reviewers
63
+ * @param {string} tempDir - Working directory
64
+ * @param {object} $ - Command executor
65
+ * @returns {Promise<string>} Full restart prompt
66
+ */
67
+ export const generateFullRestartPrompt = async (issueUrl, issueBody, prNumber, feedbackLines, tempDir, $) => {
68
+ // Get uncommitted changes with full diff
69
+ const gitStatus = await $({ cwd: tempDir })`git status --porcelain`;
70
+ const uncommittedFiles = gitStatus.stdout.toString().trim();
71
+
72
+ const gitDiff = await $({ cwd: tempDir })`git diff`;
73
+ const fullDiff = gitDiff.stdout.toString();
74
+
75
+ let prompt = `
76
+ Continuing work on issue: ${issueUrl}
77
+
78
+ Previous session completed but left uncommitted changes.
79
+ `.trim();
80
+
81
+ if (feedbackLines && feedbackLines.length > 0) {
82
+ prompt += `\n\nFeedback from reviewers:\n${feedbackLines.join('\n')}`;
83
+ }
84
+
85
+ prompt += `\n\nUncommitted changes:\n${uncommittedFiles}\n\nFull diff:\n${fullDiff}`;
86
+
87
+ prompt += '\n\nPlease review these changes and commit them appropriately.';
88
+
89
+ return prompt;
90
+ };
package/src/solve.mjs CHANGED
@@ -826,6 +826,13 @@ try {
826
826
  limitReached = toolResult.limitReached;
827
827
  cleanupContext.limitReached = limitReached;
828
828
 
829
+ if (sessionId && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart'])) {
830
+ global.previousSessionId = sessionId;
831
+ if (argv.verbose) {
832
+ await log(`Session ID stored for auto-restart resume: ${sessionId}`, { verbose: true });
833
+ }
834
+ }
835
+
829
836
  // Capture limit reset time and timezone globally for downstream handlers (auto-continue, cleanup decisions)
830
837
  if (toolResult && toolResult.limitResetTime) {
831
838
  global.limitResetTime = toolResult.limitResetTime;
@@ -290,6 +290,38 @@ export const watchForFeedback = async params => {
290
290
  // to comments posted during *this* iteration only, not across the whole watch loop.
291
291
  const iterationStartTime = new Date();
292
292
 
293
+ let restartFeedbackLines = feedbackLines;
294
+ let restartArgv = argv;
295
+ const shouldUseSessionResume = Boolean(isTemporaryWatch && (firstIterationInTemporaryMode || hasUncommittedInTempMode) && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart']) && (argv.tool === 'claude' || !argv.tool) && global.previousSessionId);
296
+
297
+ if (shouldUseSessionResume) {
298
+ await log(formatAligned('', 'Experimental session resume: using minimal auto-restart prompt', '', 2));
299
+ await log(formatAligned('', `Resuming session: ${global.previousSessionId}`, '', 2));
300
+
301
+ if (argv.verbose) {
302
+ try {
303
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
304
+ const tokenUsage = await calculateSessionTokens(global.previousSessionId, tempDir);
305
+ if (tokenUsage?.totalTokens) {
306
+ await log(formatAligned('', `Previous session tokens: ${tokenUsage.totalTokens.toLocaleString()}`, '', 2));
307
+ }
308
+ } catch {
309
+ await log(formatAligned('', 'Could not read previous session token usage', '', 2));
310
+ }
311
+ }
312
+
313
+ const { generateMinimalRestartPrompt } = await import('./solve.minimal-restart-prompt.lib.mjs');
314
+ const minimalPrompt = await generateMinimalRestartPrompt(tempDir, $);
315
+ restartFeedbackLines = [minimalPrompt];
316
+ restartArgv = {
317
+ ...argv,
318
+ resume: global.previousSessionId,
319
+ minimalRestartContext: true,
320
+ };
321
+
322
+ await log(formatAligned('', `Minimal restart prompt size: ${minimalPrompt.length} characters`, '', 2));
323
+ }
324
+
293
325
  // Execute tool using shared utility
294
326
  const toolResult = await executeToolIteration({
295
327
  issueUrl,
@@ -300,10 +332,14 @@ export const watchForFeedback = async params => {
300
332
  branchName: prBranch || branchName,
301
333
  tempDir,
302
334
  mergeStateStatus,
303
- feedbackLines,
304
- argv,
335
+ feedbackLines: restartFeedbackLines,
336
+ argv: restartArgv,
305
337
  });
306
338
 
339
+ if (toolResult.sessionId && (argv.resumeOnAutoRestart || argv['resume-on-auto-restart'])) {
340
+ global.previousSessionId = toolResult.sessionId;
341
+ }
342
+
307
343
  if (!toolResult.success) {
308
344
  // Check if this is an API error using shared utility
309
345
  if (isApiError(toolResult)) {
@@ -16,7 +16,7 @@
16
16
  * @see https://github.com/link-assistant/hive-mind/issues/1143
17
17
  */
18
18
 
19
- import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, syncReadyTags } from './github-merge.lib.mjs';
19
+ import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus, syncReadyTags } from './github-merge.lib.mjs';
20
20
  import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
21
21
  import { getProgressBar } from './limits.lib.mjs';
22
22
 
@@ -102,6 +102,12 @@ export const MERGE_QUEUE_CONFIG = {
102
102
  CHECK_BRANCH_CI_HEALTH_BEFORE_START: mergeQueueConfig.checkBranchCIHealthBeforeStart,
103
103
  POST_MERGE_CI_TIMEOUT_MS: mergeQueueConfig.postMergeCITimeoutMs,
104
104
  POST_MERGE_CI_POLL_INTERVAL_MS: mergeQueueConfig.postMergeCIPollIntervalMs,
105
+
106
+ // Issue #1807: Sequential auto-resolve — wait for each `/solve --auto-merge`
107
+ // session to land its PR (or fail) before spawning the next one. These
108
+ // timeouts apply to the polling loop in `waitForAutoResolveCompletion`.
109
+ AUTO_RESOLVE_WAIT_TIMEOUT_MS: mergeQueueConfig.autoResolveWaitTimeoutMs,
110
+ AUTO_RESOLVE_POLL_INTERVAL_MS: mergeQueueConfig.autoResolvePollIntervalMs,
105
111
  };
106
112
 
107
113
  /**
@@ -191,6 +197,17 @@ export class MergeQueueProcessor {
191
197
  // Issue #1805: track auto-resolve progress so the renderer can surface it.
192
198
  this.autoResolveActive = false;
193
199
  this.autoResolveCurrent = null;
200
+ // Issue #1807: sequential auto-resolve — track which wait phase is active
201
+ // for the current auto-resolve item so the progress message can render
202
+ // distinct "spawning…", "waiting for merge…", and "waiting for CI…" lines.
203
+ // Values: null | 'spawning' | 'awaiting-resolution' | 'awaiting-ci'.
204
+ this.autoResolvePhase = null;
205
+ this.autoResolveWaitStartedAt = null;
206
+ // For dependency injection in tests (issue #1807) — when set, the
207
+ // sequential auto-resolve pass uses this in place of `getPRStatus()`.
208
+ this.getPRStatus = typeof options.getPRStatus === 'function' ? options.getPRStatus : getPRStatus;
209
+ // Same idea for `getMergeCommitSha` so tests don't need to stub gh.
210
+ this.getMergeCommitSha = typeof options.getMergeCommitSha === 'function' ? options.getMergeCommitSha : getMergeCommitSha;
194
211
 
195
212
  // Statistics
196
213
  this.stats = {
@@ -539,11 +556,17 @@ export class MergeQueueProcessor {
539
556
  }
540
557
 
541
558
  /**
542
- * Issue #1805: Iterate every conflict-skipped item and hand it off to a
543
- * `/solve <pr-url> --auto-merge` session via the injected
544
- * `spawnSolveSession` callback. Each spawn is awaited so the bot doesn't
545
- * fan out unbounded Claude sessions. The PR's status is updated as the
546
- * spawn succeeds or fails.
559
+ * Issue #1805 / #1807: Iterate every conflict-skipped item and hand it off
560
+ * to a `/solve <pr-url> --auto-merge` session via the injected
561
+ * `spawnSolveSession` callback. Sessions are processed STRICTLY
562
+ * sequentially for each PR we:
563
+ * 1. Spawn the solve session and confirm the spawn succeeded.
564
+ * 2. Poll the PR until it becomes MERGED or CLOSED, or until
565
+ * `AUTO_RESOLVE_WAIT_TIMEOUT_MS` elapses.
566
+ * 3. If MERGED, await post-merge CI via `waitForPostMergeCI()` so the
567
+ * release pipeline drains before the next resolution starts.
568
+ * This back-pressure is what keeps the conflict pass from spinning up
569
+ * eight parallel Claude sessions (issue #1807).
547
570
  *
548
571
  * @returns {Promise<void>}
549
572
  */
@@ -571,7 +594,7 @@ export class MergeQueueProcessor {
571
594
  }
572
595
 
573
596
  this.autoResolveActive = true;
574
- this.log(`Auto-resolve: dispatching ${conflicted.length} conflict PR(s) to /solve --auto-merge`);
597
+ this.log(`Auto-resolve: dispatching ${conflicted.length} conflict PR(s) sequentially to /solve --auto-merge`);
575
598
  try {
576
599
  for (const item of conflicted) {
577
600
  if (this.isCancelled) {
@@ -581,10 +604,14 @@ export class MergeQueueProcessor {
581
604
 
582
605
  item.status = MergeItemStatus.RESOLVING;
583
606
  this.autoResolveCurrent = item.pr.number;
607
+ this.autoResolvePhase = 'spawning';
608
+ this.autoResolveWaitStartedAt = new Date();
584
609
  if (this.onProgress) {
585
610
  await this.onProgress(this.getProgressUpdate());
586
611
  }
587
612
 
613
+ // Step 1 — spawn the solve session.
614
+ let spawned = false;
588
615
  try {
589
616
  const result = await this.spawnSolveSession({
590
617
  url: item.pr.url,
@@ -596,8 +623,8 @@ export class MergeQueueProcessor {
596
623
 
597
624
  if (result && result.success) {
598
625
  item.autoResolveSession = result.sessionName || result.session || null;
599
- this.stats.autoResolved++;
600
626
  this.log(`Auto-resolve: spawned solve session for PR #${item.pr.number}${item.autoResolveSession ? ` (session ${item.autoResolveSession})` : ''}`);
627
+ spawned = true;
601
628
  } else {
602
629
  item.status = MergeItemStatus.RESOLVE_FAILED;
603
630
  item.autoResolveError = (result && (result.error || result.warning)) || 'spawn failed';
@@ -611,6 +638,97 @@ export class MergeQueueProcessor {
611
638
  console.error(`[ERROR] /merge-queue: auto-resolve error for PR #${item.pr.number}: ${item.autoResolveError}`);
612
639
  }
613
640
 
641
+ if (!spawned) {
642
+ this.autoResolvePhase = null;
643
+ this.autoResolveWaitStartedAt = null;
644
+ if (this.onProgress) {
645
+ await this.onProgress(this.getProgressUpdate());
646
+ }
647
+ continue;
648
+ }
649
+
650
+ // Step 2 — wait for the spawned session to actually land (or fail)
651
+ // before starting the next one. This is the heart of issue #1807.
652
+ this.autoResolvePhase = 'awaiting-resolution';
653
+ this.autoResolveWaitStartedAt = new Date();
654
+ if (this.onProgress) {
655
+ await this.onProgress(this.getProgressUpdate());
656
+ }
657
+
658
+ const waitResult = await this.waitForAutoResolveCompletion(item);
659
+
660
+ if (waitResult.outcome === 'merged') {
661
+ // Treat the PR as merged for accounting purposes. We bump the
662
+ // dedicated `autoResolved` counter (kept for backwards-compat with
663
+ // issue #1805 reporting) and also fold the merge into `stats.merged`
664
+ // so the final report's success percentage reflects what the queue
665
+ // ultimately accomplished.
666
+ item.status = MergeItemStatus.MERGED;
667
+ item.completedAt = new Date();
668
+ this.stats.autoResolved++;
669
+ this.stats.merged++;
670
+ // The PR previously sat in `skipped` because of the merge conflict;
671
+ // now that it's merged via auto-resolve, decrement that counter so
672
+ // we don't double-count it.
673
+ if (this.stats.skipped > 0) this.stats.skipped--;
674
+ this.log(`Auto-resolve: PR #${item.pr.number} merged by solve session`);
675
+
676
+ // Best-effort: capture the merge commit SHA so post-merge CI wait
677
+ // has something to poll on.
678
+ try {
679
+ await this.sleep(5000);
680
+ const sha = await this.getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
681
+ if (sha && sha.sha) {
682
+ item.mergeCommitSha = sha.sha;
683
+ }
684
+ } catch (error) {
685
+ this.log(`Auto-resolve: could not get merge commit SHA for PR #${item.pr.number}: ${error.message}`);
686
+ }
687
+
688
+ // Step 3 — drain the merged PR's CI before continuing. We reuse
689
+ // the same `waitForPostMergeCI` path the main loop already uses so
690
+ // release workflows finish before the next resolution starts.
691
+ if (MERGE_QUEUE_CONFIG.WAIT_FOR_POST_MERGE_CI && item.mergeCommitSha && !this.isCancelled) {
692
+ this.autoResolvePhase = 'awaiting-ci';
693
+ this.autoResolveWaitStartedAt = new Date();
694
+ if (this.onProgress) {
695
+ await this.onProgress(this.getProgressUpdate());
696
+ }
697
+ const postCi = await this.waitForPostMergeCI(item);
698
+ if (!postCi.success && MERGE_QUEUE_CONFIG.STOP_ON_POST_MERGE_CI_FAILURE) {
699
+ // Stop the auto-resolve pass on CI failure so humans can
700
+ // investigate before more resolutions run on a broken branch.
701
+ // Mirrors the main loop's behaviour for issue #1341.
702
+ this.postMergeCIFailedRuns = postCi.failedRuns;
703
+ this.error = postCi.error;
704
+ this.log(`Auto-resolve: stopping pass after post-merge CI failure for PR #${item.pr.number}`);
705
+ break;
706
+ }
707
+ }
708
+ } else if (waitResult.outcome === 'closed') {
709
+ item.status = MergeItemStatus.RESOLVE_FAILED;
710
+ item.autoResolveError = 'PR was closed without merging';
711
+ this.stats.autoResolveFailed++;
712
+ this.log(`Auto-resolve: PR #${item.pr.number} was closed without merging`);
713
+ } else if (waitResult.outcome === 'cancelled') {
714
+ this.log(`Auto-resolve: cancelled while waiting for PR #${item.pr.number}`);
715
+ // Don't downgrade the item status — the user can resume later.
716
+ break;
717
+ } else if (waitResult.outcome === 'timeout') {
718
+ item.status = MergeItemStatus.RESOLVE_FAILED;
719
+ item.autoResolveError = `timed out after ${Math.round((MERGE_QUEUE_CONFIG.AUTO_RESOLVE_WAIT_TIMEOUT_MS || 0) / 60000)}m waiting for resolution`;
720
+ this.stats.autoResolveFailed++;
721
+ this.log(`Auto-resolve: timed out waiting for PR #${item.pr.number}`);
722
+ } else {
723
+ // 'error' — surface the cause but don't halt the whole pass.
724
+ item.status = MergeItemStatus.RESOLVE_FAILED;
725
+ item.autoResolveError = waitResult.error || 'unknown error while waiting';
726
+ this.stats.autoResolveFailed++;
727
+ this.log(`Auto-resolve: error waiting for PR #${item.pr.number}: ${item.autoResolveError}`);
728
+ }
729
+
730
+ this.autoResolvePhase = null;
731
+ this.autoResolveWaitStartedAt = null;
614
732
  if (this.onProgress) {
615
733
  await this.onProgress(this.getProgressUpdate());
616
734
  }
@@ -618,6 +736,89 @@ export class MergeQueueProcessor {
618
736
  } finally {
619
737
  this.autoResolveActive = false;
620
738
  this.autoResolveCurrent = null;
739
+ this.autoResolvePhase = null;
740
+ this.autoResolveWaitStartedAt = null;
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Issue #1807: Poll the PR's lifecycle state until the spawned solve
746
+ * session either lands (MERGED), gives up (CLOSED without merge), or the
747
+ * caller hits a configured timeout. The polling cadence and overall
748
+ * timeout come from `MERGE_QUEUE_CONFIG`. Cancellation is checked between
749
+ * polls so the user can abort a long resolution wait via the inline
750
+ * cancel button.
751
+ *
752
+ * Implementation note: we deliberately use `gh pr view --json
753
+ * state,mergeStateStatus` rather than tracking the screen session
754
+ * itself. `start-screen` keeps the screen alive after `solve` exits
755
+ * (via `exec bash`), so the screen lifecycle is not a reliable
756
+ * completion signal. The PR's lifecycle, on the other hand, is the
757
+ * authoritative source of truth for "did the resolution succeed?".
758
+ *
759
+ * @param {MergeQueueItem} item
760
+ * @returns {Promise<{outcome: 'merged'|'closed'|'cancelled'|'timeout'|'error', error?: string}>}
761
+ */
762
+ async waitForAutoResolveCompletion(item) {
763
+ const timeout = MERGE_QUEUE_CONFIG.AUTO_RESOLVE_WAIT_TIMEOUT_MS;
764
+ const pollInterval = MERGE_QUEUE_CONFIG.AUTO_RESOLVE_POLL_INTERVAL_MS;
765
+ const startTime = Date.now();
766
+ let consecutiveErrors = 0;
767
+ const MAX_CONSECUTIVE_ERRORS = 5;
768
+
769
+ this.log(`Auto-resolve: polling PR #${item.pr.number} until merged/closed (timeout=${Math.round(timeout / 60000)}m, poll=${Math.round(pollInterval / 1000)}s)`);
770
+
771
+ while (Date.now() - startTime < timeout) {
772
+ if (this.isCancelled) {
773
+ return { outcome: 'cancelled' };
774
+ }
775
+
776
+ let status;
777
+ try {
778
+ status = await this.getPRStatus(this.owner, this.repo, item.pr.number, this.verbose);
779
+ } catch (error) {
780
+ consecutiveErrors++;
781
+ this.log(`Auto-resolve: error polling PR #${item.pr.number} (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${error.message}`);
782
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
783
+ return { outcome: 'error', error: error.message };
784
+ }
785
+ await this.cancellableSleep(pollInterval);
786
+ continue;
787
+ }
788
+
789
+ if (status && !status.error) {
790
+ consecutiveErrors = 0;
791
+ if (status.state === 'MERGED') {
792
+ return { outcome: 'merged' };
793
+ }
794
+ if (status.state === 'CLOSED') {
795
+ return { outcome: 'closed' };
796
+ }
797
+ } else if (status && status.error) {
798
+ consecutiveErrors++;
799
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
800
+ return { outcome: 'error', error: status.error };
801
+ }
802
+ }
803
+
804
+ await this.cancellableSleep(pollInterval);
805
+ }
806
+
807
+ return { outcome: 'timeout' };
808
+ }
809
+
810
+ /**
811
+ * Issue #1807: Sleep helper that bails out as soon as cancellation is
812
+ * requested. Used by the auto-resolve poll loop so a `cancel()` call
813
+ * doesn't have to wait a full polling interval before taking effect.
814
+ */
815
+ async cancellableSleep(ms) {
816
+ const step = Math.min(ms, 1000);
817
+ const deadline = Date.now() + ms;
818
+ while (Date.now() < deadline) {
819
+ if (this.isCancelled) return;
820
+ const remaining = deadline - Date.now();
821
+ await this.sleep(Math.min(step, remaining));
621
822
  }
622
823
  }
623
824
 
@@ -802,6 +1003,11 @@ export class MergeQueueProcessor {
802
1003
  enabled: this.autoResolve,
803
1004
  active: this.autoResolveActive,
804
1005
  currentPrNumber: this.autoResolveCurrent,
1006
+ // Issue #1807: expose the sequential wait phase so the progress
1007
+ // renderer (and tests) can show whether we're spawning, waiting on
1008
+ // resolution, or waiting on post-merge CI.
1009
+ phase: this.autoResolvePhase,
1010
+ waitElapsedMs: this.autoResolveWaitStartedAt ? Date.now() - this.autoResolveWaitStartedAt.getTime() : 0,
805
1011
  },
806
1012
  progress: {
807
1013
  processed,
@@ -917,7 +1123,24 @@ export class MergeQueueProcessor {
917
1123
  if (update.autoResolve && update.autoResolve.active && update.autoResolve.currentPrNumber) {
918
1124
  const activeItem = update.items.find(it => it.prNumber === update.autoResolve.currentPrNumber);
919
1125
  const link = activeItem ? this.formatPrLink(activeItem.prNumber, activeItem.title, activeItem.prUrl) : `\\#${update.autoResolve.currentPrNumber}`;
920
- message += `🛠️ Auto\\-resolving ${link}\n\n`;
1126
+ // Issue #1807: differentiate the wait phases so the user can tell at
1127
+ // a glance whether we're still spawning, polling for merge, or
1128
+ // waiting on post-merge CI to drain.
1129
+ const phase = update.autoResolve.phase;
1130
+ const elapsedMs = update.autoResolve.waitElapsedMs || 0;
1131
+ const elapsedSec = Math.round(elapsedMs / 1000);
1132
+ const elapsedMin = Math.floor(elapsedSec / 60);
1133
+ const elapsedSecRemainder = elapsedSec % 60;
1134
+ const elapsed = elapsedMs > 0 ? ` \\(${elapsedMin}m ${elapsedSecRemainder}s\\)` : '';
1135
+ if (phase === 'awaiting-resolution') {
1136
+ message += `🛠️ Auto\\-resolving ${link}: waiting for resolution${elapsed}\\.\\.\\.\n\n`;
1137
+ } else if (phase === 'awaiting-ci') {
1138
+ message += `🛠️ Auto\\-resolving ${link}: waiting for post\\-merge CI${elapsed}\\.\\.\\.\n\n`;
1139
+ } else if (phase === 'spawning') {
1140
+ message += `🛠️ Auto\\-resolving ${link}: dispatching solve session${elapsed}\\.\\.\\.\n\n`;
1141
+ } else {
1142
+ message += `🛠️ Auto\\-resolving ${link}\n\n`;
1143
+ }
921
1144
  } else if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
922
1145
  // Current item being processed
923
1146
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';