@pi-agents/orchid 0.1.0-beta.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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,3414 @@
1
+ /**
2
+ * Merge orchestration, merge agents, merge worktree
3
+ * @module orch/merge
4
+ */
5
+ import {
6
+ readFileSync,
7
+ writeFileSync,
8
+ existsSync,
9
+ unlinkSync,
10
+ copyFileSync,
11
+ mkdirSync,
12
+ rmSync,
13
+ readdirSync,
14
+ type Dirent,
15
+ } from "fs";
16
+ import { readFile as fsReadFile } from "fs/promises";
17
+ import { execSync, spawnSync } from "child_process";
18
+ import { join, dirname, resolve, relative } from "path";
19
+
20
+ import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
21
+ import { resolveOperatorId } from "./naming.ts";
22
+ import {
23
+ MERGE_POLL_INTERVAL_MS,
24
+ MERGE_RESULT_GRACE_MS,
25
+ MERGE_RESULT_READ_RETRIES,
26
+ MERGE_RESULT_READ_RETRY_DELAY_MS,
27
+ MERGE_SPAWN_RETRY_MAX,
28
+ MERGE_TIMEOUT_MAX_RETRIES,
29
+ MERGE_TIMEOUT_MS,
30
+ MERGE_HEALTH_POLL_INTERVAL_MS,
31
+ MERGE_HEALTH_WARNING_THRESHOLD_MS,
32
+ MERGE_HEALTH_STUCK_THRESHOLD_MS,
33
+ MergeError,
34
+ VALID_MERGE_STATUSES,
35
+ buildEngineEventBase,
36
+ } from "./types.ts";
37
+ import type {
38
+ AllocatedLane,
39
+ LaneExecutionResult,
40
+ MergeLaneResult,
41
+ MergeResult,
42
+ MergeResultStatus,
43
+ MergeWaveResult,
44
+ OrchestratorConfig,
45
+ RepoMergeOutcome,
46
+ TaskRunnerConfig,
47
+ TransactionRecord,
48
+ TransactionStatus,
49
+ VerificationBaselineResult,
50
+ WaveExecutionResult,
51
+ WorkspaceConfig,
52
+ MergeHealthStatus,
53
+ MergeHealthEventType,
54
+ MergeSessionSnapshot,
55
+ MergeSessionHealthState,
56
+ EngineEvent,
57
+ OrchBatchPhase,
58
+ RuntimeMergeSnapshot,
59
+ RuntimeAgentTelemetrySnapshot,
60
+ } from "./types.ts";
61
+ import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
62
+ import {
63
+ readManifest,
64
+ writeManifest,
65
+ buildRegistrySnapshot,
66
+ writeRegistrySnapshot,
67
+ readRegistrySnapshot,
68
+ writeMergeSnapshot,
69
+ } from "./process-registry.ts";
70
+ import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
71
+ import { getCurrentBranch, runGit } from "./git.ts";
72
+ import { ORCH_MESSAGES } from "./messages.ts";
73
+ import { emitEngineEvent } from "./persistence.ts";
74
+ import { loadOrchestratorConfig } from "./config.ts";
75
+ import {
76
+ captureBaseline,
77
+ diffFingerprints,
78
+ runVerificationCommands,
79
+ parseTestOutput,
80
+ deduplicateFingerprints,
81
+ } from "./verification.ts";
82
+ import { spawnAgent } from "./agent-host.ts";
83
+ import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
84
+ import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
85
+ import type { RuntimeBackend } from "./execution.ts";
86
+ import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
87
+
88
+ // ── Merge Implementation ─────────────────────────────────────────────
89
+
90
+ /**
91
+ * Parse and validate a merge result JSON file.
92
+ *
93
+ * Strict validation:
94
+ * - Must be valid JSON
95
+ * - Must have required fields: status, source_branch, verification
96
+ * - status must be a known MergeResultStatus
97
+ * - Unknown status values are mapped to BUILD_FAILURE (fail-safe)
98
+ *
99
+ * Retry-read strategy: if initial parse fails, waits and retries up to
100
+ * MERGE_RESULT_READ_RETRIES times to handle partially-written files.
101
+ *
102
+ * @param resultPath - Absolute path to the merge result JSON file
103
+ * @returns Validated MergeResult
104
+ * @throws MergeError with appropriate code on validation failure
105
+ */
106
+ export function parseMergeResult(resultPath: string): MergeResult {
107
+ if (!existsSync(resultPath)) {
108
+ throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
109
+ }
110
+
111
+ const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
112
+ for (const key of keys) {
113
+ const value = obj[key];
114
+ if (typeof value === "string" && value.trim().length > 0) {
115
+ return value;
116
+ }
117
+ }
118
+ return null;
119
+ };
120
+
121
+ const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
122
+ typeof obj.verification_passed === "boolean" ||
123
+ Array.isArray(obj.verification_commands) ||
124
+ typeof obj.verification_output === "string" ||
125
+ typeof obj.verification_exit_code === "number";
126
+
127
+ const normalizeVerification = (
128
+ obj: Record<string, unknown>,
129
+ ): MergeResult["verification"] | null => {
130
+ const nested =
131
+ obj.verification && typeof obj.verification === "object"
132
+ ? (obj.verification as Record<string, unknown>)
133
+ : null;
134
+
135
+ if (!nested && !hasFlatVerification(obj)) {
136
+ return null;
137
+ }
138
+
139
+ const passedFromBool =
140
+ (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ??
141
+ (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ??
142
+ (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
143
+
144
+ const exitCode =
145
+ (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ??
146
+ (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ??
147
+ (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
148
+
149
+ const passed =
150
+ typeof passedFromBool === "boolean"
151
+ ? passedFromBool
152
+ : typeof exitCode === "number"
153
+ ? exitCode === 0
154
+ : false;
155
+
156
+ const ran =
157
+ nested && typeof nested.ran === "boolean"
158
+ ? nested.ran
159
+ : typeof passedFromBool === "boolean" ||
160
+ typeof exitCode === "number" ||
161
+ (nested && typeof nested.command === "string") ||
162
+ (nested && typeof nested.summary === "string") ||
163
+ typeof obj.verification_output === "string" ||
164
+ Array.isArray(obj.verification_commands);
165
+
166
+ const output = (
167
+ (nested && typeof nested.output === "string" ? nested.output : undefined) ??
168
+ (nested && typeof nested.summary === "string" ? nested.summary : undefined) ??
169
+ (nested && typeof nested.notes === "string" ? nested.notes : undefined) ??
170
+ (typeof obj.verification_output === "string" ? obj.verification_output : "")
171
+ ).slice(0, 2000);
172
+
173
+ return { ran, passed, output };
174
+ };
175
+
176
+ // Retry-read loop for partially-written files
177
+ let lastParseError = "";
178
+ for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
179
+ try {
180
+ const raw = readFileSync(resultPath, "utf-8").trim();
181
+ if (!raw) {
182
+ lastParseError = "File is empty";
183
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
184
+ sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
185
+ continue;
186
+ }
187
+ throw new MergeError(
188
+ "MERGE_RESULT_INVALID",
189
+ `Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
190
+ );
191
+ }
192
+
193
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
194
+
195
+ // Validate required fields
196
+ if (typeof parsed.status !== "string") {
197
+ throw new MergeError(
198
+ "MERGE_RESULT_MISSING_FIELDS",
199
+ `Merge result missing required field "status": ${resultPath}`,
200
+ );
201
+ }
202
+
203
+ // Accept known source-field variants written by different merge agents.
204
+ // Canonical field remains source_branch.
205
+ const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
206
+ if (!sourceBranch) {
207
+ throw new MergeError(
208
+ "MERGE_RESULT_MISSING_FIELDS",
209
+ `Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
210
+ );
211
+ }
212
+
213
+ const verification = normalizeVerification(parsed);
214
+ if (!verification) {
215
+ throw new MergeError(
216
+ "MERGE_RESULT_MISSING_FIELDS",
217
+ `Merge result missing required field "verification": ${resultPath}`,
218
+ );
219
+ }
220
+
221
+ // Normalize status to uppercase (merge agents may write lowercase)
222
+ // TP-195: hoist normalized value to a local string so the
223
+ // `VALID_MERGE_STATUSES.has()` call typechecks. `parsed.status`
224
+ // is `unknown` after JSON parse; assigning `String(...)` to a
225
+ // property of an `any` doesn't propagate `string` type back
226
+ // through `parsed.status`. Runtime evaluation order is
227
+ // unchanged.
228
+ const normalizedStatus = String(parsed.status).toUpperCase();
229
+ parsed.status = normalizedStatus;
230
+
231
+ // Validate status value
232
+ if (!VALID_MERGE_STATUSES.has(normalizedStatus)) {
233
+ execLog(
234
+ "merge",
235
+ "parse",
236
+ `unknown merge status "${normalizedStatus}" — treating as BUILD_FAILURE`,
237
+ {
238
+ resultPath,
239
+ },
240
+ );
241
+ parsed.status = "BUILD_FAILURE";
242
+ }
243
+
244
+ const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
245
+ const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
246
+ const conflicts = Array.isArray(parsed.conflicts)
247
+ ? parsed.conflicts
248
+ .filter(
249
+ (c): c is { file: string; type: string; resolved: boolean; resolution?: string } =>
250
+ typeof c === "object" &&
251
+ c !== null &&
252
+ typeof (c as { file?: unknown }).file === "string" &&
253
+ typeof (c as { type?: unknown }).type === "string" &&
254
+ typeof (c as { resolved?: unknown }).resolved === "boolean",
255
+ )
256
+ .map((c) => ({
257
+ file: c.file,
258
+ type: c.type,
259
+ resolved: c.resolved,
260
+ ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
261
+ }))
262
+ : [];
263
+
264
+ // Normalize optional fields with defaults
265
+ return {
266
+ status: parsed.status as MergeResultStatus,
267
+ source_branch: sourceBranch,
268
+ target_branch: targetBranch,
269
+ merge_commit: mergeCommit,
270
+ conflicts,
271
+ verification,
272
+ };
273
+ } catch (err: unknown) {
274
+ if (err instanceof MergeError) throw err;
275
+
276
+ // JSON parse error — possibly partially written
277
+ lastParseError = err instanceof Error ? err.message : String(err);
278
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
279
+ sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
280
+ continue;
281
+ }
282
+ }
283
+ }
284
+
285
+ throw new MergeError(
286
+ "MERGE_RESULT_INVALID",
287
+ `Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
288
+ `Last error: ${lastParseError}. File: ${resultPath}`,
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Async version of parseMergeResult — reads and validates a merge result
294
+ * JSON file without blocking the event loop.
295
+ *
296
+ * Uses `fs/promises.readFile` instead of `readFileSync` and `sleepAsync`
297
+ * instead of `sleepSync` for retry delays. Validation semantics and error
298
+ * codes are identical to the sync version.
299
+ *
300
+ * @param resultPath - Path to the merge result JSON file
301
+ * @returns Promise resolving to a validated MergeResult
302
+ * @throws MergeError on missing/invalid/unparseable result
303
+ *
304
+ * @since TP-070
305
+ */
306
+ export async function parseMergeResultAsync(resultPath: string): Promise<MergeResult> {
307
+ if (!existsSync(resultPath)) {
308
+ throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
309
+ }
310
+
311
+ const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
312
+ for (const key of keys) {
313
+ const value = obj[key];
314
+ if (typeof value === "string" && value.trim().length > 0) {
315
+ return value;
316
+ }
317
+ }
318
+ return null;
319
+ };
320
+
321
+ const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
322
+ typeof obj.verification_passed === "boolean" ||
323
+ Array.isArray(obj.verification_commands) ||
324
+ typeof obj.verification_output === "string" ||
325
+ typeof obj.verification_exit_code === "number";
326
+
327
+ const normalizeVerification = (
328
+ obj: Record<string, unknown>,
329
+ ): MergeResult["verification"] | null => {
330
+ const nested =
331
+ obj.verification && typeof obj.verification === "object"
332
+ ? (obj.verification as Record<string, unknown>)
333
+ : null;
334
+
335
+ if (!nested && !hasFlatVerification(obj)) {
336
+ return null;
337
+ }
338
+
339
+ const passedFromBool =
340
+ (nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ??
341
+ (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ??
342
+ (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
343
+
344
+ const exitCode =
345
+ (nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ??
346
+ (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ??
347
+ (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
348
+
349
+ const passed =
350
+ typeof passedFromBool === "boolean"
351
+ ? passedFromBool
352
+ : typeof exitCode === "number"
353
+ ? exitCode === 0
354
+ : false;
355
+
356
+ const ran =
357
+ nested && typeof nested.ran === "boolean"
358
+ ? nested.ran
359
+ : typeof passedFromBool === "boolean" ||
360
+ typeof exitCode === "number" ||
361
+ (nested && typeof nested.command === "string") ||
362
+ (nested && typeof nested.summary === "string") ||
363
+ typeof obj.verification_output === "string" ||
364
+ Array.isArray(obj.verification_commands);
365
+
366
+ const output = (
367
+ (nested && typeof nested.output === "string" ? nested.output : undefined) ??
368
+ (nested && typeof nested.summary === "string" ? nested.summary : undefined) ??
369
+ (nested && typeof nested.notes === "string" ? nested.notes : undefined) ??
370
+ (typeof obj.verification_output === "string" ? obj.verification_output : "")
371
+ ).slice(0, 2000);
372
+
373
+ return { ran, passed, output };
374
+ };
375
+
376
+ // Retry-read loop for partially-written files — async version
377
+ let lastParseError = "";
378
+ for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
379
+ try {
380
+ const raw = (await fsReadFile(resultPath, "utf-8")).trim();
381
+ if (!raw) {
382
+ lastParseError = "File is empty";
383
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
384
+ await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
385
+ continue;
386
+ }
387
+ throw new MergeError(
388
+ "MERGE_RESULT_INVALID",
389
+ `Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
390
+ );
391
+ }
392
+
393
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
394
+
395
+ // Validate required fields
396
+ if (typeof parsed.status !== "string") {
397
+ throw new MergeError(
398
+ "MERGE_RESULT_MISSING_FIELDS",
399
+ `Merge result missing required field "status": ${resultPath}`,
400
+ );
401
+ }
402
+
403
+ const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
404
+ if (!sourceBranch) {
405
+ throw new MergeError(
406
+ "MERGE_RESULT_MISSING_FIELDS",
407
+ `Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
408
+ );
409
+ }
410
+
411
+ const verification = normalizeVerification(parsed);
412
+ if (!verification) {
413
+ throw new MergeError(
414
+ "MERGE_RESULT_MISSING_FIELDS",
415
+ `Merge result missing required field "verification": ${resultPath}`,
416
+ );
417
+ }
418
+
419
+ // Normalize status to uppercase
420
+ // TP-195: hoist normalized value to a local string (same rationale
421
+ // as the parallel block at line ~225 above).
422
+ const normalizedStatus = String(parsed.status).toUpperCase();
423
+ parsed.status = normalizedStatus;
424
+
425
+ if (!VALID_MERGE_STATUSES.has(normalizedStatus)) {
426
+ execLog(
427
+ "merge",
428
+ "parse",
429
+ `unknown merge status "${normalizedStatus}" — treating as BUILD_FAILURE`,
430
+ {
431
+ resultPath,
432
+ },
433
+ );
434
+ parsed.status = "BUILD_FAILURE";
435
+ }
436
+
437
+ const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
438
+ const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
439
+ const conflicts = Array.isArray(parsed.conflicts)
440
+ ? parsed.conflicts
441
+ .filter(
442
+ (c): c is { file: string; type: string; resolved: boolean; resolution?: string } =>
443
+ typeof c === "object" &&
444
+ c !== null &&
445
+ typeof (c as { file?: unknown }).file === "string" &&
446
+ typeof (c as { type?: unknown }).type === "string" &&
447
+ typeof (c as { resolved?: unknown }).resolved === "boolean",
448
+ )
449
+ .map((c) => ({
450
+ file: c.file,
451
+ type: c.type,
452
+ resolved: c.resolved,
453
+ ...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
454
+ }))
455
+ : [];
456
+
457
+ return {
458
+ status: parsed.status as MergeResultStatus,
459
+ source_branch: sourceBranch,
460
+ target_branch: targetBranch,
461
+ merge_commit: mergeCommit,
462
+ conflicts,
463
+ verification,
464
+ };
465
+ } catch (err: unknown) {
466
+ if (err instanceof MergeError) throw err;
467
+
468
+ lastParseError = err instanceof Error ? err.message : String(err);
469
+ if (attempt < MERGE_RESULT_READ_RETRIES) {
470
+ await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
471
+ continue;
472
+ }
473
+ }
474
+ }
475
+
476
+ throw new MergeError(
477
+ "MERGE_RESULT_INVALID",
478
+ `Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
479
+ `Last error: ${lastParseError}. File: ${resultPath}`,
480
+ );
481
+ }
482
+
483
+ /**
484
+ * TP-171: Stage task artifacts from skipped-only lanes onto the target branch
485
+ * using an isolated temporary worktree.
486
+ *
487
+ * When no mergeable lanes exist (e.g., all tasks skipped), there is no merge worktree.
488
+ * This function creates a lightweight temporary worktree on `targetBranch`, copies
489
+ * STATUS.md, .DONE, REVIEW_VERDICT.json, and .reviews/** from skipped-lane worktrees
490
+ * into it, commits, advances `targetBranch`, then cleans up the temporary worktree.
491
+ * This ensures partial worker progress (STATUS.md updates) survives integration.
492
+ */
493
+ function stageSkippedArtifactsToTargetBranch(
494
+ lanes: AllocatedLane[],
495
+ waveIndex: number,
496
+ repoRoot: string,
497
+ targetBranch: string,
498
+ ): void {
499
+ // TP-171: Do NOT include .DONE — skipped tasks' code was not merged,
500
+ // so staging .DONE would create false completion markers.
501
+ const ALLOWED_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"];
502
+ const ALLOWED_DIRS = [".reviews"];
503
+ const resolvedRepoRoot = resolve(repoRoot);
504
+
505
+ // Create a temporary worktree on the target branch for isolated artifact staging.
506
+ // This avoids writing to whatever branch repoRoot has checked out.
507
+ const tmpWorktreePath = join(repoRoot, "..", `skip-artifacts-w${waveIndex}-${Date.now()}`);
508
+ const resolvedTmpPath = resolve(tmpWorktreePath);
509
+
510
+ try {
511
+ const addResult = spawnSync("git", ["worktree", "add", resolvedTmpPath, targetBranch], {
512
+ cwd: repoRoot,
513
+ });
514
+ if (addResult.status !== 0) {
515
+ execLog("merge", `W${waveIndex}`, `failed to create temp worktree for skipped artifacts`, {
516
+ stderr: addResult.stderr?.toString().trim(),
517
+ });
518
+ return;
519
+ }
520
+
521
+ let staged = 0;
522
+
523
+ for (const lane of lanes) {
524
+ if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue;
525
+ for (const allocTask of lane.tasks) {
526
+ if (!allocTask.task?.taskFolder?.trim()) continue;
527
+ // Resolve taskFolder against the lane worktree first (workspace mode:
528
+ // taskFolder may be relative to the packet repo, not the execution repo).
529
+ // Fall back to repoRoot if worktreePath is unavailable.
530
+ const resolveBase = lane.worktreePath ? resolve(lane.worktreePath) : resolvedRepoRoot;
531
+ const absFolder = resolve(resolveBase, allocTask.task.taskFolder);
532
+ const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
533
+ if (relFolder.startsWith("..") || relFolder.startsWith("/")) continue;
534
+
535
+ for (const name of ALLOWED_NAMES) {
536
+ const relPath = `${relFolder}/${name}`;
537
+ const srcPath = join(lane.worktreePath, relPath);
538
+ if (!existsSync(srcPath)) continue;
539
+ try {
540
+ const destPath = join(resolvedTmpPath, relPath);
541
+ mkdirSync(dirname(destPath), { recursive: true });
542
+ copyFileSync(srcPath, destPath);
543
+ spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
544
+ staged++;
545
+ } catch {
546
+ /* best effort */
547
+ }
548
+ }
549
+
550
+ for (const dirName of ALLOWED_DIRS) {
551
+ const laneDir = join(lane.worktreePath, relFolder, dirName);
552
+ if (!existsSync(laneDir)) continue;
553
+ try {
554
+ const entries = readdirSync(laneDir, { recursive: true, withFileTypes: true });
555
+ for (const entry of entries) {
556
+ if (!entry.isFile()) continue;
557
+ const entryPath = entry.parentPath ? join(entry.parentPath, entry.name) : entry.name;
558
+ const fileRel = relative(laneDir, entryPath).replace(/\\/g, "/");
559
+ if (fileRel.startsWith("..")) continue;
560
+ const relPath = `${relFolder}/${dirName}/${fileRel}`;
561
+ const srcPath = join(laneDir, fileRel);
562
+ const destPath = join(resolvedTmpPath, relPath);
563
+ mkdirSync(dirname(destPath), { recursive: true });
564
+ copyFileSync(srcPath, destPath);
565
+ spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
566
+ staged++;
567
+ }
568
+ } catch {
569
+ /* best effort */
570
+ }
571
+ }
572
+ }
573
+ }
574
+
575
+ if (staged > 0) {
576
+ const commitResult = spawnSync(
577
+ "git",
578
+ ["commit", "-m", `checkpoint: wave ${waveIndex} skipped-task artifacts (STATUS.md, .reviews)`],
579
+ { cwd: resolvedTmpPath },
580
+ );
581
+ if (commitResult.status === 0) {
582
+ execLog("merge", `W${waveIndex}`, `staged ${staged} artifact(s) from skipped-only lanes`, {
583
+ lanes: lanes.map((l) => l.laneNumber).join(","),
584
+ });
585
+ } else {
586
+ execLog("merge", `W${waveIndex}`, `failed to commit skipped-task artifacts`, {
587
+ stderr: commitResult.stderr?.toString().trim(),
588
+ });
589
+ }
590
+ }
591
+ } catch (err: any) {
592
+ execLog("merge", `W${waveIndex}`, `unexpected error staging skipped artifacts`, {
593
+ error: err?.message,
594
+ });
595
+ } finally {
596
+ // Clean up the temporary worktree
597
+ try {
598
+ spawnSync("git", ["worktree", "remove", "--force", resolvedTmpPath], { cwd: repoRoot });
599
+ } catch {
600
+ /* best effort cleanup */
601
+ }
602
+ try {
603
+ if (existsSync(resolvedTmpPath)) {
604
+ rmSync(resolvedTmpPath, { recursive: true, force: true });
605
+ }
606
+ } catch {
607
+ /* best effort cleanup */
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Determine merge order for completed lanes.
614
+ *
615
+ * Default heuristic: fewest-files-first.
616
+ * - Lanes with fewer files in their file scope merge first
617
+ * - Smaller changes are less likely to conflict, establishing a clean base
618
+ * - Tie-breaker: branch name alphabetically (deterministic)
619
+ *
620
+ * Alternative: sequential (lane number order).
621
+ *
622
+ * @param lanes - Completed lanes to order
623
+ * @param order - Ordering strategy from config
624
+ * @returns Lanes sorted in merge order
625
+ */
626
+ export function determineMergeOrder(
627
+ lanes: AllocatedLane[],
628
+ order: "fewest-files-first" | "sequential",
629
+ ): AllocatedLane[] {
630
+ const sorted = [...lanes];
631
+
632
+ if (order === "sequential") {
633
+ sorted.sort((a, b) => a.laneNumber - b.laneNumber);
634
+ return sorted;
635
+ }
636
+
637
+ // fewest-files-first: count total file scope across all tasks in the lane
638
+ sorted.sort((a, b) => {
639
+ const aFiles = a.tasks.reduce((sum, t) => sum + (t.task?.fileScope?.length || 0), 0);
640
+ const bFiles = b.tasks.reduce((sum, t) => sum + (t.task?.fileScope?.length || 0), 0);
641
+
642
+ if (aFiles !== bFiles) return aFiles - bFiles;
643
+
644
+ // Tie-breaker: branch name alphabetically
645
+ return a.branch.localeCompare(b.branch);
646
+ });
647
+
648
+ return sorted;
649
+ }
650
+
651
+ /**
652
+ * Build merge request content for the merge agent.
653
+ *
654
+ * The merge request is a structured text document that tells the merge agent:
655
+ * - Which branch to merge (source)
656
+ * - Which branch to merge into (target)
657
+ * - What tasks were completed in this lane
658
+ * - File scope of those tasks
659
+ * - Verification commands to run
660
+ * - Where to write the result file
661
+ *
662
+ * @param lane - The lane to merge
663
+ * @param targetBranch - Target branch (typically "develop")
664
+ * @param waveIndex - Wave number (1-indexed)
665
+ * @param verifyCommands - Verification commands from config
666
+ * @param resultFilePath - Path where the merge agent should write results
667
+ * @returns Formatted merge request text
668
+ */
669
+ export function buildMergeRequest(
670
+ lane: AllocatedLane,
671
+ targetBranch: string,
672
+ waveIndex: number,
673
+ verifyCommands: string[],
674
+ resultFilePath: string,
675
+ ): string {
676
+ const taskIds = lane.tasks.map((t) => t.taskId).join(", ");
677
+ // TP-169: Guard against null task stubs from reconstructAllocatedLanes
678
+ const fileScopes = lane.tasks
679
+ .flatMap((t) => t.task?.fileScope || [])
680
+ .filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate
681
+
682
+ const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`;
683
+
684
+ const lines: string[] = [
685
+ "# Merge Request",
686
+ "",
687
+ `## Source Branch`,
688
+ `${lane.branch}`,
689
+ "",
690
+ `## Target Branch`,
691
+ `${targetBranch}`,
692
+ "",
693
+ `## Merge Message`,
694
+ `${mergeMessage}`,
695
+ "",
696
+ `## Tasks Completed`,
697
+ ...lane.tasks.map((t) => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`),
698
+ "",
699
+ `## File Scope`,
700
+ ...(fileScopes.length > 0 ? fileScopes.map((f) => `- ${f}`) : ["- (no file scope declared)"]),
701
+ "",
702
+ `## Verification Commands`,
703
+ ...verifyCommands.map((cmd) => `\`\`\`bash\n${cmd}\n\`\`\``),
704
+ "",
705
+ `## Result File`,
706
+ `result_file: ${resultFilePath.split("\\").join("/")}`,
707
+ `Write your JSON result to EXACTLY this path (do NOT modify or convert it): ${resultFilePath.split("\\").join("/")}`,
708
+ "",
709
+ "## Result JSON Schema (required)",
710
+ "Use EXACT snake_case keys shown below. Do not use camelCase or shortened keys.",
711
+ "",
712
+ "```json",
713
+ "{",
714
+ ' "status": "SUCCESS" | "CONFLICT_RESOLVED" | "CONFLICT_UNRESOLVED" | "BUILD_FAILURE",',
715
+ ' "source_branch": "<source branch name>",',
716
+ ' "target_branch": "<target branch name>",',
717
+ ' "merge_commit": "<merge commit sha or empty string>",',
718
+ ' "conflicts": [{ "file": "...", "type": "...", "resolved": true|false }],',
719
+ ' "verification": { "ran": true|false, "passed": true|false, "output": "..." }',
720
+ "}",
721
+ "```",
722
+ "",
723
+ "Do NOT use keys like source/sourceBranch/target/mergeCommit.",
724
+ "Write valid JSON only (no markdown around the final file).",
725
+ "",
726
+ "## Important",
727
+ "- You are working in an ISOLATED MERGE WORKTREE (not the user's main repo)",
728
+ "- The correct branch is ALREADY checked out — do NOT checkout any other branch",
729
+ "- Simply merge the source branch into the current HEAD",
730
+ "- Run ALL verification commands after a successful merge",
731
+ "- If verification fails, revert the merge commit before writing the result",
732
+ "- Write the result file LAST, after all git operations are complete",
733
+ ];
734
+
735
+ return lines.join("\n");
736
+ }
737
+
738
+ /**
739
+ * Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer).
740
+ *
741
+ * Per Runtime V2 spec (02-runtime-process-model.md §8.3):
742
+ * "engine spawns merge host directly" — the merge agent runs as a direct
743
+ * child process via agent-host, with process registry tracking, normalized
744
+ * events, and deterministic exit classification.
745
+ *
746
+ * The merge agent receives the merge request as its prompt and writes
747
+ * a result JSON file. The caller polls for that result file (same contract
748
+ * as the legacy session-backed path via waitForMergeResult).
749
+ *
750
+ * @param sessionName - Stable agent ID (e.g., "orch-merge-1")
751
+ * @param repoRoot - Main repository root (merge happens here)
752
+ * @param mergeWorkDir - Working directory for the merge
753
+ * @param mergeRequestPath - Path to the merge request file
754
+ * @param config - Orchestrator config
755
+ * @param stateRoot - Root for state files / registry
756
+ * @param agentRoot - Root for agent prompts
757
+ * @param batchId - Current batch ID
758
+ * @returns Promise that resolves when the agent exits
759
+ *
760
+ * @since TP-108
761
+ */
762
+ // TP-195: return type changed from `Promise<AgentHostResult>` to
763
+ // `Promise<void>` to match actual semantics. The function never returns a
764
+ // value — it spawns the merge agent, attaches `.then`/`.catch` handlers
765
+ // for fire-and-forget exit logging (line ~912 marker: "Fire-and-forget"),
766
+ // and exits. Both call sites (lines ~1929, ~1942) `await` the returned
767
+ // promise but do not consume its value.
768
+ export async function spawnMergeAgentV2(
769
+ sessionName: string,
770
+ repoRoot: string,
771
+ mergeWorkDir: string,
772
+ mergeRequestPath: string,
773
+ config: OrchestratorConfig,
774
+ stateRoot?: string,
775
+ agentRoot?: string,
776
+ batchId?: string,
777
+ waveIndex?: number,
778
+ ): Promise<void> {
779
+ execLog("merge", sessionName, "spawning merge agent via Runtime V2 (direct agent-host)", {
780
+ mergeWorkDir,
781
+ mergeRequestPath,
782
+ });
783
+
784
+ // Read the merge request as the agent prompt
785
+ const prompt = readFileSync(mergeRequestPath, "utf-8");
786
+
787
+ // Resolve merger system prompt
788
+ const systemPromptCandidates = [
789
+ agentRoot ? join(agentRoot, "task-merger.md") : "",
790
+ join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
791
+ ].filter(Boolean);
792
+ const systemPromptPath = systemPromptCandidates.find((p) => existsSync(p)) || "";
793
+ let systemPrompt: string | undefined;
794
+ if (systemPromptPath) {
795
+ try {
796
+ systemPrompt = readFileSync(systemPromptPath, "utf-8");
797
+ } catch {
798
+ /* use default */
799
+ }
800
+ }
801
+
802
+ // Resolve event/exit paths
803
+ const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
804
+ const bid = batchId || "unknown";
805
+ const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl");
806
+ const exitSummaryPath = join(
807
+ sidecarRoot,
808
+ "runtime",
809
+ bid,
810
+ "agents",
811
+ sessionName,
812
+ "exit-summary.json",
813
+ );
814
+
815
+ // Mailbox directory
816
+ let mailboxDir: string | null = null;
817
+ if (batchId) {
818
+ mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
819
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
820
+ }
821
+
822
+ // TP-180: Forward user-installed extensions to merge agent
823
+ const mergeStateRoot = stateRoot ?? repoRoot;
824
+ const allMergePackages = loadPiSettingsPackages(mergeStateRoot);
825
+ const mergeExclusions = config.merge.exclude_extensions ?? [];
826
+ const mergePackages = filterExcludedExtensions(allMergePackages, mergeExclusions);
827
+
828
+ const opts: AgentHostOptions = {
829
+ agentId: sessionName,
830
+ role: "merger",
831
+ batchId: bid,
832
+ laneNumber: null,
833
+ taskId: null,
834
+ repoId: "default",
835
+ cwd: mergeWorkDir,
836
+ prompt,
837
+ systemPrompt,
838
+ model: config.merge.model || undefined,
839
+ tools: config.merge.tools || undefined,
840
+ thinking: config.merge.thinking || undefined,
841
+ mailboxDir,
842
+ eventsPath,
843
+ exitSummaryPath,
844
+ timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
845
+ stateRoot: mergeStateRoot,
846
+ packet: null,
847
+ ...(mergePackages.length > 0 ? { extensions: mergePackages } : {}),
848
+ env: {
849
+ ORCH_BATCH_ID: bid,
850
+ },
851
+ };
852
+
853
+ // Derive the 1-indexed merge number from the session name
854
+ // (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2).
855
+ const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/);
856
+ if (!mergeNumberMatch) {
857
+ execLog(
858
+ "merge",
859
+ sessionName,
860
+ "warning: could not parse merge number from session name — defaulting to 1",
861
+ { sessionName },
862
+ );
863
+ }
864
+ const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
865
+ const mergeStartedAt = Date.now();
866
+
867
+ // Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
868
+ const buildAgentSnap = (
869
+ tel: Partial<AgentHostResult>,
870
+ status: RuntimeAgentTelemetrySnapshot["status"],
871
+ ): RuntimeAgentTelemetrySnapshot => ({
872
+ agentId: sessionName,
873
+ status,
874
+ elapsedMs: tel.durationMs ?? Date.now() - mergeStartedAt,
875
+ toolCalls: tel.toolCalls ?? 0,
876
+ contextPct: tel.contextUsage?.percent ?? 0,
877
+ costUsd: tel.costUsd ?? 0,
878
+ lastTool: tel.lastTool ?? "",
879
+ inputTokens: tel.inputTokens ?? 0,
880
+ outputTokens: tel.outputTokens ?? 0,
881
+ cacheReadTokens: tel.cacheReadTokens ?? 0,
882
+ cacheWriteTokens: tel.cacheWriteTokens ?? 0,
883
+ });
884
+
885
+ // Telemetry callback: write a merge snapshot on every telemetry update.
886
+ // Non-fatal — snapshot writes must never interfere with merge execution.
887
+ const onMergeTelemetry: AgentTelemetryCallback = (tel) => {
888
+ try {
889
+ const snap: RuntimeMergeSnapshot = {
890
+ batchId: bid,
891
+ mergeNumber,
892
+ sessionName,
893
+ waveIndex: waveIndex ?? 0,
894
+ status: "running",
895
+ agent: buildAgentSnap(tel, "running"),
896
+ updatedAt: Date.now(),
897
+ };
898
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
899
+ } catch {
900
+ /* non-fatal */
901
+ }
902
+ };
903
+
904
+ const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry);
905
+
906
+ // Write an initial "running" snapshot immediately so the dashboard row
907
+ // appears even when the first telemetry event is delayed.
908
+ try {
909
+ const initialSnap: RuntimeMergeSnapshot = {
910
+ batchId: bid,
911
+ mergeNumber,
912
+ sessionName,
913
+ waveIndex: waveIndex ?? 0,
914
+ status: "running",
915
+ agent: buildAgentSnap({}, "running"),
916
+ updatedAt: Date.now(),
917
+ };
918
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, initialSnap);
919
+ } catch {
920
+ /* non-fatal */
921
+ }
922
+
923
+ // Store the kill handle for external cleanup (pause/abort).
924
+ // The promise runs in background — caller uses waitForMergeResult()
925
+ // to poll for the result file, same contract as the legacy session path.
926
+ activeMergeAgents.set(sessionName, { promise, kill, stateRoot: mergeStateRoot, batchId: bid });
927
+
928
+ // Fire-and-forget: the background promise handles exit logging and
929
+ // writes a terminal snapshot ("complete" or "failed") when the agent exits.
930
+ promise
931
+ .then((result) => {
932
+ activeMergeAgents.delete(sessionName);
933
+ execLog("merge", sessionName, "merge agent exited (V2)", {
934
+ exitCode: result.exitCode,
935
+ durationMs: result.durationMs,
936
+ costUsd: result.costUsd,
937
+ killed: result.killed,
938
+ });
939
+ // Write terminal snapshot. Promise resolves for both successful and
940
+ // failed exits, so derive status from result fields rather than
941
+ // relying on .catch to handle failures.
942
+ // Determine terminal status. A clean post-success kill sets registry
943
+ // manifest to "exited" via killMergeAgentV2(name, true=cleanExit).
944
+ // Check the registry first so a successful-then-killed agent is shown
945
+ // as "complete" rather than "failed".
946
+ let terminalStatus: RuntimeMergeSnapshot["status"] = "complete";
947
+ try {
948
+ const manifest = readManifest(mergeStateRoot, bid, sessionName as any);
949
+ if (manifest?.status === "exited") {
950
+ terminalStatus = "complete";
951
+ } else if (result.exitCode !== 0 || !result.agentEnded) {
952
+ terminalStatus = "failed";
953
+ }
954
+ } catch {
955
+ if (result.exitCode !== 0 || !result.agentEnded) terminalStatus = "failed";
956
+ }
957
+ try {
958
+ const snap: RuntimeMergeSnapshot = {
959
+ batchId: bid,
960
+ mergeNumber,
961
+ sessionName,
962
+ waveIndex: waveIndex ?? 0,
963
+ status: terminalStatus,
964
+ agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
965
+ updatedAt: Date.now(),
966
+ };
967
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
968
+ } catch {
969
+ /* non-fatal */
970
+ }
971
+ })
972
+ .catch((err) => {
973
+ activeMergeAgents.delete(sessionName);
974
+ execLog(
975
+ "merge",
976
+ sessionName,
977
+ `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`,
978
+ );
979
+ // Write a failed terminal snapshot on unexpected rejection.
980
+ try {
981
+ const snap: RuntimeMergeSnapshot = {
982
+ batchId: bid,
983
+ mergeNumber,
984
+ sessionName,
985
+ waveIndex: waveIndex ?? 0,
986
+ status: "failed",
987
+ agent: buildAgentSnap({}, "crashed"),
988
+ updatedAt: Date.now(),
989
+ };
990
+ writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
991
+ } catch {
992
+ /* non-fatal */
993
+ }
994
+ });
995
+ }
996
+
997
+ /** Active V2 merge agent handles for cleanup/abort. @since TP-108 */
998
+ const activeMergeAgents = new Map<
999
+ string,
1000
+ { promise: Promise<AgentHostResult>; kill: () => void; stateRoot?: string; batchId?: string }
1001
+ >();
1002
+
1003
+ /**
1004
+ * Kill a V2 merge agent if it's still running.
1005
+ * Used by pause/abort/cleanup flows.
1006
+ * @since TP-108
1007
+ */
1008
+ export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): boolean {
1009
+ const handle = activeMergeAgents.get(sessionName);
1010
+ if (handle) {
1011
+ handle.kill();
1012
+ // TP-115: On clean post-result cleanup, update manifest to "exited"
1013
+ // so dashboard shows correct status instead of "killed".
1014
+ if (cleanExit && handle.stateRoot && handle.batchId) {
1015
+ try {
1016
+ const manifest = readManifest(handle.stateRoot, handle.batchId, sessionName as any);
1017
+ if (manifest) {
1018
+ manifest.status = "exited";
1019
+ writeManifest(handle.stateRoot, manifest);
1020
+ const snapshot = buildRegistrySnapshot(handle.stateRoot, handle.batchId);
1021
+ writeRegistrySnapshot(handle.stateRoot, snapshot);
1022
+ }
1023
+ } catch {
1024
+ /* best effort */
1025
+ }
1026
+ }
1027
+ activeMergeAgents.delete(sessionName);
1028
+ return true;
1029
+ }
1030
+ return false;
1031
+ }
1032
+
1033
+ /**
1034
+ * Kill ALL active V2 merge agents. Used by abort flow to ensure
1035
+ * no merge agents survive even when the legacy session list is empty.
1036
+ * @returns Number of agents killed
1037
+ * @since TP-108
1038
+ */
1039
+ export function killAllMergeAgentsV2(): number {
1040
+ let killed = 0;
1041
+ for (const [name, handle] of activeMergeAgents) {
1042
+ handle.kill();
1043
+ execLog("merge", name, "V2 merge agent killed by bulk abort");
1044
+ killed++;
1045
+ }
1046
+ activeMergeAgents.clear();
1047
+ return killed;
1048
+ }
1049
+
1050
+ /**
1051
+ * Re-read merge timeout from config on disk.
1052
+ *
1053
+ * TP-038: Allows the operator to increase `merge.timeoutMinutes` without
1054
+ * restarting the pi session. Called before each retry attempt so the
1055
+ * retry loop picks up any config changes made while the batch was running.
1056
+ *
1057
+ * @param configRoot - The directory containing `.pi/orchid-config.json`
1058
+ * @param pointerConfigRoot - Optional pointer config root (workspace mode)
1059
+ * @returns Fresh timeout in milliseconds
1060
+ */
1061
+ export function reloadMergeTimeoutMs(configRoot: string, pointerConfigRoot?: string): number {
1062
+ try {
1063
+ const freshConfig = loadOrchestratorConfig(configRoot, pointerConfigRoot);
1064
+ const minutes = freshConfig.merge.timeout_minutes ?? 90;
1065
+ return minutes * 60 * 1000;
1066
+ } catch (err: unknown) {
1067
+ // Config re-read is best-effort — fall back to default on failure
1068
+ const errMsg = err instanceof Error ? err.message : String(err);
1069
+ execLog(
1070
+ "merge",
1071
+ "config-reload",
1072
+ `failed to re-read merge timeout from config: ${errMsg} — using default`,
1073
+ );
1074
+ return MERGE_TIMEOUT_MS;
1075
+ }
1076
+ }
1077
+
1078
+ /** Merge result statuses that indicate the merge agent completed successfully. */
1079
+ const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED"]);
1080
+
1081
+ /**
1082
+ * Wait for merge agent to produce a result file.
1083
+ *
1084
+ * Polling loop with timeout and session liveness detection:
1085
+ * 1. Check if result file exists → parse and return
1086
+ * 2. Check if the merge agent session is still alive
1087
+ * 3. If session died without result → grace period → check again → fail
1088
+ * 4. If timeout exceeded → check result before killing:
1089
+ * a. If result exists with SUCCESS/CONFLICT_RESOLVED: accept it
1090
+ * (merge agent slow but succeeded)
1091
+ * b. If result missing or non-success: kill session → fail
1092
+ *
1093
+ * @param resultPath - Path to the expected result JSON file
1094
+ * @param sessionName - Merge session name for liveness checking
1095
+ * @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
1096
+ * @returns Validated MergeResult
1097
+ * @throws MergeError on timeout, session death, or invalid result
1098
+ */
1099
+ export async function waitForMergeResult(
1100
+ resultPath: string,
1101
+ sessionName: string,
1102
+ timeoutMs: number = MERGE_TIMEOUT_MS,
1103
+ runtimeBackend?: RuntimeBackend,
1104
+ ): Promise<MergeResult> {
1105
+ const startTime = Date.now();
1106
+ let sessionDiedAt: number | null = null;
1107
+ const isV2 = runtimeBackend === "v2";
1108
+
1109
+ execLog("merge", sessionName, "waiting for merge result", {
1110
+ resultPath,
1111
+ timeoutMs,
1112
+ backend: isV2 ? "v2" : "legacy",
1113
+ });
1114
+
1115
+ while (true) {
1116
+ const elapsed = Date.now() - startTime;
1117
+
1118
+ // Check timeout
1119
+ if (elapsed >= timeoutMs) {
1120
+ // TP-038: Check result file BEFORE killing the session.
1121
+ if (existsSync(resultPath)) {
1122
+ try {
1123
+ const lateResult = await parseMergeResultAsync(resultPath);
1124
+ if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) {
1125
+ execLog(
1126
+ "merge",
1127
+ sessionName,
1128
+ "merge agent slow but succeeded — accepting result at timeout",
1129
+ {
1130
+ status: lateResult.status,
1131
+ elapsed,
1132
+ timeoutMs,
1133
+ },
1134
+ );
1135
+ // Clean up agent (may still be running post-write)
1136
+ killMergeAgentV2(sessionName, true);
1137
+ return lateResult;
1138
+ }
1139
+ execLog("merge", sessionName, "merge result exists at timeout but non-success — killing", {
1140
+ status: lateResult.status,
1141
+ });
1142
+ } catch {
1143
+ // Result file unreadable — fall through to kill
1144
+ }
1145
+ }
1146
+
1147
+ execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
1148
+ killMergeAgentV2(sessionName);
1149
+
1150
+ throw new MergeError(
1151
+ "MERGE_TIMEOUT",
1152
+ `Merge agent '${sessionName}' did not produce a result within ` +
1153
+ `${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` +
1154
+ `Check the merge request and agent logs.`,
1155
+ );
1156
+ }
1157
+
1158
+ // Check if result file exists
1159
+ if (existsSync(resultPath)) {
1160
+ try {
1161
+ const result = await parseMergeResultAsync(resultPath);
1162
+ execLog("merge", sessionName, "merge result received", {
1163
+ status: result.status,
1164
+ elapsed,
1165
+ });
1166
+ // Clean up agent if still alive
1167
+ killMergeAgentV2(sessionName, true);
1168
+ return result;
1169
+ } catch (err: unknown) {
1170
+ if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
1171
+ await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
1172
+ if (existsSync(resultPath)) {
1173
+ try {
1174
+ return await parseMergeResultAsync(resultPath);
1175
+ } catch {
1176
+ /* give up */
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ // Check agent liveness — backend-aware
1184
+ // Runtime V2: check active merge agent handle map (process-owned).
1185
+ const agentAlive = activeMergeAgents.has(sessionName);
1186
+
1187
+ if (!agentAlive) {
1188
+ if (sessionDiedAt === null) {
1189
+ sessionDiedAt = Date.now();
1190
+ execLog("merge", sessionName, "agent exited — starting grace period", {
1191
+ graceMs: MERGE_RESULT_GRACE_MS,
1192
+ });
1193
+ } else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
1194
+ // Grace period expired — one final check
1195
+ if (existsSync(resultPath)) {
1196
+ try {
1197
+ return await parseMergeResultAsync(resultPath);
1198
+ } catch {
1199
+ /* fall through */
1200
+ }
1201
+ }
1202
+
1203
+ throw new MergeError(
1204
+ "MERGE_SESSION_DIED",
1205
+ `Merge agent '${sessionName}' exited without writing ` +
1206
+ `a result file to '${resultPath}'. The merge may have crashed. ` +
1207
+ `Check agent logs for diagnostics.`,
1208
+ );
1209
+ }
1210
+ }
1211
+
1212
+ await sleepAsync(MERGE_POLL_INTERVAL_MS);
1213
+ }
1214
+ }
1215
+
1216
+ /**
1217
+ * Force-remove a merge worktree directory and prune stale git references.
1218
+ *
1219
+ * TP-029: Applies the same forceCleanupWorktree pattern used for lane
1220
+ * worktrees. Tries `git worktree remove --force` first, then falls back
1221
+ * to `rm -rf` + `git worktree prune` if the initial removal fails.
1222
+ *
1223
+ * Used in both stale-prep cleanup (before creating a fresh merge worktree)
1224
+ * and end-of-wave cleanup (after merge completes).
1225
+ *
1226
+ * @param mergeWorkDir - Absolute path to the merge worktree directory
1227
+ * @param repoRoot - Main repository root for git operations
1228
+ * @param context - Logging context (e.g., "W1" for wave 1)
1229
+ */
1230
+ function forceRemoveMergeWorktree(mergeWorkDir: string, repoRoot: string, context: string): void {
1231
+ if (!existsSync(mergeWorkDir)) return;
1232
+
1233
+ // Try git worktree remove --force first
1234
+ const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], {
1235
+ cwd: repoRoot,
1236
+ });
1237
+ if (removeResult.status === 0) {
1238
+ return;
1239
+ }
1240
+
1241
+ // Fallback: force-remove the directory and prune git worktree state
1242
+ const stderr = removeResult.stderr?.toString().trim() || "";
1243
+ execLog(
1244
+ "merge",
1245
+ context,
1246
+ `git worktree remove failed for merge worktree, applying force cleanup`,
1247
+ {
1248
+ error: stderr.slice(0, 200),
1249
+ path: mergeWorkDir,
1250
+ },
1251
+ );
1252
+
1253
+ try {
1254
+ rmSync(mergeWorkDir, { recursive: true, force: true });
1255
+ execLog("merge", context, `force-removed merge worktree directory`, { path: mergeWorkDir });
1256
+ } catch (rmErr: unknown) {
1257
+ // Node's rmSync may fail on Windows reserved-name files — try OS-level removal
1258
+ const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
1259
+ execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, {
1260
+ error: rmMsg,
1261
+ });
1262
+ try {
1263
+ if (process.platform === "win32") {
1264
+ execSync(`rd /s /q "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
1265
+ } else {
1266
+ execSync(`rm -rf "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
1267
+ }
1268
+ execLog("merge", context, `OS-level removal of merge worktree succeeded`, {
1269
+ path: mergeWorkDir,
1270
+ });
1271
+ } catch (osErr: unknown) {
1272
+ const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
1273
+ execLog("merge", context, `OS-level removal also failed — manual cleanup needed`, {
1274
+ path: mergeWorkDir,
1275
+ error: osMsg,
1276
+ });
1277
+ }
1278
+ }
1279
+
1280
+ // Prune stale worktree references
1281
+ runGit(["worktree", "prune"], repoRoot);
1282
+ }
1283
+
1284
+ // ── Transaction Record Persistence (TP-033) ─────────────────────────
1285
+
1286
+ /**
1287
+ * Persist a transaction record to disk as JSON.
1288
+ *
1289
+ * Written to: `.pi/verification/{opId}/txn-b{batchId}-repo-{repoId}-wave-{n}-lane-{k}.json`
1290
+ *
1291
+ * When repoId is null/undefined (repo mode), uses "default" as the repo slug.
1292
+ * Non-alphanumeric characters in repoId are sanitized to underscores.
1293
+ *
1294
+ * @param record - The transaction record to persist
1295
+ * @param stateRoot - Root directory for .pi state files
1296
+ */
1297
+ /**
1298
+ * Persist a transaction record to disk. Returns null on success, or an error
1299
+ * message string on failure. Persistence is best-effort — callers should
1300
+ * accumulate errors and surface them in MergeWaveResult.persistenceErrors
1301
+ * so operators know recovery guidance may reference missing files.
1302
+ */
1303
+ function persistTransactionRecord(record: TransactionRecord, stateRoot: string): string | null {
1304
+ try {
1305
+ const repoSlug = record.repoId ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_") : "default";
1306
+ const verifyDir = join(stateRoot, ".pi", "verification", record.opId);
1307
+ mkdirSync(verifyDir, { recursive: true });
1308
+ const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`;
1309
+ writeFileSync(join(verifyDir, fileName), JSON.stringify(record, null, 2), "utf-8");
1310
+ execLog("merge", `W${record.waveIndex}`, `transaction record persisted`, {
1311
+ file: fileName,
1312
+ status: record.status,
1313
+ });
1314
+ return null;
1315
+ } catch (err: unknown) {
1316
+ // Transaction record persistence is best-effort — don't fail the merge
1317
+ const errMsg = err instanceof Error ? err.message : String(err);
1318
+ execLog("merge", `W${record.waveIndex}`, `failed to persist transaction record: ${errMsg}`);
1319
+ return `lane ${record.laneNumber} (repo: ${record.repoId ?? "default"}): ${errMsg}`;
1320
+ }
1321
+ }
1322
+
1323
+ // ── Orchestrator-Side Verification (TP-032) ──────────────────────────
1324
+
1325
+ /**
1326
+ * Run post-merge verification and compare against baseline.
1327
+ *
1328
+ * Captures fingerprints from the merge worktree after a successful merge,
1329
+ * diffs against the pre-merge baseline, and classifies the result:
1330
+ * - "pass": no new failures (only pre-existing or fixed)
1331
+ * - "verification_new_failure": genuinely new failures detected
1332
+ * - "flaky_suspected": new failures disappeared on re-run (warning only)
1333
+ *
1334
+ * Flaky handling: when new failures are detected and flakyReruns > 0,
1335
+ * only the commands that produced new failures are re-run up to
1336
+ * flakyReruns times. If the failures disappear on any re-run attempt,
1337
+ * the result is reclassified as "flaky_suspected". When flakyReruns is
1338
+ * 0, no re-runs are attempted and new failures immediately block.
1339
+ *
1340
+ * @param testingCommands - Named verification commands (from testing.commands config)
1341
+ * @param mergeWorkDir - Merge worktree path (post-merge state)
1342
+ * @param baseline - Pre-merge baseline to compare against
1343
+ * @param laneNumber - Lane number (for logging/persistence)
1344
+ * @param waveIndex - Wave index (for persistence naming)
1345
+ * @param batchId - Batch ID (for persistence naming)
1346
+ * @param opId - Operator ID (for persistence naming)
1347
+ * @param sessionName - Session name for structured logging
1348
+ * @param stateRoot - State root for persistence (workspace root or repo root)
1349
+ * @param repoId - Repository ID for workspace-mode artifact naming (optional)
1350
+ * @param flakyReruns - Number of flaky re-runs (0 = disabled, default 1)
1351
+ * @returns VerificationBaselineResult with classification and details
1352
+ */
1353
+ function runPostMergeVerification(
1354
+ testingCommands: Record<string, string>,
1355
+ mergeWorkDir: string,
1356
+ baseline: VerificationBaseline,
1357
+ laneNumber: number,
1358
+ waveIndex: number,
1359
+ batchId: string,
1360
+ opId: string,
1361
+ sessionName: string,
1362
+ stateRoot: string,
1363
+ repoId?: string,
1364
+ flakyReruns: number = 1,
1365
+ ): VerificationBaselineResult {
1366
+ execLog("merge", sessionName, "capturing post-merge verification fingerprints");
1367
+
1368
+ // Capture post-merge fingerprints
1369
+ const postMerge = captureBaseline(testingCommands, mergeWorkDir);
1370
+
1371
+ // Persist post-merge snapshot for debugging
1372
+ try {
1373
+ const verifyDir = join(stateRoot, ".pi", "verification", opId);
1374
+ mkdirSync(verifyDir, { recursive: true });
1375
+ // TP-032 R006-1: Include repoId in filename to prevent overwrites
1376
+ // when mergeWaveByRepo() calls mergeWave() once per repo group.
1377
+ const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
1378
+ const postFileName = `post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`;
1379
+ writeFileSync(join(verifyDir, postFileName), JSON.stringify(postMerge, null, 2), "utf-8");
1380
+ } catch {
1381
+ // Best effort — persistence failure doesn't block verification
1382
+ }
1383
+
1384
+ // Diff fingerprints
1385
+ const diff = diffFingerprints(baseline.fingerprints, postMerge.fingerprints);
1386
+
1387
+ execLog("merge", sessionName, "verification diff computed", {
1388
+ newFailures: diff.newFailures.length,
1389
+ preExisting: diff.preExisting.length,
1390
+ fixed: diff.fixed.length,
1391
+ });
1392
+
1393
+ // No new failures — pass
1394
+ if (diff.newFailures.length === 0) {
1395
+ return {
1396
+ performed: true,
1397
+ newFailureCount: 0,
1398
+ preExistingCount: diff.preExisting.length,
1399
+ fixedCount: diff.fixed.length,
1400
+ classification: "pass",
1401
+ newFailureSummary: "",
1402
+ flakyRerunPerformed: false,
1403
+ };
1404
+ }
1405
+
1406
+ // ── Flaky re-run: re-run only the commands that produced new failures ──
1407
+ // Only when flakyReruns > 0 (0 = disabled — any new failure immediately blocks)
1408
+ if (flakyReruns > 0) {
1409
+ // Identify which commandIds produced new failures
1410
+ const failedCommandIds = new Set(diff.newFailures.map((fp) => fp.commandId));
1411
+ const rerunCommands: Record<string, string> = {};
1412
+ for (const cmdId of failedCommandIds) {
1413
+ if (testingCommands[cmdId]) {
1414
+ rerunCommands[cmdId] = testingCommands[cmdId];
1415
+ }
1416
+ }
1417
+
1418
+ // Re-run up to flakyReruns times; break early if failures clear
1419
+ let clearedOnRerun = false;
1420
+ for (let attempt = 0; attempt < flakyReruns; attempt++) {
1421
+ execLog(
1422
+ "merge",
1423
+ sessionName,
1424
+ `new failures detected — running flaky re-run ${attempt + 1}/${flakyReruns}`,
1425
+ {
1426
+ failedCommands: [...failedCommandIds].join(", "),
1427
+ rerunCount: Object.keys(rerunCommands).length,
1428
+ },
1429
+ );
1430
+
1431
+ const rerunResults = runVerificationCommands(rerunCommands, mergeWorkDir);
1432
+
1433
+ // Parse re-run fingerprints
1434
+ const rerunFingerprints: TestFingerprint[] = [];
1435
+ for (const result of rerunResults) {
1436
+ const fps = parseTestOutput(result);
1437
+ rerunFingerprints.push(...fps);
1438
+ }
1439
+ const dedupedRerun = deduplicateFingerprints(rerunFingerprints);
1440
+
1441
+ // Re-diff: compare baseline against re-run results for the failed commands only
1442
+ // Filter baseline fingerprints to only the commands we re-ran
1443
+ const baselineForRerun = baseline.fingerprints.filter((fp) =>
1444
+ failedCommandIds.has(fp.commandId),
1445
+ );
1446
+ const rerunDiff = diffFingerprints(baselineForRerun, dedupedRerun);
1447
+
1448
+ if (rerunDiff.newFailures.length === 0) {
1449
+ // Failures disappeared on re-run — flaky suspected
1450
+ execLog(
1451
+ "merge",
1452
+ sessionName,
1453
+ `flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`,
1454
+ );
1455
+ clearedOnRerun = true;
1456
+ break;
1457
+ }
1458
+
1459
+ // If this is the last attempt and failures persist, return failure
1460
+ if (attempt === flakyReruns - 1) {
1461
+ const summary = rerunDiff.newFailures
1462
+ .slice(0, 5)
1463
+ .map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
1464
+ .join("; ");
1465
+ const truncated =
1466
+ rerunDiff.newFailures.length > 5 ? ` ... and ${rerunDiff.newFailures.length - 5} more` : "";
1467
+
1468
+ return {
1469
+ performed: true,
1470
+ newFailureCount: rerunDiff.newFailures.length,
1471
+ preExistingCount: diff.preExisting.length,
1472
+ fixedCount: diff.fixed.length,
1473
+ classification: "verification_new_failure",
1474
+ newFailureSummary: summary + truncated,
1475
+ flakyRerunPerformed: true,
1476
+ };
1477
+ }
1478
+ }
1479
+
1480
+ if (clearedOnRerun) {
1481
+ return {
1482
+ performed: true,
1483
+ newFailureCount: 0,
1484
+ preExistingCount: diff.preExisting.length,
1485
+ fixedCount: diff.fixed.length,
1486
+ classification: "flaky_suspected",
1487
+ newFailureSummary: `Flaky: ${diff.newFailures.length} failure(s) disappeared on re-run`,
1488
+ flakyRerunPerformed: true,
1489
+ };
1490
+ }
1491
+ }
1492
+
1493
+ // flakyReruns === 0 or fallthrough: new failures block immediately
1494
+ const summary = diff.newFailures
1495
+ .slice(0, 5)
1496
+ .map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
1497
+ .join("; ");
1498
+ const truncated =
1499
+ diff.newFailures.length > 5 ? ` ... and ${diff.newFailures.length - 5} more` : "";
1500
+
1501
+ return {
1502
+ performed: true,
1503
+ newFailureCount: diff.newFailures.length,
1504
+ preExistingCount: diff.preExisting.length,
1505
+ fixedCount: diff.fixed.length,
1506
+ classification: "verification_new_failure",
1507
+ newFailureSummary: summary + truncated,
1508
+ flakyRerunPerformed: flakyReruns > 0,
1509
+ };
1510
+ }
1511
+
1512
+ /**
1513
+ * Merge a completed wave's lane branches into the base branch.
1514
+ *
1515
+ * Orchestration flow:
1516
+ * 1. Filter to only succeeded lanes (failed lanes are not merged)
1517
+ * 2. Determine merge order (fewest-files-first or sequential)
1518
+ * 3. For each lane, sequentially:
1519
+ * a. Build merge request content
1520
+ * b. Write merge request to temp file
1521
+ * c. Spawn merge agent session (in main repo)
1522
+ * d. Wait for merge result
1523
+ * e. Handle result (continue, log, or pause)
1524
+ * 4. Return MergeWaveResult
1525
+ *
1526
+ * Sequential execution is mandatory — the base branch is a shared
1527
+ * resource, and each merge must see the prior merge's result.
1528
+ *
1529
+ * On CONFLICT_UNRESOLVED or BUILD_FAILURE: stops merging remaining lanes
1530
+ * and returns with failure status.
1531
+ *
1532
+ * Temp file cleanup: merge request files are cleaned up after each lane,
1533
+ * regardless of outcome. Result files are left for debugging.
1534
+ *
1535
+ * @param completedLanes - Lanes that completed execution (from wave result)
1536
+ * @param waveResult - The wave execution result (for lane status filtering)
1537
+ * @param waveIndex - Wave number (1-indexed)
1538
+ * @param config - Orchestrator configuration
1539
+ * @param repoRoot - Main repository root
1540
+ * @param batchId - Batch ID for session naming
1541
+ * @param baseBranch - Branch to merge into (captured at batch start)
1542
+ * @returns MergeWaveResult with per-lane outcomes
1543
+ */
1544
+ export async function mergeWave(
1545
+ completedLanes: AllocatedLane[],
1546
+ waveResult: WaveExecutionResult,
1547
+ waveIndex: number,
1548
+ config: OrchestratorConfig,
1549
+ repoRoot: string,
1550
+ batchId: string,
1551
+ baseBranch: string,
1552
+ stateRoot?: string,
1553
+ agentRoot?: string,
1554
+ testingCommands?: Record<string, string>,
1555
+ repoId?: string,
1556
+ healthMonitor?: MergeHealthMonitor | null,
1557
+ forceMixedOutcome?: boolean,
1558
+ runtimeBackend?: RuntimeBackend,
1559
+ ): Promise<MergeWaveResult> {
1560
+ const startTime = Date.now();
1561
+ const sessionPrefix = config.orchestrator.sessionPrefix;
1562
+ const opId = resolveOperatorId(config);
1563
+ const targetBranch = baseBranch;
1564
+ const laneResults: MergeLaneResult[] = [];
1565
+
1566
+ // Build lane outcome lookup for merge eligibility checks.
1567
+ const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
1568
+ for (const laneOutcome of waveResult.laneResults) {
1569
+ laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
1570
+ }
1571
+
1572
+ // A lane is mergeable if:
1573
+ // - It has at least one succeeded task, AND
1574
+ // - It has no hard failures (failed/stalled).
1575
+ //
1576
+ // This allows succeeded+skipped lanes (e.g., stop-wave skip of remaining tasks)
1577
+ // to merge their committed work, while excluding mixed succeeded+failed lanes.
1578
+ //
1579
+ // TP-078: When forceMixedOutcome is true, lanes with both succeeded and
1580
+ // failed/stalled tasks are also considered mergeable. This allows the
1581
+ // orch_force_merge tool to merge succeeded commits from mixed-outcome lanes.
1582
+ const mergeableLanes = completedLanes.filter((lane) => {
1583
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
1584
+ if (!outcome) return false;
1585
+
1586
+ const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
1587
+ const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
1588
+
1589
+ if (forceMixedOutcome) {
1590
+ // In force mode, merge any lane with at least one succeeded task
1591
+ return hasSucceeded;
1592
+ }
1593
+
1594
+ return hasSucceeded && !hasHardFailure;
1595
+ });
1596
+
1597
+ if (mergeableLanes.length === 0) {
1598
+ // TP-171: Even when no lanes are mergeable, skipped-task lanes may have
1599
+ // partial progress (STATUS.md updates) that should be staged on the target
1600
+ // branch so it survives integration. Stage artifacts directly without
1601
+ // creating a full merge worktree.
1602
+ const skippedOnlyLanes = completedLanes.filter((lane) => {
1603
+ if (!lane.worktreePath) return false;
1604
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
1605
+ if (!outcome) return false;
1606
+ return outcome.tasks.some((t) => t.status === "skipped");
1607
+ });
1608
+ if (skippedOnlyLanes.length > 0) {
1609
+ stageSkippedArtifactsToTargetBranch(skippedOnlyLanes, waveIndex, repoRoot, targetBranch);
1610
+ }
1611
+
1612
+ execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
1613
+ return {
1614
+ waveIndex,
1615
+ status: "succeeded", // vacuous success — nothing to merge
1616
+ laneResults: [],
1617
+ failedLane: null,
1618
+ failureReason: null,
1619
+ totalDurationMs: Date.now() - startTime,
1620
+ };
1621
+ }
1622
+
1623
+ // Determine merge order
1624
+ const orderedLanes = determineMergeOrder(mergeableLanes, config.merge.order);
1625
+
1626
+ // TP-171: Identify lanes with skipped tasks that are NOT in the mergeable set.
1627
+ // These lanes won't have their branches merged, but their task artifacts
1628
+ // (STATUS.md, .reviews) should still be staged so partial progress is preserved
1629
+ // through integration. Only lanes with worktree paths can contribute artifacts.
1630
+ const mergeableLaneNumbers = new Set(mergeableLanes.map((l) => l.laneNumber));
1631
+ const skippedArtifactLanes = completedLanes.filter((lane) => {
1632
+ if (mergeableLaneNumbers.has(lane.laneNumber)) return false;
1633
+ if (!lane.worktreePath) return false;
1634
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
1635
+ if (!outcome) return false;
1636
+ return outcome.tasks.some((t) => t.status === "skipped");
1637
+ });
1638
+
1639
+ execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, {
1640
+ order: config.merge.order,
1641
+ lanes: orderedLanes.map((l) => l.laneNumber).join(","),
1642
+ skippedArtifactLanes:
1643
+ skippedArtifactLanes.length > 0
1644
+ ? skippedArtifactLanes.map((l) => l.laneNumber).join(",")
1645
+ : undefined,
1646
+ });
1647
+
1648
+ // ── Create isolated merge worktree ──────────────────────────────
1649
+ // Merging in a dedicated worktree prevents dirty-worktree failures
1650
+ // caused by user edits or orchestrator-generated files in the main repo.
1651
+ // The merge worktree lives inside the batch container alongside lane worktrees:
1652
+ // {basePath}/{opId}-{batchId}/merge
1653
+ const tempBranch = `_merge-temp-${opId}-${batchId}`;
1654
+ const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config);
1655
+
1656
+ // Clean up stale merge worktree/branch from prior failed attempt.
1657
+ // TP-029: Apply forceRemoveMergeWorktree fallback so stale merge worktrees
1658
+ // from prior failed attempts don't block new merge creation.
1659
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
1660
+ if (existsSync(mergeWorkDir)) {
1661
+ // Force cleanup didn't fully remove — wait and retry once
1662
+ await sleepAsync(500);
1663
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
1664
+ }
1665
+ try {
1666
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
1667
+ } catch {
1668
+ /* branch may not exist */
1669
+ }
1670
+
1671
+ // Create temp branch at target branch HEAD, then worktree
1672
+ const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot });
1673
+ if (branchResult.status !== 0) {
1674
+ const err = branchResult.stderr?.toString().trim() || "unknown error";
1675
+ execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`);
1676
+ return {
1677
+ waveIndex,
1678
+ status: "failed",
1679
+ laneResults: [],
1680
+ failedLane: null,
1681
+ failureReason: `Failed to create merge temp branch: ${err}`,
1682
+ totalDurationMs: Date.now() - startTime,
1683
+ };
1684
+ }
1685
+
1686
+ const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], {
1687
+ cwd: repoRoot,
1688
+ });
1689
+ if (wtResult.status !== 0) {
1690
+ const err = wtResult.stderr?.toString().trim() || "unknown error";
1691
+ execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`);
1692
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
1693
+ return {
1694
+ waveIndex,
1695
+ status: "failed",
1696
+ laneResults: [],
1697
+ failedLane: null,
1698
+ failureReason: `Failed to create merge worktree: ${err}`,
1699
+ totalDurationMs: Date.now() - startTime,
1700
+ };
1701
+ }
1702
+
1703
+ execLog("merge", `W${waveIndex}`, `merge worktree created`, {
1704
+ worktree: mergeWorkDir,
1705
+ tempBranch,
1706
+ });
1707
+
1708
+ // ── Orchestrator-side baseline capture (TP-032) ────────────────
1709
+ // Capture verification fingerprints on the pre-merge state of the merge
1710
+ // worktree. This baseline is compared against post-merge fingerprints
1711
+ // for each lane to detect genuinely new failures vs pre-existing ones.
1712
+ // Only runs when verification.enabled === true AND testing.commands present.
1713
+ let baseline: VerificationBaseline | null = null;
1714
+ const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
1715
+ const verificationEnabled = config.verification.enabled;
1716
+ const verificationMode = config.verification.mode;
1717
+ const flakyReruns = config.verification.flaky_reruns;
1718
+
1719
+ if (verificationEnabled && !hasTestingCommands) {
1720
+ // Verification is enabled but no testing commands configured — treat as
1721
+ // baseline-unavailable. Strict/permissive handling below.
1722
+ if (verificationMode === "strict") {
1723
+ execLog(
1724
+ "merge",
1725
+ `W${waveIndex}`,
1726
+ "verification enabled but no testing commands configured — strict mode: failing merge",
1727
+ );
1728
+ // Clean up worktree and temp branch before returning failure
1729
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
1730
+ try {
1731
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
1732
+ } catch {
1733
+ /* best effort */
1734
+ }
1735
+ return {
1736
+ waveIndex,
1737
+ status: "failed",
1738
+ laneResults: [],
1739
+ failedLane: null,
1740
+ failureReason:
1741
+ "Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands",
1742
+ totalDurationMs: Date.now() - startTime,
1743
+ };
1744
+ } else {
1745
+ execLog(
1746
+ "merge",
1747
+ `W${waveIndex}`,
1748
+ "verification enabled but no testing commands configured — permissive mode: continuing without verification",
1749
+ );
1750
+ }
1751
+ }
1752
+
1753
+ if (verificationEnabled && hasTestingCommands) {
1754
+ execLog("merge", `W${waveIndex}`, "capturing verification baseline on pre-merge state", {
1755
+ commandCount: Object.keys(testingCommands).length,
1756
+ commands: Object.keys(testingCommands).join(", "),
1757
+ });
1758
+
1759
+ try {
1760
+ baseline = captureBaseline(testingCommands, mergeWorkDir);
1761
+
1762
+ // Persist baseline for debugging/auditability
1763
+ const piDir = stateRoot ?? repoRoot;
1764
+ const verifyDir = join(piDir, ".pi", "verification", opId);
1765
+ mkdirSync(verifyDir, { recursive: true });
1766
+ // TP-032 R006-1: Include repoId in filename to prevent overwrites
1767
+ // when mergeWaveByRepo() calls mergeWave() once per repo group.
1768
+ const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
1769
+ const baselineFileName = `baseline-b${batchId}-w${waveIndex}${repoSuffix}.json`;
1770
+ writeFileSync(join(verifyDir, baselineFileName), JSON.stringify(baseline, null, 2), "utf-8");
1771
+
1772
+ execLog("merge", `W${waveIndex}`, "verification baseline captured", {
1773
+ fingerprints: baseline.fingerprints.length,
1774
+ preExistingFailures: baseline.fingerprints.length,
1775
+ storedAt: join(verifyDir, baselineFileName),
1776
+ });
1777
+ } catch (err: unknown) {
1778
+ const errMsg = err instanceof Error ? err.message : String(err);
1779
+ if (verificationMode === "strict") {
1780
+ execLog("merge", `W${waveIndex}`, `baseline capture failed — strict mode: failing merge`, {
1781
+ error: errMsg,
1782
+ });
1783
+ // Clean up worktree and temp branch before returning failure
1784
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
1785
+ try {
1786
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
1787
+ } catch {
1788
+ /* best effort */
1789
+ }
1790
+ return {
1791
+ waveIndex,
1792
+ status: "failed",
1793
+ laneResults: [],
1794
+ failedLane: null,
1795
+ failureReason: `Verification baseline capture failed (strict mode): ${errMsg}`,
1796
+ totalDurationMs: Date.now() - startTime,
1797
+ };
1798
+ }
1799
+ execLog(
1800
+ "merge",
1801
+ `W${waveIndex}`,
1802
+ `baseline capture failed — permissive mode: continuing without baseline verification`,
1803
+ {
1804
+ error: errMsg,
1805
+ },
1806
+ );
1807
+ // Permissive: baseline capture failure is non-fatal — merge proceeds without
1808
+ // orchestrator-side verification. Merge-agent verification (merge.verify)
1809
+ // still applies independently.
1810
+ baseline = null;
1811
+ }
1812
+ }
1813
+
1814
+ // Sequential merge loop
1815
+ let failedLane: number | null = null;
1816
+ let failureReason: string | null = null;
1817
+ // TP-032 R006-2: When verification rollback fails, the temp branch still contains
1818
+ // the bad merge commit. Branch advancement MUST be blocked entirely — not just for
1819
+ // the verification-blocked lane, but for all lanes, because the temp branch HEAD
1820
+ // includes the unverified commit and any prior successful merges built on top of it.
1821
+ let blockAdvancement = false;
1822
+
1823
+ // TP-033: Collect transaction records for all lane merges in this wave
1824
+ const transactionRecords: TransactionRecord[] = [];
1825
+ // TP-033 R004-2: Track persistence errors for operator visibility
1826
+ const persistenceErrors: string[] = [];
1827
+ // TP-033: Track whether any rollback failure triggered safe-stop
1828
+ let rollbackFailed = false;
1829
+
1830
+ for (const lane of orderedLanes) {
1831
+ const laneStart = Date.now();
1832
+ const txnStartedAt = new Date().toISOString();
1833
+ const sessionName = `${sessionPrefix}-${opId}-merge-${lane.laneNumber}`;
1834
+ const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
1835
+ const piDir = stateRoot ?? repoRoot;
1836
+ const resultFilePath = join(piDir, ".pi", resultFileName);
1837
+ const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
1838
+ const requestFilePath = join(piDir, ".pi", requestFileName);
1839
+
1840
+ // ── TP-033: Capture baseHEAD (temp branch HEAD before lane merge) ──
1841
+ // Always captured for transaction record — not conditional on baseline.
1842
+ // This is the rollback target if verification detects new failures.
1843
+ let baseHEAD = "";
1844
+ {
1845
+ const headResult = spawnSync("git", ["rev-parse", "HEAD"], {
1846
+ cwd: mergeWorkDir,
1847
+ encoding: "utf-8",
1848
+ });
1849
+ if (headResult.status === 0) {
1850
+ baseHEAD = headResult.stdout.trim();
1851
+ }
1852
+ }
1853
+
1854
+ // ── TP-033: Capture laneHEAD (source branch tip being merged in) ──
1855
+ let laneHEAD = "";
1856
+ {
1857
+ const laneRef = spawnSync("git", ["rev-parse", lane.branch], {
1858
+ cwd: repoRoot,
1859
+ encoding: "utf-8",
1860
+ });
1861
+ if (laneRef.status === 0) {
1862
+ laneHEAD = laneRef.stdout.trim();
1863
+ }
1864
+ }
1865
+
1866
+ // TP-032 compat: preLaneHead is baseHEAD (renamed for clarity in txn model)
1867
+ const preLaneHead = baseHEAD;
1868
+
1869
+ execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
1870
+ sourceBranch: lane.branch,
1871
+ targetBranch,
1872
+ baseHEAD: baseHEAD.slice(0, 8),
1873
+ laneHEAD: laneHEAD.slice(0, 8),
1874
+ });
1875
+
1876
+ try {
1877
+ // Clean up any stale result file from prior attempt
1878
+ if (existsSync(resultFilePath)) {
1879
+ try {
1880
+ unlinkSync(resultFilePath);
1881
+ } catch {
1882
+ // Best effort
1883
+ }
1884
+ }
1885
+
1886
+ // Build merge request content
1887
+ // TP-032 R006-3: Preserve merge.verify commands independently of baseline
1888
+ // fingerprinting. The orchestrator-side baseline comparison (testing.commands)
1889
+ // is additive — it does NOT replace the merge agent's own verification
1890
+ // (merge.verify). Agents may run build checks or other non-fingerprintable
1891
+ // commands via merge.verify that must not be silently suppressed.
1892
+ const mergeRequestContent = buildMergeRequest(
1893
+ lane,
1894
+ targetBranch,
1895
+ waveIndex,
1896
+ config.merge.verify,
1897
+ resultFilePath,
1898
+ );
1899
+
1900
+ // Write merge request to temp file
1901
+ writeFileSync(requestFilePath, mergeRequestContent, "utf-8");
1902
+
1903
+ // ── TP-038: Spawn + wait with retry-on-timeout ──────────────
1904
+ // On MERGE_TIMEOUT, retry with 2× the previous timeout (up to
1905
+ // MERGE_TIMEOUT_MAX_RETRIES). Before each retry, re-read config
1906
+ // from disk so operators can increase merge.timeoutMinutes without
1907
+ // restarting the session.
1908
+ let mergeResult: MergeResult;
1909
+ {
1910
+ const configRoot = stateRoot ?? repoRoot;
1911
+ let currentTimeoutMs = (config.merge.timeout_minutes ?? 10) * 60 * 1000;
1912
+ let lastTimeoutError: MergeError | null = null;
1913
+
1914
+ for (let attempt = 0; attempt <= MERGE_TIMEOUT_MAX_RETRIES; attempt++) {
1915
+ // On retry: clean up stale result, re-read config, apply backoff
1916
+ if (attempt > 0) {
1917
+ // Re-read config from disk (TP-038: allows operator to adjust timeout)
1918
+ const freshTimeoutMs = reloadMergeTimeoutMs(configRoot);
1919
+ // Apply 2× backoff: double the timeout for each retry attempt
1920
+ currentTimeoutMs = freshTimeoutMs * Math.pow(2, attempt);
1921
+
1922
+ execLog(
1923
+ "merge",
1924
+ sessionName,
1925
+ `retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent`,
1926
+ {
1927
+ newTimeoutMs: currentTimeoutMs,
1928
+ newTimeoutMin: Math.round(currentTimeoutMs / 60_000),
1929
+ attempt,
1930
+ },
1931
+ );
1932
+
1933
+ // Clean up stale result file from prior attempt
1934
+ if (existsSync(resultFilePath)) {
1935
+ try {
1936
+ unlinkSync(resultFilePath);
1937
+ } catch {
1938
+ /* best effort */
1939
+ }
1940
+ }
1941
+
1942
+ // Re-spawn merge agent for the retry.
1943
+ // Kill previous V2 agent handle to prevent orphan/duplicate.
1944
+ killMergeAgentV2(sessionName);
1945
+ await spawnMergeAgentV2(
1946
+ sessionName,
1947
+ repoRoot,
1948
+ mergeWorkDir,
1949
+ requestFilePath,
1950
+ config,
1951
+ stateRoot,
1952
+ agentRoot,
1953
+ batchId,
1954
+ waveIndex,
1955
+ );
1956
+ } else {
1957
+ // First attempt: spawn merge agent (Runtime V2)
1958
+ await spawnMergeAgentV2(
1959
+ sessionName,
1960
+ repoRoot,
1961
+ mergeWorkDir,
1962
+ requestFilePath,
1963
+ config,
1964
+ stateRoot,
1965
+ agentRoot,
1966
+ batchId,
1967
+ waveIndex,
1968
+ );
1969
+ }
1970
+
1971
+ try {
1972
+ mergeResult = await waitForMergeResult(
1973
+ resultFilePath,
1974
+ sessionName,
1975
+ currentTimeoutMs,
1976
+ runtimeBackend,
1977
+ );
1978
+ // TP-056: Deregister session from health monitor on completion
1979
+ if (healthMonitor) healthMonitor.removeSession(sessionName);
1980
+ lastTimeoutError = null;
1981
+ break; // Success — exit retry loop
1982
+ } catch (waitErr: unknown) {
1983
+ if (
1984
+ waitErr instanceof MergeError &&
1985
+ waitErr.code === "MERGE_TIMEOUT" &&
1986
+ attempt < MERGE_TIMEOUT_MAX_RETRIES
1987
+ ) {
1988
+ // Timeout — will retry on next loop iteration
1989
+ lastTimeoutError = waitErr;
1990
+ // TP-056: Deregister before retry (will re-register on respawn)
1991
+ if (healthMonitor) healthMonitor.removeSession(sessionName);
1992
+ continue;
1993
+ }
1994
+ // Non-timeout error or final retry exhausted — propagate
1995
+ // TP-056: Deregister session from health monitor on error
1996
+ if (healthMonitor) healthMonitor.removeSession(sessionName);
1997
+ throw waitErr;
1998
+ }
1999
+ }
2000
+
2001
+ // TypeScript: mergeResult is guaranteed to be assigned here
2002
+ // (either break from loop or throw propagated the error)
2003
+ mergeResult = mergeResult!;
2004
+ }
2005
+
2006
+ // Clean up request file (leave result file for debugging)
2007
+ try {
2008
+ unlinkSync(requestFilePath);
2009
+ } catch {
2010
+ // Best effort
2011
+ }
2012
+
2013
+ // Record lane result (verificationBaseline populated below if applicable)
2014
+ const laneResult: MergeLaneResult = {
2015
+ laneNumber: lane.laneNumber,
2016
+ laneId: lane.laneId,
2017
+ sourceBranch: lane.branch,
2018
+ targetBranch,
2019
+ result: mergeResult,
2020
+ error: null,
2021
+ durationMs: Date.now() - laneStart,
2022
+ repoId: lane.repoId,
2023
+ };
2024
+ laneResults.push(laneResult);
2025
+
2026
+ // Handle merge outcome
2027
+ switch (mergeResult.status) {
2028
+ case "SUCCESS":
2029
+ execLog("merge", sessionName, "merge succeeded", {
2030
+ mergeCommit: mergeResult.merge_commit.slice(0, 8),
2031
+ duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
2032
+ });
2033
+ break;
2034
+
2035
+ case "CONFLICT_RESOLVED":
2036
+ execLog("merge", sessionName, "merge succeeded with resolved conflicts", {
2037
+ mergeCommit: mergeResult.merge_commit.slice(0, 8),
2038
+ conflictCount: mergeResult.conflicts.length,
2039
+ duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
2040
+ });
2041
+ break;
2042
+
2043
+ case "CONFLICT_UNRESOLVED":
2044
+ execLog("merge", sessionName, "merge failed — unresolved conflicts", {
2045
+ conflictCount: mergeResult.conflicts.length,
2046
+ files: mergeResult.conflicts.map((c) => c.file).join(", "),
2047
+ });
2048
+ failedLane = lane.laneNumber;
2049
+ failureReason =
2050
+ `Unresolved merge conflicts in lane ${lane.laneNumber}: ` +
2051
+ mergeResult.conflicts.map((c) => c.file).join(", ");
2052
+ break;
2053
+
2054
+ case "BUILD_FAILURE":
2055
+ // TP-032: When baseline is active, BUILD_FAILURE from the merge agent
2056
+ // should not normally occur (we suppress verify commands). But if it does
2057
+ // (e.g., agent detected build failure independently), log and proceed as
2058
+ // a regular failure — the orchestrator-side verification below will not
2059
+ // run because the agent already reverted the merge commit.
2060
+ execLog("merge", sessionName, "merge failed — verification failed", {
2061
+ output: mergeResult.verification.output.slice(0, 200),
2062
+ baselineActive: !!baseline,
2063
+ });
2064
+ failedLane = lane.laneNumber;
2065
+ failureReason =
2066
+ `Post-merge verification failed in lane ${lane.laneNumber}: ` +
2067
+ mergeResult.verification.output.slice(0, 500);
2068
+ break;
2069
+ }
2070
+
2071
+ // ── TP-033: Capture mergedHEAD after successful merge commit ──
2072
+ let mergedHEAD: string | null = null;
2073
+ if (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") {
2074
+ const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], {
2075
+ cwd: mergeWorkDir,
2076
+ encoding: "utf-8",
2077
+ });
2078
+ if (postMergeRef.status === 0) {
2079
+ mergedHEAD = postMergeRef.stdout.trim();
2080
+ }
2081
+ }
2082
+
2083
+ // ── TP-033: Initialize transaction record for this lane ──
2084
+ let txnStatus: TransactionStatus = failedLane !== null ? "merge_failed" : "committed";
2085
+ let txnRollbackAttempted = false;
2086
+ let txnRollbackResult: string | null = null;
2087
+ let txnRecoveryCommands: string[] = [];
2088
+
2089
+ // ── Orchestrator-side post-merge verification (TP-032) ──────
2090
+ // After a successful merge (SUCCESS/CONFLICT_RESOLVED), capture
2091
+ // post-merge fingerprints and diff against baseline. New failures
2092
+ // that weren't in the baseline block merge advancement.
2093
+ if (
2094
+ baseline !== null &&
2095
+ hasTestingCommands &&
2096
+ verificationEnabled &&
2097
+ failedLane === null &&
2098
+ (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED")
2099
+ ) {
2100
+ const verificationResult = runPostMergeVerification(
2101
+ testingCommands!,
2102
+ mergeWorkDir,
2103
+ baseline,
2104
+ lane.laneNumber,
2105
+ waveIndex,
2106
+ batchId,
2107
+ opId,
2108
+ sessionName,
2109
+ stateRoot ?? repoRoot,
2110
+ repoId,
2111
+ flakyReruns,
2112
+ );
2113
+
2114
+ // Attach verification result to the lane result
2115
+ laneResult.verificationBaseline = verificationResult;
2116
+
2117
+ if (verificationResult.classification === "verification_new_failure") {
2118
+ execLog("merge", sessionName, "orchestrator-side verification detected new failures", {
2119
+ newFailures: verificationResult.newFailureCount,
2120
+ preExisting: verificationResult.preExistingCount,
2121
+ summary: verificationResult.newFailureSummary.slice(0, 200),
2122
+ });
2123
+
2124
+ // ── TP-032: Rollback merge commit on verification_new_failure ──
2125
+ // Reset the temp branch to pre-lane HEAD so the failed lane's
2126
+ // merge commit doesn't get included in branch advancement.
2127
+ // TP-032 R006-2: Mark lane as errored so it's excluded from success
2128
+ // counters and branch advancement (R006-3).
2129
+ laneResult.error = `verification_new_failure: ${verificationResult.newFailureCount} new failure(s)`;
2130
+
2131
+ if (preLaneHead) {
2132
+ txnRollbackAttempted = true;
2133
+ execLog("merge", sessionName, "rolling back temp branch to pre-lane HEAD", {
2134
+ preLaneHead: preLaneHead.slice(0, 8),
2135
+ });
2136
+ const resetResult = spawnSync("git", ["reset", "--hard", preLaneHead], { cwd: mergeWorkDir });
2137
+ if (resetResult.status === 0) {
2138
+ execLog("merge", sessionName, "temp branch rolled back successfully");
2139
+ txnStatus = "rolled_back";
2140
+ txnRollbackResult = "success";
2141
+ } else {
2142
+ // TP-032 R006-2: Rollback failure is merge-fatal for this wave.
2143
+ // The temp branch still contains the failing merge commit — target
2144
+ // ref advancement MUST NOT proceed for ANY lane, because the temp
2145
+ // branch HEAD includes the unverified commit.
2146
+ const resetErr = resetResult.stderr?.toString().trim() || "unknown error";
2147
+ laneResult.error =
2148
+ `verification_new_failure: rollback reset failed (${resetErr}) — ` +
2149
+ `temp branch may contain failing merge commit, advancement blocked`;
2150
+ blockAdvancement = true;
2151
+ txnStatus = "rollback_failed";
2152
+ txnRollbackResult = `reset failed: ${resetErr}`;
2153
+
2154
+ // ── TP-033: Safe-stop — emit recovery commands ──
2155
+ txnRecoveryCommands = [
2156
+ `# Recovery: manually reset merge worktree to pre-lane HEAD`,
2157
+ `cd "${mergeWorkDir}"`,
2158
+ `git reset --hard ${preLaneHead}`,
2159
+ `# Then re-run merge or resume orchestration`,
2160
+ ];
2161
+ rollbackFailed = true;
2162
+
2163
+ execLog(
2164
+ "merge",
2165
+ sessionName,
2166
+ `CRITICAL: rollback reset failed: ${resetErr} — safe-stop triggered`,
2167
+ {
2168
+ preLaneHead: preLaneHead.slice(0, 8),
2169
+ recoveryCommands: txnRecoveryCommands,
2170
+ },
2171
+ );
2172
+ }
2173
+ } else {
2174
+ // TP-032 R006-2: No pre-lane HEAD captured — cannot roll back.
2175
+ // Block advancement since the bad commit cannot be removed.
2176
+ laneResult.error =
2177
+ `verification_new_failure: no pre-lane HEAD available for rollback — ` +
2178
+ `advancement blocked`;
2179
+ blockAdvancement = true;
2180
+ txnStatus = "rollback_failed";
2181
+ txnRollbackAttempted = false;
2182
+ txnRollbackResult = "no baseHEAD captured — rollback impossible";
2183
+
2184
+ // ── TP-033: Safe-stop — emit recovery commands ──
2185
+ txnRecoveryCommands = [
2186
+ `# Recovery: no baseHEAD was captured for rollback`,
2187
+ `# Inspect merge worktree state manually:`,
2188
+ `cd "${mergeWorkDir}"`,
2189
+ `git log --oneline -5`,
2190
+ `# Determine the correct pre-merge commit and reset:`,
2191
+ `# git reset --hard <correct-commit>`,
2192
+ ];
2193
+ rollbackFailed = true;
2194
+
2195
+ execLog(
2196
+ "merge",
2197
+ sessionName,
2198
+ "CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered",
2199
+ );
2200
+ }
2201
+
2202
+ failedLane = lane.laneNumber;
2203
+ failureReason =
2204
+ `Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` +
2205
+ `in lane ${lane.laneNumber} (${verificationResult.preExistingCount} pre-existing). ` +
2206
+ verificationResult.newFailureSummary.slice(0, 300);
2207
+ } else if (verificationResult.classification === "flaky_suspected") {
2208
+ execLog(
2209
+ "merge",
2210
+ sessionName,
2211
+ "flaky test suspected — failures disappeared on re-run (warning only)",
2212
+ {
2213
+ newFailures: verificationResult.newFailureCount,
2214
+ flakyRerun: true,
2215
+ },
2216
+ );
2217
+ // Warning only — does not block merge advancement
2218
+ } else {
2219
+ execLog("merge", sessionName, "orchestrator-side verification passed", {
2220
+ preExisting: verificationResult.preExistingCount,
2221
+ fixed: verificationResult.fixedCount,
2222
+ });
2223
+ }
2224
+ }
2225
+
2226
+ // ── TP-033: Persist transaction record for this lane ──
2227
+ const txnRecord: TransactionRecord = {
2228
+ opId,
2229
+ batchId,
2230
+ waveIndex,
2231
+ laneNumber: lane.laneNumber,
2232
+ repoId: repoId ?? null,
2233
+ baseHEAD,
2234
+ laneHEAD,
2235
+ mergedHEAD,
2236
+ status: txnStatus,
2237
+ rollbackAttempted: txnRollbackAttempted,
2238
+ rollbackResult: txnRollbackResult,
2239
+ recoveryCommands: txnRecoveryCommands,
2240
+ startedAt: txnStartedAt,
2241
+ completedAt: new Date().toISOString(),
2242
+ };
2243
+ transactionRecords.push(txnRecord);
2244
+ const txnPersistError = persistTransactionRecord(txnRecord, stateRoot ?? repoRoot);
2245
+ if (txnPersistError) persistenceErrors.push(txnPersistError);
2246
+
2247
+ // Stop merging if this lane failed
2248
+ if (failedLane !== null) break;
2249
+ } catch (err: unknown) {
2250
+ // Clean up request file on error
2251
+ try {
2252
+ if (existsSync(requestFilePath)) unlinkSync(requestFilePath);
2253
+ } catch {
2254
+ // Best effort
2255
+ }
2256
+
2257
+ // Kill merge agent if still alive.
2258
+ killMergeAgentV2(sessionName);
2259
+
2260
+ const errMsg = err instanceof Error ? err.message : String(err);
2261
+ const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
2262
+
2263
+ execLog("merge", sessionName, `merge error: ${errMsg}`, { code: errCode });
2264
+
2265
+ laneResults.push({
2266
+ laneNumber: lane.laneNumber,
2267
+ laneId: lane.laneId,
2268
+ sourceBranch: lane.branch,
2269
+ targetBranch,
2270
+ result: null,
2271
+ error: errMsg,
2272
+ durationMs: Date.now() - laneStart,
2273
+ repoId: lane.repoId,
2274
+ });
2275
+
2276
+ // ── TP-033: Transaction record for merge error ──
2277
+ const errorTxnRecord: TransactionRecord = {
2278
+ opId,
2279
+ batchId,
2280
+ waveIndex,
2281
+ laneNumber: lane.laneNumber,
2282
+ repoId: repoId ?? null,
2283
+ baseHEAD,
2284
+ laneHEAD,
2285
+ mergedHEAD: null,
2286
+ status: "merge_failed",
2287
+ rollbackAttempted: false,
2288
+ rollbackResult: null,
2289
+ recoveryCommands: [],
2290
+ startedAt: txnStartedAt,
2291
+ completedAt: new Date().toISOString(),
2292
+ };
2293
+ transactionRecords.push(errorTxnRecord);
2294
+ const errorTxnPersistError = persistTransactionRecord(errorTxnRecord, stateRoot ?? repoRoot);
2295
+ if (errorTxnPersistError) persistenceErrors.push(errorTxnPersistError);
2296
+
2297
+ failedLane = lane.laneNumber;
2298
+ failureReason = `Merge error in lane ${lane.laneNumber}: ${errMsg}`;
2299
+ break;
2300
+ }
2301
+ }
2302
+
2303
+ // ── Stage workspace task artifacts into merge worktree ──────────
2304
+ // TP-035: Tightened artifact staging — only allowlisted task-owned files
2305
+ // are staged. The allowlist is derived per-task-folder from completed lanes:
2306
+ // `.DONE`, `STATUS.md`, `REVIEW_VERDICT.json`, and `.reviews/**` files.
2307
+ // Files outside known task folders, worktree internals, and repo-escape
2308
+ // paths are rejected. Uses resolve+relative path containment consistent
2309
+ // with ensureTaskFilesCommitted() in execution.ts.
2310
+ if (mergeWorkDir) {
2311
+ // Build the set of allowed artifact paths (repo-root-relative) from
2312
+ // the completed lanes' task folders.
2313
+ //
2314
+ // Allowlist policy:
2315
+ // - task marker files: .DONE, STATUS.md, REVIEW_VERDICT.json
2316
+ // - review outputs under task-local .reviews/**
2317
+ const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
2318
+ const ALLOWED_ARTIFACT_DIRS = [".reviews"];
2319
+ const resolvedRepoRoot = resolve(repoRoot);
2320
+ const allowedRelPaths = new Set<string>();
2321
+ const relPathToWorktree = new Map<string, string>();
2322
+
2323
+ const listFilesRecursively = (rootDir: string): string[] => {
2324
+ if (!existsSync(rootDir)) return [];
2325
+ const files: string[] = [];
2326
+ const walk = (dir: string): void => {
2327
+ let entries: Dirent[];
2328
+ try {
2329
+ entries = readdirSync(dir, { withFileTypes: true });
2330
+ } catch {
2331
+ return;
2332
+ }
2333
+ for (const entry of entries) {
2334
+ const absPath = join(dir, entry.name);
2335
+ if (entry.isDirectory()) {
2336
+ walk(absPath);
2337
+ continue;
2338
+ }
2339
+ if (!entry.isFile()) continue;
2340
+ const relPath = relative(rootDir, absPath).replace(/\\/g, "/");
2341
+ if (!relPath || relPath.startsWith("..") || relPath.startsWith("/")) continue;
2342
+ files.push(relPath);
2343
+ }
2344
+ };
2345
+ walk(rootDir);
2346
+ return files;
2347
+ };
2348
+
2349
+ // TP-171: Skipped-artifact lanes get a restricted allowlist — no .DONE
2350
+ // because their code was not merged; staging .DONE would create false
2351
+ // completion markers on the orch branch.
2352
+ const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"];
2353
+ const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map((l) => l.laneNumber));
2354
+
2355
+ // Include both merged lanes and skipped-artifact lanes in staging.
2356
+ const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes];
2357
+ for (const lane of artifactStagingLanes) {
2358
+ // Select allowlist based on lane type
2359
+ const isSkippedOnly = skippedArtifactLaneNumbers.has(lane.laneNumber);
2360
+ const nameAllowlist = isSkippedOnly ? SKIPPED_ARTIFACT_NAMES : ALLOWED_ARTIFACT_NAMES;
2361
+
2362
+ for (const allocTask of lane.tasks) {
2363
+ if (!allocTask.task?.taskFolder?.trim()) {
2364
+ execLog(
2365
+ "merge",
2366
+ `W${waveIndex}`,
2367
+ `skipping task with missing taskFolder (possibly dynamically expanded)`,
2368
+ {
2369
+ taskId: allocTask.taskId,
2370
+ },
2371
+ );
2372
+ continue;
2373
+ }
2374
+ const absFolder = resolve(allocTask.task.taskFolder);
2375
+ const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
2376
+
2377
+ // Reject paths that escape the repo root
2378
+ if (relFolder.startsWith("..") || relFolder.startsWith("/")) {
2379
+ execLog("merge", `W${waveIndex}`, `skipping task folder outside repo root`, {
2380
+ taskId: allocTask.taskId,
2381
+ folder: relFolder,
2382
+ });
2383
+ continue;
2384
+ }
2385
+
2386
+ for (const name of nameAllowlist) {
2387
+ const rp = `${relFolder}/${name}`;
2388
+ allowedRelPaths.add(rp);
2389
+ relPathToWorktree.set(rp, join(lane.worktreePath, rp));
2390
+ }
2391
+
2392
+ for (const dirName of ALLOWED_ARTIFACT_DIRS) {
2393
+ const laneDir = join(lane.worktreePath, relFolder, dirName);
2394
+ for (const relFile of listFilesRecursively(laneDir)) {
2395
+ const rp = `${relFolder}/${dirName}/${relFile}`;
2396
+ allowedRelPaths.add(rp);
2397
+ relPathToWorktree.set(rp, join(lane.worktreePath, rp));
2398
+ }
2399
+
2400
+ const repoDir = join(repoRoot, relFolder, dirName);
2401
+ for (const relFile of listFilesRecursively(repoDir)) {
2402
+ const rp = `${relFolder}/${dirName}/${relFile}`;
2403
+ allowedRelPaths.add(rp);
2404
+ }
2405
+ }
2406
+ }
2407
+ }
2408
+
2409
+ if (allowedRelPaths.size > 0) {
2410
+ let staged = 0;
2411
+ let skipped = 0;
2412
+ let preserved = 0;
2413
+
2414
+ for (const relPath of allowedRelPaths) {
2415
+ const destPath = join(mergeWorkDir, relPath);
2416
+
2417
+ // TP-099: If the file already exists in mergeWorkDir (from lane merge),
2418
+ // do NOT overwrite it — the lane merge brought the correct worker-updated
2419
+ // version (e.g., STATUS.md with checked items, execution log, discoveries).
2420
+ // Overwriting from repoRoot would revert to the pre-execution template.
2421
+ if (existsSync(destPath)) {
2422
+ preserved++;
2423
+ continue;
2424
+ }
2425
+
2426
+ // File missing from mergeWorkDir — backfill from best available source.
2427
+ // Primary: lane worktree (has worker-generated .DONE/STATUS/.reviews content).
2428
+ // Fallback: repoRoot (original task folder, with path containment check).
2429
+ const worktreeSrc = relPathToWorktree.get(relPath);
2430
+ let srcPath: string | null = null;
2431
+
2432
+ // Try lane worktree first (trusted engine-allocated path)
2433
+ if (worktreeSrc && existsSync(worktreeSrc)) {
2434
+ srcPath = worktreeSrc;
2435
+ } else {
2436
+ // Fallback to repoRoot with path containment check (TP-035 hardening)
2437
+ const repoRootSrc = join(repoRoot, relPath);
2438
+ if (existsSync(repoRootSrc)) {
2439
+ const resolvedSrc = resolve(repoRootSrc);
2440
+ const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/");
2441
+ if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) {
2442
+ execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, {
2443
+ path: relPath,
2444
+ src: repoRootSrc,
2445
+ });
2446
+ continue;
2447
+ }
2448
+ srcPath = repoRootSrc;
2449
+ }
2450
+ }
2451
+ if (!srcPath) continue; // File not present anywhere — skip silently
2452
+
2453
+ try {
2454
+ mkdirSync(dirname(destPath), { recursive: true });
2455
+ copyFileSync(srcPath, destPath);
2456
+ // Use pathspec-safe staging with -- separator
2457
+ spawnSync("git", ["add", "--", relPath], { cwd: mergeWorkDir });
2458
+ staged++;
2459
+ } catch {
2460
+ skipped++;
2461
+ execLog("merge", `W${waveIndex}`, `failed to stage artifact`, { path: relPath });
2462
+ }
2463
+ }
2464
+
2465
+ if (staged > 0) {
2466
+ spawnSync(
2467
+ "git",
2468
+ [
2469
+ "commit",
2470
+ "-m",
2471
+ `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`,
2472
+ ],
2473
+ { cwd: mergeWorkDir },
2474
+ );
2475
+ execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
2476
+ skipped,
2477
+ preserved,
2478
+ allowedCandidates: allowedRelPaths.size,
2479
+ });
2480
+ } else {
2481
+ execLog(
2482
+ "merge",
2483
+ `W${waveIndex}`,
2484
+ `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`,
2485
+ );
2486
+ }
2487
+
2488
+ // Keep both .DONE and STATUS.md in develop's working tree:
2489
+ // - STATUS.md: dashboard reads current progress from canonical path
2490
+ // - .DONE: harmless untracked files, cleaned up by /orch-integrate stash
2491
+ // Previous approach of deleting .DONE caused them to be missing
2492
+ // after ff integration (git couldn't reliably restore them).
2493
+ }
2494
+ }
2495
+
2496
+ // ── Update target branch ref and clean up merge worktree ────────
2497
+ // TP-032 R006-2: blockAdvancement overrides all success determination.
2498
+ // When verification rollback fails, the temp branch contains a bad merge commit
2499
+ // that would be included in branch advancement — so we block entirely.
2500
+ // Also exclude verification_new_failure lanes (with successful rollback) from
2501
+ // success accounting: they have laneResult.error set, so !r.error filters them.
2502
+ const anySuccess =
2503
+ !blockAdvancement &&
2504
+ laneResults.some(
2505
+ (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
2506
+ );
2507
+
2508
+ if (blockAdvancement) {
2509
+ execLog(
2510
+ "merge",
2511
+ `W${waveIndex}`,
2512
+ "branch advancement BLOCKED due to verification rollback failure — " +
2513
+ "temp branch may contain unverified merge commit",
2514
+ );
2515
+ }
2516
+
2517
+ if (anySuccess) {
2518
+ // Get the temp branch HEAD commit — this is the merged result.
2519
+ const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
2520
+
2521
+ if (revParseResult.status !== 0) {
2522
+ const err = revParseResult.stderr?.toString().trim() || "unknown error";
2523
+ execLog("merge", `W${waveIndex}`, `failed to resolve temp branch HEAD: ${err}`, { tempBranch });
2524
+ failedLane = failedLane ?? -1;
2525
+ failureReason = `Failed to resolve merge temp branch HEAD (${tempBranch}): ${err}`;
2526
+ } else {
2527
+ const tempBranchHead = revParseResult.stdout.toString().trim();
2528
+
2529
+ // Gate advancement strategy:
2530
+ // - If targetBranch is NOT checked out in repoRoot, use update-ref
2531
+ // (safe, does not touch the working tree). This is the common case
2532
+ // for the orch branch in repo mode.
2533
+ // - If targetBranch IS checked out in repoRoot (workspace mode, where
2534
+ // resolveBaseBranch returns the repo's current branch), use
2535
+ // git merge --ff-only to advance HEAD+index+worktree together.
2536
+ const checkedOutBranch = getCurrentBranch(repoRoot);
2537
+ const targetIsCheckedOut = checkedOutBranch === targetBranch;
2538
+
2539
+ if (targetIsCheckedOut) {
2540
+ // Checked-out branch — must use ff-only to keep HEAD/index/worktree in sync.
2541
+ // Dirty working tree may block ff — stash if needed.
2542
+ const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
2543
+
2544
+ if (ffResult.status !== 0) {
2545
+ // Dirty working tree may block ff — try stash + ff + pop
2546
+ execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
2547
+ const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
2548
+ spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
2549
+
2550
+ const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
2551
+
2552
+ // Always pop stash, regardless of ff result
2553
+ spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
2554
+
2555
+ if (ffRetry.status !== 0) {
2556
+ const err = ffRetry.stderr?.toString().trim() || "unknown error";
2557
+ execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
2558
+ failedLane = failedLane ?? -1;
2559
+ failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
2560
+ } else {
2561
+ execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
2562
+ }
2563
+ } else {
2564
+ execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
2565
+ }
2566
+ } else {
2567
+ // Not checked out — safe to use update-ref without touching the worktree.
2568
+ // Use compare-and-swap (3-arg form) to guard against concurrent branch movement.
2569
+ const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], {
2570
+ cwd: repoRoot,
2571
+ });
2572
+ const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : "";
2573
+
2574
+ const updateRefArgs = oldRef
2575
+ ? ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead, oldRef]
2576
+ : ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead];
2577
+
2578
+ const updateRefResult = spawnSync("git", updateRefArgs, { cwd: repoRoot });
2579
+
2580
+ if (updateRefResult.status !== 0) {
2581
+ const err = updateRefResult.stderr?.toString().trim() || "unknown error";
2582
+ execLog("merge", `W${waveIndex}`, `update-ref failed for ${targetBranch}: ${err}`, {
2583
+ targetBranch,
2584
+ tempBranchHead: tempBranchHead.slice(0, 8),
2585
+ });
2586
+ failedLane = failedLane ?? -1;
2587
+ failureReason = `update-ref of ${targetBranch} to ${tempBranchHead.slice(0, 8)} failed: ${err}`;
2588
+ } else {
2589
+ execLog("merge", `W${waveIndex}`, `updated ${targetBranch} ref to merge result`, {
2590
+ targetBranch,
2591
+ commit: tempBranchHead.slice(0, 8),
2592
+ });
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2597
+
2598
+ // Clean up merge worktree and temp branch.
2599
+ // TP-033: When rollback failed (safe-stop), preserve merge worktree and temp
2600
+ // branch for manual recovery. The operator can use the recovery commands in
2601
+ // the transaction record to restore consistency.
2602
+ if (rollbackFailed) {
2603
+ execLog(
2604
+ "merge",
2605
+ `W${waveIndex}`,
2606
+ "SAFE-STOP: preserving merge worktree and temp branch for recovery",
2607
+ {
2608
+ mergeWorkDir,
2609
+ tempBranch,
2610
+ },
2611
+ );
2612
+ } else {
2613
+ // TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted
2614
+ // merge worktrees don't persist between attempts.
2615
+ forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
2616
+ try {
2617
+ // Small delay to ensure worktree lock is released
2618
+ await sleepAsync(500);
2619
+ spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
2620
+ } catch {
2621
+ /* best effort */
2622
+ }
2623
+ }
2624
+
2625
+ // Determine overall status
2626
+ let status: MergeWaveResult["status"];
2627
+ if (failedLane === null) {
2628
+ status = "succeeded";
2629
+ } else if (anySuccess) {
2630
+ status = "partial";
2631
+ } else {
2632
+ status = "failed";
2633
+ }
2634
+
2635
+ const totalDurationMs = Date.now() - startTime;
2636
+
2637
+ execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
2638
+ mergedLanes: laneResults.filter(
2639
+ (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
2640
+ ).length,
2641
+ failedLane: failedLane ?? 0,
2642
+ duration: `${Math.round(totalDurationMs / 1000)}s`,
2643
+ });
2644
+
2645
+ const result: MergeWaveResult = {
2646
+ waveIndex,
2647
+ status,
2648
+ laneResults,
2649
+ failedLane,
2650
+ failureReason,
2651
+ totalDurationMs,
2652
+ };
2653
+
2654
+ // TP-033: Attach transaction metadata
2655
+ if (transactionRecords.length > 0) {
2656
+ result.transactionRecords = transactionRecords;
2657
+ }
2658
+ if (rollbackFailed) {
2659
+ result.rollbackFailed = true;
2660
+ }
2661
+ // TP-033 R004-2: Surface persistence failures so operator knows
2662
+ // recovery guidance may reference missing transaction record files
2663
+ if (persistenceErrors.length > 0) {
2664
+ result.persistenceErrors = persistenceErrors;
2665
+ }
2666
+
2667
+ return result;
2668
+ }
2669
+
2670
+ // ── Repo-Scoped Merge ────────────────────────────────────────────────
2671
+
2672
+ /**
2673
+ * Group mergeable lanes by their `repoId`.
2674
+ *
2675
+ * Returns groups sorted deterministically by repoId (undefined/repo-mode
2676
+ * group sorts first as empty string). Lanes within each group preserve
2677
+ * the input order.
2678
+ *
2679
+ * @param lanes - Lanes to group (already filtered for mergeability)
2680
+ * @returns Array of { repoId, lanes } groups in deterministic order
2681
+ */
2682
+ export function groupLanesByRepo(
2683
+ lanes: AllocatedLane[],
2684
+ ): Array<{ repoId: string | undefined; lanes: AllocatedLane[] }> {
2685
+ const groupMap = new Map<string, AllocatedLane[]>();
2686
+
2687
+ for (const lane of lanes) {
2688
+ const key = lane.repoId ?? "";
2689
+ const existing = groupMap.get(key) || [];
2690
+ existing.push(lane);
2691
+ groupMap.set(key, existing);
2692
+ }
2693
+
2694
+ const sortedKeys = [...groupMap.keys()].sort();
2695
+ return sortedKeys.map((key) => ({
2696
+ repoId: key || undefined,
2697
+ lanes: groupMap.get(key)!,
2698
+ }));
2699
+ }
2700
+
2701
+ /**
2702
+ * Merge a wave's lanes partitioned by repository.
2703
+ *
2704
+ * In repo mode (all lanes have repoId=undefined), this produces a single
2705
+ * repo group and delegates to `mergeWave()` exactly once — a no-op
2706
+ * regression case that preserves existing behavior.
2707
+ *
2708
+ * In workspace mode, lanes are grouped by `repoId`. Each repo group gets:
2709
+ * - Its own repo root (via `resolveRepoRoot()`)
2710
+ * - Its own base branch (via `resolveBaseBranch()`)
2711
+ * - An independent `mergeWave()` call with those repo-scoped parameters
2712
+ *
2713
+ * Repo groups are processed in deterministic order (sorted by repoId).
2714
+ * Per-repo results are aggregated into a single `MergeWaveResult` for
2715
+ * the existing wave-level failure policy handling in `engine.ts`.
2716
+ *
2717
+ * Failure semantics:
2718
+ * - A failure in one repo does NOT stop merging in other repos.
2719
+ * - The aggregate status is "succeeded" only if all repos succeeded.
2720
+ * - If any repo failed and any succeeded, status is "partial".
2721
+ * - `repoResults` field carries per-repo attribution for downstream
2722
+ * reporting (Step 1 will use this for explicit partial-success summaries).
2723
+ *
2724
+ * @param completedLanes - Lanes that completed execution (from wave result)
2725
+ * @param waveResult - The wave execution result (for lane status filtering)
2726
+ * @param waveIndex - Wave number (1-indexed)
2727
+ * @param config - Orchestrator configuration
2728
+ * @param repoRoot - Default repository root (used in repo mode)
2729
+ * @param batchId - Batch ID for session naming
2730
+ * @param baseBranch - Default branch to merge into (captured at batch start)
2731
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
2732
+ * @returns MergeWaveResult with per-lane and per-repo outcomes
2733
+ */
2734
+ export async function mergeWaveByRepo(
2735
+ completedLanes: AllocatedLane[],
2736
+ waveResult: WaveExecutionResult,
2737
+ waveIndex: number,
2738
+ config: OrchestratorConfig,
2739
+ repoRoot: string,
2740
+ batchId: string,
2741
+ baseBranch: string,
2742
+ workspaceConfig?: WorkspaceConfig | null,
2743
+ stateRoot?: string,
2744
+ agentRoot?: string,
2745
+ testingCommands?: Record<string, string>,
2746
+ healthMonitor?: MergeHealthMonitor | null,
2747
+ forceMixedOutcome?: boolean,
2748
+ runtimeBackend?: RuntimeBackend,
2749
+ ): Promise<MergeWaveResult> {
2750
+ const startTime = Date.now();
2751
+
2752
+ // Build lane outcome lookup for merge eligibility (same logic as mergeWave).
2753
+ const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
2754
+ for (const laneOutcome of waveResult.laneResults) {
2755
+ laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
2756
+ }
2757
+
2758
+ // Filter to mergeable lanes (same criteria as mergeWave).
2759
+ // TP-078: When forceMixedOutcome is true, lanes with mixed outcomes are also included.
2760
+ const mergeableLanes = completedLanes.filter((lane) => {
2761
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
2762
+ if (!outcome) return false;
2763
+ const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
2764
+ const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
2765
+ if (forceMixedOutcome) return hasSucceeded;
2766
+ return hasSucceeded && !hasHardFailure;
2767
+ });
2768
+
2769
+ if (mergeableLanes.length === 0) {
2770
+ // TP-171: Even when no lanes are mergeable, skipped-task lanes may have
2771
+ // partial progress that should be staged on the target branch.
2772
+ const skippedOnlyLanes = completedLanes.filter((lane) => {
2773
+ if (!lane.worktreePath) return false;
2774
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
2775
+ if (!outcome) return false;
2776
+ return outcome.tasks.some((t) => t.status === "skipped");
2777
+ });
2778
+ if (skippedOnlyLanes.length > 0) {
2779
+ // In workspace mode, group skipped lanes by repo and stage per-repo.
2780
+ const skippedByRepo = groupLanesByRepo(skippedOnlyLanes);
2781
+ for (const group of skippedByRepo) {
2782
+ const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
2783
+ stageSkippedArtifactsToTargetBranch(group.lanes, waveIndex, groupRepoRoot, baseBranch);
2784
+ }
2785
+ }
2786
+
2787
+ execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
2788
+ return {
2789
+ waveIndex,
2790
+ status: "succeeded",
2791
+ laneResults: [],
2792
+ failedLane: null,
2793
+ failureReason: null,
2794
+ totalDurationMs: Date.now() - startTime,
2795
+ repoResults: [],
2796
+ };
2797
+ }
2798
+
2799
+ // Group lanes by repo
2800
+ const repoGroups = groupLanesByRepo(mergeableLanes);
2801
+
2802
+ execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, {
2803
+ repos: repoGroups.map((g) => g.repoId ?? "(default)").join(", "),
2804
+ totalLanes: mergeableLanes.length,
2805
+ });
2806
+
2807
+ // In repo mode (single group with repoId=undefined), delegate directly
2808
+ // to mergeWave() for zero-overhead backward compatibility.
2809
+ if (repoGroups.length === 1 && repoGroups[0].repoId === undefined) {
2810
+ const result = await mergeWave(
2811
+ completedLanes,
2812
+ waveResult,
2813
+ waveIndex,
2814
+ config,
2815
+ repoRoot,
2816
+ batchId,
2817
+ baseBranch,
2818
+ stateRoot,
2819
+ agentRoot,
2820
+ testingCommands,
2821
+ undefined, // repoId
2822
+ healthMonitor,
2823
+ forceMixedOutcome,
2824
+ runtimeBackend,
2825
+ );
2826
+ // Attach empty repoResults for consistent shape
2827
+ return { ...result, repoResults: [] };
2828
+ }
2829
+
2830
+ // ── Workspace mode: per-repo merge loops ─────────────────────
2831
+ const allLaneResults: MergeLaneResult[] = [];
2832
+ const repoOutcomes: RepoMergeOutcome[] = [];
2833
+ const allTransactionRecords: TransactionRecord[] = [];
2834
+ // TP-033 R004-2: Accumulate persistence errors across all repo groups
2835
+ const allPersistenceErrors: string[] = [];
2836
+ let firstFailedLane: number | null = null;
2837
+ let firstFailureReason: string | null = null;
2838
+ // Track repo-level failures independently of lane-level failures.
2839
+ // mergeWave() can return status="failed" with failedLane=null for
2840
+ // pre-lane setup errors (temp branch creation, worktree creation).
2841
+ // We must detect these to avoid misclassifying the aggregate as "succeeded".
2842
+ let anyRepoFailed = false;
2843
+ // TP-033: Track rollback failures across all repo groups
2844
+ let anyRollbackFailed = false;
2845
+
2846
+ for (const group of repoGroups) {
2847
+ const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
2848
+ // In workspace mode with orch branch, always merge into the orch branch
2849
+ // (passed as baseBranch from engine.ts). Do NOT use resolveBaseBranch()
2850
+ // which returns the repo's current branch (e.g., develop), bypassing
2851
+ // the orch branch model entirely.
2852
+ const groupBaseBranch = baseBranch;
2853
+
2854
+ execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, {
2855
+ repoRoot: groupRepoRoot,
2856
+ baseBranch: groupBaseBranch,
2857
+ laneCount: group.lanes.length,
2858
+ lanes: group.lanes.map((l) => l.laneNumber).join(","),
2859
+ });
2860
+
2861
+ // TP-171: Build allGroupLanes from all completed lanes for this repo
2862
+ // (not just mergeable) so mergeWave() can compute skippedArtifactLanes.
2863
+ const groupRepoId = group.repoId;
2864
+ const allGroupLanes = completedLanes.filter((l) => (l.repoId ?? undefined) === groupRepoId);
2865
+ const allGroupLaneNumbers = new Set(allGroupLanes.map((l) => l.laneNumber));
2866
+
2867
+ // Build a filtered WaveExecutionResult containing all lanes for this repo
2868
+ // (including skipped-only lanes that aren't in the mergeable group).
2869
+ const filteredWaveResult: WaveExecutionResult = {
2870
+ ...waveResult,
2871
+ laneResults: waveResult.laneResults.filter((lr) => allGroupLaneNumbers.has(lr.laneNumber)),
2872
+ allocatedLanes: waveResult.allocatedLanes.filter((l) => allGroupLaneNumbers.has(l.laneNumber)),
2873
+ };
2874
+
2875
+ const groupResult = await mergeWave(
2876
+ allGroupLanes,
2877
+ filteredWaveResult,
2878
+ waveIndex,
2879
+ config,
2880
+ groupRepoRoot,
2881
+ batchId,
2882
+ groupBaseBranch,
2883
+ stateRoot,
2884
+ agentRoot,
2885
+ testingCommands,
2886
+ group.repoId,
2887
+ healthMonitor,
2888
+ forceMixedOutcome,
2889
+ runtimeBackend,
2890
+ );
2891
+
2892
+ // Accumulate lane results
2893
+ allLaneResults.push(...groupResult.laneResults);
2894
+
2895
+ // TP-033: Accumulate transaction records and rollback status
2896
+ if (groupResult.transactionRecords) {
2897
+ allTransactionRecords.push(...groupResult.transactionRecords);
2898
+ }
2899
+ // TP-033 R004-2: Accumulate persistence errors
2900
+ if (groupResult.persistenceErrors) {
2901
+ allPersistenceErrors.push(...groupResult.persistenceErrors);
2902
+ }
2903
+ if (groupResult.rollbackFailed) {
2904
+ anyRollbackFailed = true;
2905
+ }
2906
+
2907
+ // Build per-repo outcome
2908
+ const repoOutcome: RepoMergeOutcome = {
2909
+ repoId: group.repoId,
2910
+ status: groupResult.status,
2911
+ laneResults: groupResult.laneResults,
2912
+ failedLane: groupResult.failedLane,
2913
+ failureReason: groupResult.failureReason,
2914
+ };
2915
+ repoOutcomes.push(repoOutcome);
2916
+
2917
+ // Track failures across repos (but continue to merge other repos).
2918
+ // Check groupResult.status (not just failedLane) to catch setup failures
2919
+ // where mergeWave() returns status="failed" with failedLane=null
2920
+ // (e.g., temp branch creation or worktree creation failure).
2921
+ if (groupResult.status !== "succeeded") {
2922
+ anyRepoFailed = true;
2923
+
2924
+ if (firstFailureReason === null) {
2925
+ firstFailedLane = groupResult.failedLane;
2926
+ firstFailureReason = groupResult.failureReason
2927
+ ? `[repo:${group.repoId ?? "default"}] ${groupResult.failureReason}`
2928
+ : `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
2929
+ }
2930
+ }
2931
+
2932
+ // TP-033 R004-1: Safe-stop — halt all remaining repo merges immediately
2933
+ // when a rollback failure is detected. Continuing would advance refs in
2934
+ // other repos, making manual recovery harder.
2935
+ if (anyRollbackFailed) {
2936
+ const processedIndex = repoGroups.indexOf(group);
2937
+ const remainingGroups = repoGroups.slice(processedIndex + 1);
2938
+ if (remainingGroups.length > 0) {
2939
+ execLog(
2940
+ "merge",
2941
+ `W${waveIndex}`,
2942
+ `safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`,
2943
+ {
2944
+ skippedRepos: remainingGroups.map((g) => g.repoId ?? "(default)").join(", "),
2945
+ },
2946
+ );
2947
+ }
2948
+ break;
2949
+ }
2950
+ }
2951
+
2952
+ // TP-171: Stage artifacts for repos that have only skipped lanes but were
2953
+ // not included in the mergeable repoGroups.
2954
+ const processedRepoIds = new Set(repoGroups.map((g) => g.repoId));
2955
+ const skippedOnlyRepoLanes = completedLanes.filter((lane) => {
2956
+ if (!lane.worktreePath) return false;
2957
+ const laneRepoId = lane.repoId ?? undefined;
2958
+ if (processedRepoIds.has(laneRepoId)) return false; // already handled by mergeWave
2959
+ const outcome = laneOutcomeByNumber.get(lane.laneNumber);
2960
+ if (!outcome) return false;
2961
+ return outcome.tasks.some((t) => t.status === "skipped");
2962
+ });
2963
+ // TP-171 R004: Gate artifact staging behind safe-stop — do not advance
2964
+ // any branch refs when a rollback failure has been detected.
2965
+ if (skippedOnlyRepoLanes.length > 0 && !anyRollbackFailed) {
2966
+ const skippedRepoGroups = groupLanesByRepo(skippedOnlyRepoLanes);
2967
+ for (const group of skippedRepoGroups) {
2968
+ const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
2969
+ stageSkippedArtifactsToTargetBranch(group.lanes, waveIndex, groupRepoRoot, baseBranch);
2970
+ }
2971
+ }
2972
+
2973
+ // ── Aggregate status ─────────────────────────────────────────
2974
+ // Use both lane-level and repo-level evidence for correct classification:
2975
+ // - anyLaneSucceeded: at least one lane merged successfully across all repos
2976
+ // - anyRepoFailed: at least one repo had a non-succeeded status (includes
2977
+ // both lane-level failures AND repo setup failures with failedLane=null)
2978
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success determination
2979
+ const anyLaneSucceeded = allLaneResults.some(
2980
+ (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
2981
+ );
2982
+
2983
+ let status: MergeWaveResult["status"];
2984
+ if (!anyRepoFailed) {
2985
+ status = "succeeded";
2986
+ } else if (anyLaneSucceeded) {
2987
+ status = "partial";
2988
+ } else {
2989
+ status = "failed";
2990
+ }
2991
+
2992
+ const totalDurationMs = Date.now() - startTime;
2993
+
2994
+ execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
2995
+ repoCount: repoOutcomes.length,
2996
+ repoStatuses: repoOutcomes.map((r) => `${r.repoId ?? "default"}:${r.status}`).join(", "),
2997
+ mergedLanes: allLaneResults.filter(
2998
+ (r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
2999
+ ).length,
3000
+ duration: `${Math.round(totalDurationMs / 1000)}s`,
3001
+ });
3002
+
3003
+ const aggregateResult: MergeWaveResult = {
3004
+ waveIndex,
3005
+ status,
3006
+ laneResults: allLaneResults,
3007
+ failedLane: firstFailedLane,
3008
+ failureReason: firstFailureReason,
3009
+ totalDurationMs,
3010
+ repoResults: repoOutcomes,
3011
+ };
3012
+
3013
+ // TP-033: Attach transaction metadata from all repo groups
3014
+ if (allTransactionRecords.length > 0) {
3015
+ aggregateResult.transactionRecords = allTransactionRecords;
3016
+ }
3017
+ if (anyRollbackFailed) {
3018
+ aggregateResult.rollbackFailed = true;
3019
+ }
3020
+ // TP-033 R004-2: Surface persistence errors from all repo groups
3021
+ if (allPersistenceErrors.length > 0) {
3022
+ aggregateResult.persistenceErrors = allPersistenceErrors;
3023
+ }
3024
+
3025
+ return aggregateResult;
3026
+ }
3027
+
3028
+ // ── Auto-Integration ─────────────────────────────────────────────────
3029
+
3030
+ /**
3031
+ * Attempt to fast-forward baseBranch to orchBranch in the main repo.
3032
+ *
3033
+ * Shared by engine.ts (fresh batch) and resume.ts (resumed batch).
3034
+ * The `logCategory` parameter distinguishes the calling context in execLog.
3035
+ *
3036
+ * Failure matrix — all failures are warnings, never batch-fatal:
3037
+ * - **Diverged**: baseBranch has commits not in orchBranch (not fast-forwardable)
3038
+ * - **Detached HEAD / missing base**: baseBranch not resolvable
3039
+ * - **Dirty worktree**: baseBranch is checked out with uncommitted changes
3040
+ * - **Branch not checked out**: baseBranch is not the current branch;
3041
+ * use update-ref (no worktree impact) with compare-and-swap
3042
+ *
3043
+ * @param orchBranch - The orch branch to integrate from
3044
+ * @param baseBranch - The user's branch to advance
3045
+ * @param repoRoot - Absolute path to the primary repo root
3046
+ * @param batchId - Batch identifier for logging
3047
+ * @param logCategory - execLog category ("batch" for engine, "resume" for resume)
3048
+ * @param onNotify - Notification callback
3049
+ * @returns true if integration succeeded, false otherwise
3050
+ */
3051
+ export function attemptAutoIntegration(
3052
+ orchBranch: string,
3053
+ baseBranch: string,
3054
+ repoRoot: string,
3055
+ batchId: string,
3056
+ logCategory: string,
3057
+ onNotify: (message: string, level: "info" | "warning" | "error") => void,
3058
+ ): boolean {
3059
+ // 1. Verify orchBranch exists
3060
+ const orchExists = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoRoot);
3061
+ if (!orchExists.ok) {
3062
+ const reason = `orch branch '${orchBranch}' not found`;
3063
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
3064
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3065
+ return false;
3066
+ }
3067
+
3068
+ // 2. Verify baseBranch exists
3069
+ const baseExists = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot);
3070
+ if (!baseExists.ok) {
3071
+ const reason = `base branch '${baseBranch}' not found`;
3072
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
3073
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3074
+ return false;
3075
+ }
3076
+
3077
+ // 3. Check fast-forwardability: baseBranch must be an ancestor of orchBranch
3078
+ const isAncestor = runGit(["merge-base", "--is-ancestor", baseBranch, orchBranch], repoRoot);
3079
+ if (!isAncestor.ok) {
3080
+ const reason = `branches have diverged (${baseBranch} is not an ancestor of ${orchBranch})`;
3081
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
3082
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3083
+ return false;
3084
+ }
3085
+
3086
+ // 4. Gate on whether baseBranch is checked out (same pattern as merge advancement)
3087
+ const checkedOutBranch = getCurrentBranch(repoRoot);
3088
+ const baseIsCheckedOut = checkedOutBranch === baseBranch;
3089
+
3090
+ const orchHead = runGit(["rev-parse", orchBranch], repoRoot).stdout.trim();
3091
+
3092
+ if (baseIsCheckedOut) {
3093
+ // baseBranch is checked out — use merge --ff-only (updates worktree)
3094
+ // Check for dirty worktree first
3095
+ const statusCheck = runGit(["status", "--porcelain"], repoRoot);
3096
+ if (statusCheck.ok && statusCheck.stdout.trim()) {
3097
+ const reason = `working tree is dirty (${baseBranch} is checked out with uncommitted changes)`;
3098
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
3099
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3100
+ return false;
3101
+ }
3102
+
3103
+ const ffResult = runGit(["merge", "--ff-only", orchBranch], repoRoot);
3104
+ if (!ffResult.ok) {
3105
+ const reason = `fast-forward failed: ${ffResult.stderr || ffResult.stdout || "unknown"}`;
3106
+ execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
3107
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3108
+ return false;
3109
+ }
3110
+ } else {
3111
+ // baseBranch is NOT checked out — use update-ref with compare-and-swap
3112
+ const baseOldRef = runGit(["rev-parse", baseBranch], repoRoot).stdout.trim();
3113
+ const updateResult = runGit(
3114
+ ["update-ref", `refs/heads/${baseBranch}`, orchHead, baseOldRef],
3115
+ repoRoot,
3116
+ );
3117
+ if (!updateResult.ok) {
3118
+ const reason = `update-ref failed: ${updateResult.stderr || updateResult.stdout || "unknown"}`;
3119
+ execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
3120
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
3121
+ return false;
3122
+ }
3123
+ }
3124
+
3125
+ execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, {
3126
+ orchHead,
3127
+ });
3128
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info");
3129
+ return true;
3130
+ }
3131
+
3132
+ // ── Merge Health Monitor (TP-056) ────────────────────────────────────
3133
+
3134
+ /**
3135
+ * Classify merge-session health from Runtime V2 liveness and result-file state.
3136
+ *
3137
+ * Without legacy pane capture, warning/stuck are time-based heuristics from
3138
+ * the session registration timestamp (`lastActivityAt`).
3139
+ *
3140
+ * @param sessionAlive - Whether the Runtime V2 merge agent is alive
3141
+ * @param hasResultFile - Whether the merge result file exists
3142
+ * @param healthState - Tracked health state for this session
3143
+ * @param now - Current epoch ms
3144
+ * @returns Updated health status
3145
+ *
3146
+ * @since TP-056
3147
+ */
3148
+ export function classifyMergeHealth(
3149
+ sessionAlive: boolean,
3150
+ hasResultFile: boolean,
3151
+ healthState: MergeSessionHealthState,
3152
+ now: number,
3153
+ ): MergeHealthStatus {
3154
+ if (!sessionAlive && !hasResultFile) {
3155
+ return "dead";
3156
+ }
3157
+
3158
+ if (!sessionAlive && hasResultFile) {
3159
+ return "healthy";
3160
+ }
3161
+
3162
+ const elapsedMs = now - healthState.lastActivityAt;
3163
+ if (elapsedMs >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
3164
+ return "stuck";
3165
+ }
3166
+ if (elapsedMs >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
3167
+ return "warning";
3168
+ }
3169
+ return "healthy";
3170
+ }
3171
+
3172
+ /**
3173
+ * Active merge session health monitor.
3174
+ *
3175
+ * Runs on its own polling interval during the merge phase, checking each
3176
+ * active merge session for liveness and activity. Emits structured events
3177
+ * for the supervisor to consume.
3178
+ *
3179
+ * Design principles (from PROMPT.md):
3180
+ * - Does NOT kill sessions autonomously — emits events for operator decision
3181
+ * - Runs independently of the merge result poll
3182
+ * - Stores session snapshots in memory (ephemeral, not persisted)
3183
+ * - Emits structured events to the unified events.jsonl
3184
+ *
3185
+ * @since TP-056
3186
+ */
3187
+ export class MergeHealthMonitor {
3188
+ /** Per-session health state, keyed by session name */
3189
+ private sessions: Map<string, MergeSessionHealthState> = new Map();
3190
+
3191
+ /** Timer handle for the polling loop */
3192
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
3193
+
3194
+ /** Whether the monitor is currently running */
3195
+ private _running = false;
3196
+
3197
+ /** Callback invoked when a dead session is detected (for early exit signaling) */
3198
+ private _onDeadSession: ((sessionName: string, laneNumber: number) => void) | null = null;
3199
+
3200
+ /** Event emission context */
3201
+ private stateRoot: string;
3202
+ private batchId: string;
3203
+ private waveIndex: number;
3204
+ private phase: OrchBatchPhase;
3205
+
3206
+ /** Polling interval override (for testing) */
3207
+ private pollIntervalMs: number;
3208
+
3209
+ constructor(opts: {
3210
+ stateRoot: string;
3211
+ batchId: string;
3212
+ waveIndex: number;
3213
+ phase: OrchBatchPhase;
3214
+ pollIntervalMs?: number;
3215
+ onDeadSession?: (sessionName: string, laneNumber: number) => void;
3216
+ }) {
3217
+ this.stateRoot = opts.stateRoot;
3218
+ this.batchId = opts.batchId;
3219
+ this.waveIndex = opts.waveIndex;
3220
+ this.phase = opts.phase;
3221
+ this.pollIntervalMs = opts.pollIntervalMs ?? MERGE_HEALTH_POLL_INTERVAL_MS;
3222
+ this._onDeadSession = opts.onDeadSession ?? null;
3223
+ }
3224
+
3225
+ /** Whether the monitor is currently running */
3226
+ get running(): boolean {
3227
+ return this._running;
3228
+ }
3229
+
3230
+ /**
3231
+ * Register a merge session for monitoring.
3232
+ *
3233
+ * @param sessionName - Merge session name
3234
+ * @param laneNumber - Lane number the session belongs to
3235
+ * @param resultPath - Path to the expected merge result file
3236
+ */
3237
+ addSession(sessionName: string, laneNumber: number, resultPath: string): void {
3238
+ const now = Date.now();
3239
+ this.sessions.set(sessionName, {
3240
+ sessionName,
3241
+ laneNumber,
3242
+ lastSnapshot: null,
3243
+ lastActivityAt: now,
3244
+ status: "healthy",
3245
+ warningEmitted: false,
3246
+ stuckEmitted: false,
3247
+ deadEmitted: false,
3248
+ });
3249
+ // Store resultPath for later lookup
3250
+ this._resultPaths.set(sessionName, resultPath);
3251
+ }
3252
+
3253
+ /** Result file paths for each session (for dead-session detection) */
3254
+ private _resultPaths: Map<string, string> = new Map();
3255
+
3256
+ /**
3257
+ * Remove a session from monitoring (e.g., merge completed for this lane).
3258
+ */
3259
+ removeSession(sessionName: string): void {
3260
+ this.sessions.delete(sessionName);
3261
+ this._resultPaths.delete(sessionName);
3262
+ }
3263
+
3264
+ /** Overlap guard for async poll (TP-070) */
3265
+ private _polling = false;
3266
+
3267
+ /**
3268
+ * Start the health monitoring polling loop.
3269
+ */
3270
+ start(): void {
3271
+ if (this._running) return;
3272
+ this._running = true;
3273
+
3274
+ execLog("merge-health", "monitor", "merge health monitor started", {
3275
+ sessionCount: this.sessions.size,
3276
+ pollIntervalMs: this.pollIntervalMs,
3277
+ });
3278
+
3279
+ this.pollTimer = setInterval(async () => {
3280
+ if (this._polling) return; // Overlap guard (TP-070)
3281
+ this._polling = true;
3282
+ try {
3283
+ await this.poll();
3284
+ } finally {
3285
+ this._polling = false;
3286
+ }
3287
+ }, this.pollIntervalMs);
3288
+ }
3289
+
3290
+ /**
3291
+ * Stop the health monitoring polling loop.
3292
+ */
3293
+ stop(): void {
3294
+ if (!this._running) return;
3295
+ this._running = false;
3296
+
3297
+ if (this.pollTimer !== null) {
3298
+ clearInterval(this.pollTimer);
3299
+ this.pollTimer = null;
3300
+ }
3301
+
3302
+ execLog("merge-health", "monitor", "merge health monitor stopped", {
3303
+ sessionCount: this.sessions.size,
3304
+ });
3305
+
3306
+ this.sessions.clear();
3307
+ this._resultPaths.clear();
3308
+ }
3309
+
3310
+ /**
3311
+ * Run a single poll cycle across all monitored sessions.
3312
+ *
3313
+ * Exposed as public for testing — normally called by the interval timer.
3314
+ */
3315
+ async poll(): Promise<void> {
3316
+ const now = Date.now();
3317
+
3318
+ try {
3319
+ setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId));
3320
+ } catch {
3321
+ setV2LivenessRegistryCache(null);
3322
+ }
3323
+
3324
+ try {
3325
+ for (const [sessionName, state] of this.sessions) {
3326
+ const sessionAlive = isV2AgentAlive(sessionName, "v2");
3327
+ const resultPath = this._resultPaths.get(sessionName) ?? "";
3328
+ const hasResultFile = resultPath ? existsSync(resultPath) : false;
3329
+
3330
+ const newStatus = classifyMergeHealth(sessionAlive, hasResultFile, state, now);
3331
+
3332
+ state.status = newStatus;
3333
+
3334
+ // Emit events based on status transitions
3335
+ this._emitHealthEvents(state, now);
3336
+
3337
+ // Signal dead session for early exit
3338
+ if (newStatus === "dead" && !state.deadEmitted) {
3339
+ state.deadEmitted = true;
3340
+ if (this._onDeadSession) {
3341
+ this._onDeadSession(sessionName, state.laneNumber);
3342
+ }
3343
+ }
3344
+ }
3345
+ } finally {
3346
+ setV2LivenessRegistryCache(null);
3347
+ }
3348
+ }
3349
+
3350
+ /**
3351
+ * Emit health events based on current state.
3352
+ * De-duplicates: each event type emitted at most once per session.
3353
+ */
3354
+ private _emitHealthEvents(state: MergeSessionHealthState, now: number): void {
3355
+ const stalledMinutes = Math.round((now - state.lastActivityAt) / 60_000);
3356
+
3357
+ if (state.status === "warning" && !state.warningEmitted) {
3358
+ state.warningEmitted = true;
3359
+ const event: EngineEvent = {
3360
+ ...buildEngineEventBase("merge_health_warning", this.batchId, this.waveIndex, this.phase),
3361
+ laneNumber: state.laneNumber,
3362
+ sessionName: state.sessionName,
3363
+ healthStatus: "warning",
3364
+ stalledMinutes,
3365
+ reason: `Merge agent on lane ${state.laneNumber} may be stalled (${stalledMinutes} min without completion)`,
3366
+ };
3367
+ emitEngineEvent(this.stateRoot, event);
3368
+ execLog("merge-health", state.sessionName, `⚠️ merge session possibly stalled`, {
3369
+ stalledMinutes,
3370
+ laneNumber: state.laneNumber,
3371
+ });
3372
+ }
3373
+
3374
+ if (state.status === "dead" && !state.deadEmitted) {
3375
+ // deadEmitted is set in poll() after onDeadSession callback
3376
+ const event: EngineEvent = {
3377
+ ...buildEngineEventBase("merge_health_dead", this.batchId, this.waveIndex, this.phase),
3378
+ laneNumber: state.laneNumber,
3379
+ sessionName: state.sessionName,
3380
+ healthStatus: "dead",
3381
+ reason: `Merge agent on lane ${state.laneNumber} session died without producing a result`,
3382
+ };
3383
+ emitEngineEvent(this.stateRoot, event);
3384
+ execLog("merge-health", state.sessionName, `💀 merge session dead — no result file`, {
3385
+ laneNumber: state.laneNumber,
3386
+ });
3387
+ }
3388
+
3389
+ if (state.status === "stuck" && !state.stuckEmitted) {
3390
+ state.stuckEmitted = true;
3391
+ const event: EngineEvent = {
3392
+ ...buildEngineEventBase("merge_health_stuck", this.batchId, this.waveIndex, this.phase),
3393
+ laneNumber: state.laneNumber,
3394
+ sessionName: state.sessionName,
3395
+ healthStatus: "stuck",
3396
+ stalledMinutes,
3397
+ reason: `Merge agent on lane ${state.laneNumber} appears stuck (${stalledMinutes} min without completion). Consider killing and retrying.`,
3398
+ };
3399
+ emitEngineEvent(this.stateRoot, event);
3400
+ execLog("merge-health", state.sessionName, `🔒 merge session stuck`, {
3401
+ stalledMinutes,
3402
+ laneNumber: state.laneNumber,
3403
+ });
3404
+ }
3405
+ }
3406
+
3407
+ /**
3408
+ * Get the current health states for all monitored sessions.
3409
+ * Used for testing and inspection.
3410
+ */
3411
+ getSessionStates(): Map<string, MergeSessionHealthState> {
3412
+ return new Map(this.sessions);
3413
+ }
3414
+ }