@generacy-ai/orchestrator 0.6.0 → 0.7.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.
Files changed (178) hide show
  1. package/dist/config/schema.d.ts +7 -7
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/routes/health.d.ts.map +1 -1
  7. package/dist/routes/health.js +5 -0
  8. package/dist/routes/health.js.map +1 -1
  9. package/dist/server.d.ts.map +1 -1
  10. package/dist/server.js +130 -26
  11. package/dist/server.js.map +1 -1
  12. package/dist/services/base-advance-monitor-service.d.ts +78 -0
  13. package/dist/services/base-advance-monitor-service.d.ts.map +1 -0
  14. package/dist/services/base-advance-monitor-service.js +233 -0
  15. package/dist/services/base-advance-monitor-service.js.map +1 -0
  16. package/dist/services/boot-resume-service.d.ts +19 -0
  17. package/dist/services/boot-resume-service.d.ts.map +1 -0
  18. package/dist/services/boot-resume-service.js +87 -0
  19. package/dist/services/boot-resume-service.js.map +1 -0
  20. package/dist/services/epic-completion-monitor-service.d.ts.map +1 -1
  21. package/dist/services/epic-completion-monitor-service.js +2 -0
  22. package/dist/services/epic-completion-monitor-service.js.map +1 -1
  23. package/dist/services/in-memory-queue-adapter.d.ts +4 -0
  24. package/dist/services/in-memory-queue-adapter.d.ts.map +1 -1
  25. package/dist/services/in-memory-queue-adapter.js +30 -3
  26. package/dist/services/in-memory-queue-adapter.js.map +1 -1
  27. package/dist/services/label-monitor-service.d.ts +3 -3
  28. package/dist/services/label-monitor-service.d.ts.map +1 -1
  29. package/dist/services/label-monitor-service.js +58 -41
  30. package/dist/services/label-monitor-service.js.map +1 -1
  31. package/dist/services/label-sync-service.d.ts.map +1 -1
  32. package/dist/services/label-sync-service.js +45 -10
  33. package/dist/services/label-sync-service.js.map +1 -1
  34. package/dist/services/merge-conflict-monitor-service.d.ts +73 -0
  35. package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -0
  36. package/dist/services/merge-conflict-monitor-service.js +295 -0
  37. package/dist/services/merge-conflict-monitor-service.js.map +1 -0
  38. package/dist/services/orchestrator-version.d.ts +2 -0
  39. package/dist/services/orchestrator-version.d.ts.map +1 -0
  40. package/dist/services/orchestrator-version.js +23 -0
  41. package/dist/services/orchestrator-version.js.map +1 -0
  42. package/dist/services/phase-tracker-service.d.ts +10 -0
  43. package/dist/services/phase-tracker-service.d.ts.map +1 -1
  44. package/dist/services/phase-tracker-service.js +38 -0
  45. package/dist/services/phase-tracker-service.js.map +1 -1
  46. package/dist/services/post-activation-dispatch.d.ts +18 -0
  47. package/dist/services/post-activation-dispatch.d.ts.map +1 -0
  48. package/dist/services/post-activation-dispatch.js +30 -0
  49. package/dist/services/post-activation-dispatch.js.map +1 -0
  50. package/dist/services/pr-feedback-monitor-service.d.ts +13 -4
  51. package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
  52. package/dist/services/pr-feedback-monitor-service.js +212 -26
  53. package/dist/services/pr-feedback-monitor-service.js.map +1 -1
  54. package/dist/services/redis-queue-adapter.d.ts +4 -0
  55. package/dist/services/redis-queue-adapter.d.ts.map +1 -1
  56. package/dist/services/redis-queue-adapter.js +90 -10
  57. package/dist/services/redis-queue-adapter.js.map +1 -1
  58. package/dist/services/worker-dispatcher.d.ts +14 -2
  59. package/dist/services/worker-dispatcher.d.ts.map +1 -1
  60. package/dist/services/worker-dispatcher.js +55 -2
  61. package/dist/services/worker-dispatcher.js.map +1 -1
  62. package/dist/types/api.d.ts +3 -0
  63. package/dist/types/api.d.ts.map +1 -1
  64. package/dist/types/api.js +1 -0
  65. package/dist/types/api.js.map +1 -1
  66. package/dist/types/index.d.ts +1 -1
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/index.js.map +1 -1
  69. package/dist/types/monitor.d.ts +72 -4
  70. package/dist/types/monitor.d.ts.map +1 -1
  71. package/dist/worker/base-merge.d.ts +54 -0
  72. package/dist/worker/base-merge.d.ts.map +1 -0
  73. package/dist/worker/base-merge.js +99 -0
  74. package/dist/worker/base-merge.js.map +1 -0
  75. package/dist/worker/clarification-markers.d.ts +29 -0
  76. package/dist/worker/clarification-markers.d.ts.map +1 -0
  77. package/dist/worker/clarification-markers.js +44 -0
  78. package/dist/worker/clarification-markers.js.map +1 -0
  79. package/dist/worker/clarification-poster.d.ts.map +1 -1
  80. package/dist/worker/clarification-poster.js +266 -25
  81. package/dist/worker/clarification-poster.js.map +1 -1
  82. package/dist/worker/claude-cli-worker.d.ts +21 -2
  83. package/dist/worker/claude-cli-worker.d.ts.map +1 -1
  84. package/dist/worker/claude-cli-worker.js +149 -4
  85. package/dist/worker/claude-cli-worker.js.map +1 -1
  86. package/dist/worker/cli-spawner.d.ts +4 -0
  87. package/dist/worker/cli-spawner.d.ts.map +1 -1
  88. package/dist/worker/cli-spawner.js +51 -7
  89. package/dist/worker/cli-spawner.js.map +1 -1
  90. package/dist/worker/config.d.ts +24 -8
  91. package/dist/worker/config.d.ts.map +1 -1
  92. package/dist/worker/config.js +37 -2
  93. package/dist/worker/config.js.map +1 -1
  94. package/dist/worker/evidence-hash.d.ts +32 -0
  95. package/dist/worker/evidence-hash.d.ts.map +1 -0
  96. package/dist/worker/evidence-hash.js +139 -0
  97. package/dist/worker/evidence-hash.js.map +1 -0
  98. package/dist/worker/handler-outcome-assertion.d.ts +38 -0
  99. package/dist/worker/handler-outcome-assertion.d.ts.map +1 -0
  100. package/dist/worker/handler-outcome-assertion.js +58 -0
  101. package/dist/worker/handler-outcome-assertion.js.map +1 -0
  102. package/dist/worker/handler-outcome.d.ts +50 -0
  103. package/dist/worker/handler-outcome.d.ts.map +1 -0
  104. package/dist/worker/handler-outcome.js +2 -0
  105. package/dist/worker/handler-outcome.js.map +1 -0
  106. package/dist/worker/label-manager.d.ts +58 -2
  107. package/dist/worker/label-manager.d.ts.map +1 -1
  108. package/dist/worker/label-manager.js +225 -27
  109. package/dist/worker/label-manager.js.map +1 -1
  110. package/dist/worker/merge-conflict-handler.d.ts +141 -0
  111. package/dist/worker/merge-conflict-handler.d.ts.map +1 -0
  112. package/dist/worker/merge-conflict-handler.js +642 -0
  113. package/dist/worker/merge-conflict-handler.js.map +1 -0
  114. package/dist/worker/merge-conflict-prompt.d.ts +35 -0
  115. package/dist/worker/merge-conflict-prompt.d.ts.map +1 -0
  116. package/dist/worker/merge-conflict-prompt.js +71 -0
  117. package/dist/worker/merge-conflict-prompt.js.map +1 -0
  118. package/dist/worker/merge-conflict-remedy.d.ts +42 -0
  119. package/dist/worker/merge-conflict-remedy.d.ts.map +1 -0
  120. package/dist/worker/merge-conflict-remedy.js +28 -0
  121. package/dist/worker/merge-conflict-remedy.js.map +1 -0
  122. package/dist/worker/output-tail-synthesis.d.ts +15 -0
  123. package/dist/worker/output-tail-synthesis.d.ts.map +1 -0
  124. package/dist/worker/output-tail-synthesis.js +25 -0
  125. package/dist/worker/output-tail-synthesis.js.map +1 -0
  126. package/dist/worker/output-tail.d.ts +13 -0
  127. package/dist/worker/output-tail.d.ts.map +1 -0
  128. package/dist/worker/output-tail.js +31 -0
  129. package/dist/worker/output-tail.js.map +1 -0
  130. package/dist/worker/pause-context.d.ts +30 -0
  131. package/dist/worker/pause-context.d.ts.map +1 -0
  132. package/dist/worker/pause-context.js +98 -0
  133. package/dist/worker/pause-context.js.map +1 -0
  134. package/dist/worker/phase-loop.d.ts +66 -0
  135. package/dist/worker/phase-loop.d.ts.map +1 -1
  136. package/dist/worker/phase-loop.js +295 -29
  137. package/dist/worker/phase-loop.js.map +1 -1
  138. package/dist/worker/pr-feedback-handler.d.ts +46 -46
  139. package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
  140. package/dist/worker/pr-feedback-handler.js +276 -105
  141. package/dist/worker/pr-feedback-handler.js.map +1 -1
  142. package/dist/worker/pr-manager.d.ts +5 -0
  143. package/dist/worker/pr-manager.d.ts.map +1 -1
  144. package/dist/worker/pr-manager.js +7 -0
  145. package/dist/worker/pr-manager.js.map +1 -1
  146. package/dist/worker/product-diff.d.ts +39 -0
  147. package/dist/worker/product-diff.d.ts.map +1 -0
  148. package/dist/worker/product-diff.js +43 -0
  149. package/dist/worker/product-diff.js.map +1 -0
  150. package/dist/worker/provisioning-failure.d.ts +14 -0
  151. package/dist/worker/provisioning-failure.d.ts.map +1 -0
  152. package/dist/worker/provisioning-failure.js +2 -0
  153. package/dist/worker/provisioning-failure.js.map +1 -0
  154. package/dist/worker/repo-checkout.d.ts +14 -0
  155. package/dist/worker/repo-checkout.d.ts.map +1 -1
  156. package/dist/worker/repo-checkout.js +19 -1
  157. package/dist/worker/repo-checkout.js.map +1 -1
  158. package/dist/worker/stage-comment-manager.d.ts +42 -1
  159. package/dist/worker/stage-comment-manager.d.ts.map +1 -1
  160. package/dist/worker/stage-comment-manager.js +205 -1
  161. package/dist/worker/stage-comment-manager.js.map +1 -1
  162. package/dist/worker/terminal-label-op-error.d.ts +23 -0
  163. package/dist/worker/terminal-label-op-error.d.ts.map +1 -0
  164. package/dist/worker/terminal-label-op-error.js +27 -0
  165. package/dist/worker/terminal-label-op-error.js.map +1 -0
  166. package/dist/worker/types.d.ts +161 -2
  167. package/dist/worker/types.d.ts.map +1 -1
  168. package/dist/worker/types.js +11 -0
  169. package/dist/worker/types.js.map +1 -1
  170. package/dist/worker/validate-fix-handler.d.ts +79 -0
  171. package/dist/worker/validate-fix-handler.d.ts.map +1 -0
  172. package/dist/worker/validate-fix-handler.js +320 -0
  173. package/dist/worker/validate-fix-handler.js.map +1 -0
  174. package/dist/worker/worker-result.d.ts +39 -0
  175. package/dist/worker/worker-result.d.ts.map +1 -0
  176. package/dist/worker/worker-result.js +10 -0
  177. package/dist/worker/worker-result.js.map +1 -0
  178. package/package.json +6 -5
@@ -0,0 +1,642 @@
1
+ /**
2
+ * #898 T011 — Bounded merge-conflict resolution handler.
3
+ *
4
+ * Shape template: `pr-feedback-handler.ts`.
5
+ * Contract: `specs/898-found-during-cockpit-v1/contracts/handler-contract.md`.
6
+ *
7
+ * Termination discipline: the "one autonomous attempt" is the agent-CLI
8
+ * invocation itself (Q4 → D). Pre-agent git/network flakes get bounded
9
+ * 3× retry budgets; the agent invocation runs at most once; post-agent
10
+ * `git push` gets 3× retry on network errors only (non-fast-forward
11
+ * rejections do NOT retry — they escalate to `blocked:stuck-merge-conflicts`).
12
+ *
13
+ * On success: `completed:merge-conflicts` label added, `waiting-for:merge-conflicts`
14
+ * and `agent:paused` removed. On failure: `blocked:stuck-merge-conflicts`
15
+ * added, `waiting-for:merge-conflicts` preserved, evidence block emitted.
16
+ */
17
+ import { execFile } from 'node:child_process';
18
+ import { promisify } from 'node:util';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { createGitHubClient, } from '@generacy-ai/workflow-engine';
22
+ import { OutputCapture } from './output-capture.js';
23
+ import { RepoCheckout } from './repo-checkout.js';
24
+ import { PrLinker } from './pr-linker.js';
25
+ import { buildMergeConflictPrompt } from './merge-conflict-prompt.js';
26
+ import { buildLaunchCredentials } from './credentials-helper.js';
27
+ const execFileAsync = promisify(execFile);
28
+ /**
29
+ * Operator-advance marker consumed on the success path (#902 FR-001).
30
+ * Removing this on success is what prevents the second-conflict-cycle
31
+ * insta-resume through the generic pair path.
32
+ */
33
+ const COMPLETED_MERGE_CONFLICTS_LABEL = 'completed:merge-conflicts';
34
+ /** Label that must be present + removed on success. */
35
+ const WAITING_FOR_MERGE_CONFLICTS_LABEL = 'waiting-for:merge-conflicts';
36
+ /** Label removed on success alongside `waiting-for:merge-conflicts`. */
37
+ const AGENT_PAUSED_LABEL = 'agent:paused';
38
+ /**
39
+ * Zombie-ownership label cleared on success (#902 FR-001). Left set by #898,
40
+ * caused sniplink#6/#7/#8 dead-park.
41
+ */
42
+ const AGENT_IN_PROGRESS_LABEL = 'agent:in-progress';
43
+ /** Label added when the one autonomous attempt fails to resolve the conflict. */
44
+ const BLOCKED_STUCK_MERGE_CONFLICTS_LABEL = 'blocked:stuck-merge-conflicts';
45
+ /** Retry backoffs used by every 3× retry-classed operation. */
46
+ const RETRY_BACKOFFS_MS = [250, 500, 1000];
47
+ /**
48
+ * Handles the `resolve-merge-conflicts` command per #898.
49
+ *
50
+ * Processing flow (see `handler-contract.md` §"Flow"):
51
+ * 1-3. Parse item, create GitHub client, resolve PR via PrLinker.
52
+ * 4. switchBranch with 3× retry.
53
+ * 5. resolveBaseBranch (reuses #864 helper).
54
+ * 6-7. fetch + merge origin/<base> with 3× retry. No-op merge → immediate success.
55
+ * 8. Enumerate conflicted paths.
56
+ * 9-10.Enumerate open PRs targeting same base, cache file lists.
57
+ * 11. Build MergeConflictIntent prompt via buildMergeConflictPrompt.
58
+ * 12. agentLauncher.launch EXACTLY ONCE.
59
+ * 13. Success predicate: no MERGE_HEAD, no unresolved paths, no conflict markers.
60
+ * 14. git push origin <branch> with 3× retry (network only; NFF is a hard fail).
61
+ * 15. On success: apply completed:merge-conflicts + remove waiting-for + agent:paused.
62
+ * 17. On block: apply blocked:stuck-merge-conflicts, leave waiting-for in place,
63
+ * emit evidence block, return normally.
64
+ */
65
+ export class MergeConflictHandler {
66
+ config;
67
+ logger;
68
+ agentLauncher;
69
+ sseEmitter;
70
+ repoCheckout;
71
+ prLinker;
72
+ constructor(config, logger, agentLauncher, sseEmitter) {
73
+ this.config = config;
74
+ this.logger = logger;
75
+ this.agentLauncher = agentLauncher;
76
+ this.sseEmitter = sseEmitter;
77
+ this.repoCheckout = new RepoCheckout(config.workspaceDir, logger);
78
+ this.prLinker = new PrLinker(logger);
79
+ }
80
+ /**
81
+ * Process a merge-conflict resolution task (#902 FR-005 return type).
82
+ *
83
+ * Returns a `HandlerOutcome` — the dispatcher (via `ClaudeCliWorker.handle`)
84
+ * routes on the discriminant to decide re-arm vs failed-terminal.
85
+ *
86
+ * Success/no-op/push-succeeded → `re-armed` with `startPhase` from
87
+ * `metadata.phase`. Blocked branches → `failed` with evidence. Missing
88
+ * `metadata.phase` at a would-be `re-armed` return → fail-loud path
89
+ * (FR-004): applies `blocked:stuck-merge-conflicts`, returns `failed` with
90
+ * evidence citing the missing pause-context.
91
+ */
92
+ async handle(item, checkoutPath) {
93
+ const { owner, repo, issueNumber } = item;
94
+ const workflowId = `${owner}/${repo}#${issueNumber}`;
95
+ const metadata = item.metadata;
96
+ this.logger.info({ owner, repo, issueNumber, checkoutPath, hint: metadata }, 'MergeConflictHandler: starting');
97
+ const github = createGitHubClient(checkoutPath);
98
+ // Steps 1-3: resolve PR + branch.
99
+ let pr = null;
100
+ try {
101
+ // Try `context.linkedPRs` semantics via `findPRForBranch` on the item's
102
+ // implicit branch. But the handler's cleaner path is to enumerate open
103
+ // PRs and match the issue number via PrLinker.
104
+ const openPRs = await this.retry(() => github.listOpenPullRequests(owner, repo), 'listOpenPullRequests');
105
+ for (const candidate of openPRs) {
106
+ const linkInput = {
107
+ number: candidate.number,
108
+ body: candidate.body ?? '',
109
+ head: { ref: candidate.head.ref },
110
+ };
111
+ const link = await this.prLinker.linkPrToIssue(github, owner, repo, linkInput);
112
+ if (link && link.issueNumber === issueNumber) {
113
+ pr = candidate;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ this.logger.warn({ err: String(error), owner, repo, issueNumber }, 'MergeConflictHandler: failed to enumerate PRs — proceeding to no-PR disposition');
120
+ }
121
+ if (!pr) {
122
+ const evidence = {
123
+ unresolvedPaths: [],
124
+ partiallyResolvedPaths: [],
125
+ baseRef: '',
126
+ branchTipSha: '',
127
+ attemptedAt: new Date().toISOString(),
128
+ reason: 'no linked PR',
129
+ };
130
+ await this.applyBlockedDisposition(github, owner, repo, issueNumber, evidence);
131
+ return { outcome: 'failed', evidence };
132
+ }
133
+ const branchName = pr.head.ref;
134
+ // Step 4: switch to the PR branch with 3× retry.
135
+ try {
136
+ await this.retry(() => this.repoCheckout.switchBranch(checkoutPath, branchName), 'switchBranch');
137
+ }
138
+ catch (error) {
139
+ this.logger.error({ err: String(error), branchName, checkoutPath }, 'MergeConflictHandler: failed to switch to PR branch after retries');
140
+ throw new Error(`Failed to switch to branch ${branchName}: ${String(error)}`);
141
+ }
142
+ // Step 5: resolve base branch (`origin/<name>`) directly from the linked PR.
143
+ const baseRef = `origin/${pr.base.ref}`;
144
+ const baseName = pr.base.ref;
145
+ // Step 6: fresh fetch of the base ref.
146
+ try {
147
+ await this.retry(() => execFileAsync('git', ['fetch', 'origin', baseName], { cwd: checkoutPath }), 'git fetch origin ' + baseName);
148
+ }
149
+ catch (error) {
150
+ this.logger.error({ err: String(error), baseRef }, 'MergeConflictHandler: git fetch origin exhausted retries');
151
+ throw new Error(`Failed to fetch ${baseRef}: ${String(error)}`);
152
+ }
153
+ // Guard: no-op merge — the branch is already up-to-date with base. Clear
154
+ // labels and return re-armed without spending the agent attempt.
155
+ // `git merge-base --is-ancestor <base> HEAD` exit 0 => base is ancestor of HEAD.
156
+ if (await this.baseIsAncestor(checkoutPath, baseRef)) {
157
+ this.logger.info({ owner, repo, issueNumber, baseRef, branch: branchName }, 'MergeConflictHandler: no-op merge (branch already up to date) — clearing labels');
158
+ return this.finishSuccess(github, owner, repo, issueNumber, metadata, baseRef, checkoutPath);
159
+ }
160
+ // Step 7: git merge origin/<base>. Retry ONLY on env classes (index.lock,
161
+ // RPC). Conflict output is the expected forward path.
162
+ let mergeExitedCleanly = false;
163
+ let mergeAttemptError;
164
+ for (let attempt = 0; attempt < RETRY_BACKOFFS_MS.length + 1; attempt++) {
165
+ try {
166
+ await execFileAsync('git', ['merge', '--no-ff', baseRef], { cwd: checkoutPath });
167
+ mergeExitedCleanly = true;
168
+ break;
169
+ }
170
+ catch (err) {
171
+ mergeAttemptError = err;
172
+ const stderr = err?.stderr ?? '';
173
+ const looksLikeConflict = /conflict/i.test(stderr);
174
+ if (looksLikeConflict) {
175
+ // Expected — CONTINUE to conflicted-path enumeration.
176
+ break;
177
+ }
178
+ const looksTransient = this.isTransientGitError(err);
179
+ if (!looksTransient || attempt >= RETRY_BACKOFFS_MS.length) {
180
+ // Non-transient non-conflict git failure. Fall through to guard below.
181
+ break;
182
+ }
183
+ const backoff = RETRY_BACKOFFS_MS[attempt];
184
+ this.logger.warn({ err: String(err), attempt: attempt + 1, backoffMs: backoff }, 'MergeConflictHandler: transient git merge failure — retrying');
185
+ await this.sleep(backoff);
186
+ }
187
+ }
188
+ if (mergeExitedCleanly) {
189
+ // The merge command reported success but we thought there'd be a
190
+ // conflict. Push the resulting merge commit and finish.
191
+ return this.pushAndSucceed(github, checkoutPath, branchName, owner, repo, issueNumber, baseRef, metadata);
192
+ }
193
+ // Step 8: enumerate conflicted paths after the failed merge.
194
+ let conflictedPaths = [];
195
+ try {
196
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: checkoutPath });
197
+ conflictedPaths = stdout
198
+ .split('\n')
199
+ .map((line) => line.trim())
200
+ .filter((line) => line.length > 0);
201
+ }
202
+ catch (err) {
203
+ this.logger.warn({ err: String(err) }, 'MergeConflictHandler: could not enumerate conflicted paths after merge failure');
204
+ }
205
+ if (conflictedPaths.length === 0) {
206
+ // Non-conflict merge failure — treat as blocked with the raw error text
207
+ // as the reason. Abort the merge so we leave a clean tree.
208
+ try {
209
+ await execFileAsync('git', ['merge', '--abort'], { cwd: checkoutPath });
210
+ }
211
+ catch {
212
+ // best-effort
213
+ }
214
+ const evidence = {
215
+ unresolvedPaths: [],
216
+ partiallyResolvedPaths: [],
217
+ baseRef,
218
+ branchTipSha: await this.getBranchTipSha(checkoutPath),
219
+ attemptedAt: new Date().toISOString(),
220
+ reason: `git merge failed without conflicts: ${String(mergeAttemptError ?? 'unknown')}`,
221
+ };
222
+ await this.applyBlockedDisposition(github, owner, repo, issueNumber, evidence);
223
+ return { outcome: 'failed', evidence };
224
+ }
225
+ // Steps 9-10: enumerate open PRs targeting the same base and cache their
226
+ // file lists. FR-005 / Q3 → A: same-base-in-repo enumeration is the
227
+ // load-bearing input, NOT `context.linkedPRs`.
228
+ const siblingFileMap = await this.enumerateSiblingFileMap(github, owner, repo, baseName, pr.number);
229
+ // Step 11: derive sibling-owned paths for the prompt.
230
+ const siblingOwnedPaths = conflictedPaths.filter((p) => siblingFileMap.has(p));
231
+ // Step 12: build the prompt + invoke the agent CLI EXACTLY ONCE.
232
+ const prompt = buildMergeConflictPrompt({
233
+ conflictedPaths,
234
+ siblingOwnedPaths,
235
+ baseRef,
236
+ branch: branchName,
237
+ });
238
+ this.logger.info({
239
+ owner, repo, issueNumber,
240
+ conflictedPathCount: conflictedPaths.length,
241
+ siblingOwnedPathCount: siblingOwnedPaths.length,
242
+ siblingPrFileMapSize: siblingFileMap.size,
243
+ }, 'MergeConflictHandler: invoking agent CLI (single attempt) for conflict resolution');
244
+ const cliSucceeded = await this.spawnAgentForConflict(checkoutPath, prompt, workflowId, issueNumber);
245
+ // Step 13: post-agent verification — success predicate.
246
+ const verification = await this.verifyMergeResolved(checkoutPath);
247
+ if (!cliSucceeded || !verification.ok) {
248
+ this.logger.warn({
249
+ owner, repo, issueNumber,
250
+ cliSucceeded,
251
+ verification,
252
+ }, 'MergeConflictHandler: agent attempt did not produce conflict-free merge — blocked disposition');
253
+ const partiallyResolvedPaths = conflictedPaths.filter((p) => !verification.unresolvedPaths.includes(p));
254
+ // Abort the in-progress merge so we don't leave the working tree half-
255
+ // resolved — the operator will take the manual remedy from Ship 1.
256
+ try {
257
+ await execFileAsync('git', ['merge', '--abort'], { cwd: checkoutPath });
258
+ }
259
+ catch {
260
+ // best-effort
261
+ }
262
+ const evidence = {
263
+ unresolvedPaths: verification.unresolvedPaths,
264
+ partiallyResolvedPaths,
265
+ baseRef,
266
+ branchTipSha: await this.getBranchTipSha(checkoutPath),
267
+ attemptedAt: new Date().toISOString(),
268
+ };
269
+ await this.applyBlockedDisposition(github, owner, repo, issueNumber, evidence);
270
+ return { outcome: 'failed', evidence };
271
+ }
272
+ // Step 14: push the merge commit.
273
+ return this.pushAndSucceed(github, checkoutPath, branchName, owner, repo, issueNumber, baseRef, metadata);
274
+ }
275
+ /**
276
+ * Enumerate open PRs targeting `baseName` (excluding the target issue's own PR)
277
+ * and build a Map<path, prNumber[]> of files each sibling PR touches.
278
+ *
279
+ * Uses `gh pr view <number> --json files` per sibling (call site is bounded
280
+ * to the poll cycle, per handler-contract §Observability). Missing files
281
+ * data is treated as an empty set.
282
+ */
283
+ async enumerateSiblingFileMap(github, owner, repo, baseName, selfPrNumber) {
284
+ const fileMap = new Map();
285
+ let siblings;
286
+ try {
287
+ const openPRs = await github.listOpenPullRequests(owner, repo);
288
+ siblings = openPRs.filter((p) => p.number !== selfPrNumber && p.base.ref === baseName);
289
+ }
290
+ catch (err) {
291
+ this.logger.warn({ err: String(err), owner, repo, baseName }, 'MergeConflictHandler: sibling PR enumeration failed — proceeding without sibling scope guard');
292
+ return fileMap;
293
+ }
294
+ for (const sibling of siblings) {
295
+ try {
296
+ const files = await this.fetchPrFiles(owner, repo, sibling.number);
297
+ for (const file of files) {
298
+ const existing = fileMap.get(file) ?? [];
299
+ existing.push(sibling.number);
300
+ fileMap.set(file, existing);
301
+ }
302
+ }
303
+ catch (err) {
304
+ this.logger.warn({ err: String(err), siblingPrNumber: sibling.number }, 'MergeConflictHandler: failed to fetch sibling PR files — omitting from scope guard');
305
+ }
306
+ }
307
+ this.logger.debug({ siblingCount: siblings.length, fileCount: fileMap.size }, 'MergeConflictHandler: sibling file map built');
308
+ return fileMap;
309
+ }
310
+ /**
311
+ * `gh pr view <number> --json files` on the local checkout. Called
312
+ * per-sibling; failures are non-fatal (the scope guard degrades to
313
+ * "no siblings" for that PR, which is safer than throwing).
314
+ */
315
+ async fetchPrFiles(owner, repo, prNumber) {
316
+ const { stdout } = await execFileAsync('gh', [
317
+ 'pr', 'view', String(prNumber),
318
+ '--repo', `${owner}/${repo}`,
319
+ '--json', 'files',
320
+ '--jq', '.files[].path',
321
+ ]);
322
+ return stdout
323
+ .split('\n')
324
+ .map((line) => line.trim())
325
+ .filter((line) => line.length > 0);
326
+ }
327
+ /**
328
+ * Success predicate (handler-contract §"Success predicate"):
329
+ * - `.git/MERGE_HEAD` does NOT exist (merge is complete)
330
+ * - `git diff --name-only --diff-filter=U` is empty (no unresolved paths)
331
+ * - No file in the tree contains `<<<<<<< ` at line-start (belt & suspenders)
332
+ */
333
+ async verifyMergeResolved(checkoutPath) {
334
+ const unresolvedPaths = [];
335
+ if (existsSync(join(checkoutPath, '.git', 'MERGE_HEAD'))) {
336
+ return { ok: false, unresolvedPaths: await this.listUnresolvedPaths(checkoutPath) };
337
+ }
338
+ const gitUnmerged = await this.listUnresolvedPaths(checkoutPath);
339
+ if (gitUnmerged.length > 0) {
340
+ return { ok: false, unresolvedPaths: gitUnmerged };
341
+ }
342
+ // Belt & suspenders: scan tracked files for the conflict marker sentinel.
343
+ // Small-scale enumeration — cheaper than launching `git grep` which
344
+ // requires the index to be in a specific state.
345
+ try {
346
+ const markerCandidates = await this.scanForConflictMarkers(checkoutPath);
347
+ if (markerCandidates.length > 0) {
348
+ return { ok: false, unresolvedPaths: markerCandidates };
349
+ }
350
+ }
351
+ catch (err) {
352
+ this.logger.debug({ err: String(err) }, 'MergeConflictHandler: conflict-marker scan failed — treating as no markers');
353
+ }
354
+ return { ok: true, unresolvedPaths };
355
+ }
356
+ async listUnresolvedPaths(checkoutPath) {
357
+ try {
358
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', '--diff-filter=U'], { cwd: checkoutPath });
359
+ return stdout
360
+ .split('\n')
361
+ .map((l) => l.trim())
362
+ .filter((l) => l.length > 0);
363
+ }
364
+ catch {
365
+ return [];
366
+ }
367
+ }
368
+ /**
369
+ * Best-effort scan for the `<<<<<<< ` conflict marker at line start across
370
+ * tracked files. Uses `git ls-files` to enumerate, then reads each file
371
+ * synchronously (bounded — small worker workloads).
372
+ */
373
+ async scanForConflictMarkers(checkoutPath) {
374
+ let tracked;
375
+ try {
376
+ const { stdout } = await execFileAsync('git', ['ls-files'], { cwd: checkoutPath });
377
+ tracked = stdout.split('\n').filter((l) => l.length > 0);
378
+ }
379
+ catch {
380
+ return [];
381
+ }
382
+ const hits = [];
383
+ for (const rel of tracked) {
384
+ const abs = join(checkoutPath, rel);
385
+ try {
386
+ const contents = readFileSync(abs, 'utf-8');
387
+ if (contents.startsWith('<<<<<<< ') || contents.includes('\n<<<<<<< ')) {
388
+ hits.push(rel);
389
+ }
390
+ }
391
+ catch {
392
+ // Skip binary/unreadable files silently.
393
+ }
394
+ }
395
+ return hits;
396
+ }
397
+ /**
398
+ * `git push origin <branch>` with 3× retry on transient network errors.
399
+ * Non-fast-forward rejection does NOT retry — it escalates to blocked.
400
+ * On success, applies the combined label cleanup and returns re-armed.
401
+ */
402
+ async pushAndSucceed(github, checkoutPath, branchName, owner, repo, issueNumber, baseRef, metadata) {
403
+ let pushed = false;
404
+ let lastErr;
405
+ for (let attempt = 0; attempt < RETRY_BACKOFFS_MS.length + 1; attempt++) {
406
+ try {
407
+ await execFileAsync('git', ['push', 'origin', branchName], { cwd: checkoutPath });
408
+ pushed = true;
409
+ break;
410
+ }
411
+ catch (err) {
412
+ lastErr = err;
413
+ if (this.isNonFastForward(err)) {
414
+ this.logger.warn({ err: String(err), branchName }, 'MergeConflictHandler: push rejected non-fast-forward — blocked disposition');
415
+ break;
416
+ }
417
+ if (!this.isTransientNetworkError(err) || attempt >= RETRY_BACKOFFS_MS.length) {
418
+ break;
419
+ }
420
+ const backoff = RETRY_BACKOFFS_MS[attempt];
421
+ this.logger.warn({ err: String(err), attempt: attempt + 1, backoffMs: backoff }, 'MergeConflictHandler: transient push failure — retrying');
422
+ await this.sleep(backoff);
423
+ }
424
+ }
425
+ if (!pushed) {
426
+ this.logger.error({ err: String(lastErr), branchName, owner, repo, issueNumber }, 'MergeConflictHandler: git push exhausted — blocked disposition');
427
+ const evidence = {
428
+ unresolvedPaths: [],
429
+ partiallyResolvedPaths: [],
430
+ baseRef,
431
+ branchTipSha: await this.getBranchTipSha(checkoutPath),
432
+ attemptedAt: new Date().toISOString(),
433
+ reason: `git push failed: ${String(lastErr ?? 'unknown')}`,
434
+ };
435
+ await this.applyBlockedDisposition(github, owner, repo, issueNumber, evidence);
436
+ return { outcome: 'failed', evidence };
437
+ }
438
+ return this.finishSuccess(github, owner, repo, issueNumber, metadata, baseRef, checkoutPath);
439
+ }
440
+ /**
441
+ * Success-terminal branch (#902 FR-001, FR-004, FR-007).
442
+ *
443
+ * - FR-001: consume `completed:merge-conflicts` and clear `agent:in-progress` /
444
+ * `agent:paused` residue so the next natural conflict pause is not
445
+ * insta-resumed by the generic pair path.
446
+ * - FR-004: fail-loud if `metadata.phase` is missing — never re-derive from
447
+ * labels. Applies `blocked:stuck-merge-conflicts`, returns `failed` with
448
+ * evidence citing the missing pause-context.
449
+ * - FR-007: one combined `gh issue edit --remove-label …` invocation (via
450
+ * `github.removeLabels` which already batches removes into a single call).
451
+ */
452
+ async finishSuccess(github, owner, repo, issueNumber, metadata, baseRef, checkoutPath) {
453
+ // FR-004: fail-loud if pause-context is missing. Never re-derive from labels.
454
+ if (!metadata?.phase) {
455
+ const evidence = {
456
+ unresolvedPaths: [],
457
+ partiallyResolvedPaths: [],
458
+ baseRef,
459
+ branchTipSha: await this.getBranchTipSha(checkoutPath),
460
+ attemptedAt: new Date().toISOString(),
461
+ reason: 'pause-context missing: phase',
462
+ };
463
+ this.logger.error({ owner, repo, issueNumber }, 'MergeConflictHandler: pause-context missing at success return — fail-loud (FR-004)');
464
+ await this.applyBlockedDisposition(github, owner, repo, issueNumber, evidence);
465
+ return { outcome: 'failed', evidence };
466
+ }
467
+ await this.applySuccessDisposition(github, owner, repo, issueNumber);
468
+ return { outcome: 're-armed', startPhase: metadata.phase };
469
+ }
470
+ /**
471
+ * #902 FR-007: single combined `gh issue edit --remove-label …` for all four
472
+ * ownership-transition removes. No adds — re-arm is direct enqueue, not a
473
+ * resume-pair, so no `waiting-for:<gate>` / `completed:<gate>` labels are
474
+ * needed. The next `continue` item's normal in-progress labels are applied
475
+ * by `LabelManager.onResumeStart` when the phase loop enters.
476
+ *
477
+ * `github.removeLabels` batches all four `--remove-label` flags into one
478
+ * `gh issue edit` invocation — one HTTP round-trip, atomic at the label-set
479
+ * level (per contracts/handler-outcome.md §"Label edit shape").
480
+ */
481
+ async applySuccessDisposition(github, owner, repo, issueNumber) {
482
+ try {
483
+ await github.removeLabels(owner, repo, issueNumber, [
484
+ COMPLETED_MERGE_CONFLICTS_LABEL,
485
+ WAITING_FOR_MERGE_CONFLICTS_LABEL,
486
+ AGENT_IN_PROGRESS_LABEL,
487
+ AGENT_PAUSED_LABEL,
488
+ ]);
489
+ }
490
+ catch (err) {
491
+ this.logger.warn({ err: String(err), issueNumber }, 'MergeConflictHandler: failed to clear ownership-transition labels');
492
+ }
493
+ this.logger.info({ owner, repo, issueNumber, disposition: 'success' }, 'MergeConflictHandler: conflict resolved — cleared ownership-transition labels');
494
+ }
495
+ async applyBlockedDisposition(github, owner, repo, issueNumber, evidence) {
496
+ try {
497
+ await github.addLabels(owner, repo, issueNumber, [BLOCKED_STUCK_MERGE_CONFLICTS_LABEL]);
498
+ }
499
+ catch (err) {
500
+ this.logger.warn({ err: String(err), issueNumber, label: BLOCKED_STUCK_MERGE_CONFLICTS_LABEL }, 'MergeConflictHandler: failed to add blocked:stuck-merge-conflicts label');
501
+ }
502
+ // Evidence emission: for now this is a structured warn log line; the
503
+ // stage-comment integration lives at the phase-loop pause site and is
504
+ // already load-bearing (Ship 1's self-describing remedy). Rendering
505
+ // evidence into the stage comment from the handler process requires the
506
+ // StageCommentManager wiring, which the handler does not construct.
507
+ this.logger.warn({
508
+ owner, repo, issueNumber,
509
+ evidence,
510
+ disposition: 'blocked',
511
+ }, 'MergeConflictHandler: blocked:stuck-merge-conflicts — evidence recorded');
512
+ }
513
+ /**
514
+ * Spawn the agent CLI with a MergeConflictIntent. Runs at most once
515
+ * (Q4 → D). Returns true if the CLI exited 0, false otherwise.
516
+ */
517
+ async spawnAgentForConflict(checkoutPath, prompt, workflowId, issueNumber) {
518
+ let child;
519
+ try {
520
+ const handle = await this.agentLauncher.launch({
521
+ intent: {
522
+ kind: 'merge-conflict',
523
+ issueNumber,
524
+ prompt,
525
+ },
526
+ cwd: checkoutPath,
527
+ env: {},
528
+ credentials: buildLaunchCredentials(this.config.credentialRole),
529
+ });
530
+ child = handle.process;
531
+ }
532
+ catch (err) {
533
+ this.logger.error({ err: String(err), cwd: checkoutPath }, 'MergeConflictHandler: failed to spawn agent CLI process');
534
+ return false;
535
+ }
536
+ const outputCapture = new OutputCapture(workflowId, this.logger, this.sseEmitter);
537
+ if (child.stdout) {
538
+ child.stdout.on('data', (data) => {
539
+ outputCapture.processChunk(typeof data === 'string' ? data : data.toString('utf-8'));
540
+ });
541
+ }
542
+ if (child.stderr) {
543
+ let stderrBuffer = '';
544
+ child.stderr.on('data', (data) => {
545
+ stderrBuffer += typeof data === 'string' ? data : data.toString('utf-8');
546
+ });
547
+ child.exitPromise.finally(() => {
548
+ if (stderrBuffer.trim()) {
549
+ this.logger.debug({ stderr: stderrBuffer.trim() }, 'MergeConflictHandler: agent CLI stderr');
550
+ }
551
+ });
552
+ }
553
+ const timeoutMs = this.config.phaseTimeoutMs;
554
+ let timedOut = false;
555
+ const timeoutTimer = setTimeout(() => {
556
+ timedOut = true;
557
+ this.logger.warn({ pid: child.pid, timeoutMs }, 'MergeConflictHandler: agent CLI timed out — sending SIGTERM');
558
+ child.kill('SIGTERM');
559
+ setTimeout(() => {
560
+ if (child.pid) {
561
+ child.kill('SIGKILL');
562
+ }
563
+ }, this.config.shutdownGracePeriodMs);
564
+ }, timeoutMs);
565
+ try {
566
+ const exitCode = await child.exitPromise;
567
+ clearTimeout(timeoutTimer);
568
+ outputCapture.flush();
569
+ if (timedOut)
570
+ return false;
571
+ return exitCode === 0;
572
+ }
573
+ catch (err) {
574
+ clearTimeout(timeoutTimer);
575
+ this.logger.error({ err: String(err), timedOut }, 'MergeConflictHandler: error waiting for agent CLI');
576
+ return false;
577
+ }
578
+ }
579
+ async retry(op, label) {
580
+ let lastErr;
581
+ for (let attempt = 0; attempt < RETRY_BACKOFFS_MS.length + 1; attempt++) {
582
+ try {
583
+ return await op();
584
+ }
585
+ catch (err) {
586
+ lastErr = err;
587
+ if (!this.isTransientGitError(err) || attempt >= RETRY_BACKOFFS_MS.length) {
588
+ break;
589
+ }
590
+ const backoff = RETRY_BACKOFFS_MS[attempt];
591
+ this.logger.warn({ err: String(err), attempt: attempt + 1, label, backoffMs: backoff }, 'MergeConflictHandler: transient error — retrying');
592
+ await this.sleep(backoff);
593
+ }
594
+ }
595
+ throw lastErr;
596
+ }
597
+ isTransientGitError(err) {
598
+ const s = String(err?.message ?? err ?? '');
599
+ return (s.includes('ECONNRESET') ||
600
+ s.includes('ETIMEDOUT') ||
601
+ s.includes('index.lock') ||
602
+ s.includes('RPC failed') ||
603
+ s.includes('early EOF'));
604
+ }
605
+ isTransientNetworkError(err) {
606
+ const s = String(err?.message ?? err ?? '');
607
+ return (s.includes('ECONNRESET') ||
608
+ s.includes('ETIMEDOUT') ||
609
+ s.includes('RPC failed') ||
610
+ s.includes('early EOF') ||
611
+ s.includes('remote end hung up'));
612
+ }
613
+ isNonFastForward(err) {
614
+ const s = String(err?.stderr ?? '') +
615
+ String(err?.message ?? '');
616
+ return /non-fast-forward|!\s*\[rejected\]/i.test(s);
617
+ }
618
+ async baseIsAncestor(checkoutPath, baseRef) {
619
+ try {
620
+ await execFileAsync('git', ['merge-base', '--is-ancestor', baseRef, 'HEAD'], { cwd: checkoutPath });
621
+ return true;
622
+ }
623
+ catch {
624
+ return false;
625
+ }
626
+ }
627
+ async getBranchTipSha(checkoutPath) {
628
+ try {
629
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
630
+ cwd: checkoutPath,
631
+ });
632
+ return stdout.trim();
633
+ }
634
+ catch {
635
+ return '';
636
+ }
637
+ }
638
+ sleep(ms) {
639
+ return new Promise((resolve) => setTimeout(resolve, ms));
640
+ }
641
+ }
642
+ //# sourceMappingURL=merge-conflict-handler.js.map