taskplane 0.28.4 → 0.28.6

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,985 +1,985 @@
1
- /**
2
- * User-facing message templates (ORCH_MESSAGES)
3
- * @module orch/messages
4
- */
5
- import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
6
- import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts";
7
-
8
- // ── Message Templates ────────────────────────────────────────────────
9
-
10
- /**
11
- * Deterministic message templates for user-facing /orch commands.
12
- * Ensures consistent UX across invocations.
13
- */
14
- export const ORCH_MESSAGES = {
15
- // /orch
16
- orchStarting: (batchId: string, waves: number, tasks: number) =>
17
- `🚀 Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`,
18
- orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) =>
19
- `\n🌊 Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`,
20
- orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
21
- `✅ Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
22
- orchMergeStart: (waveNum: number, laneCount: number) =>
23
- `🔀 [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`,
24
- orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
25
- ` ✅ Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
26
- orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
27
- ` ⚡ Lane ${laneNum} merged with ${conflictCount} auto-resolved conflict(s) (${durationSec}s)`,
28
- orchMergeLaneFailed: (laneNum: number, reason: string) =>
29
- ` ❌ Lane ${laneNum} merge failed: ${reason}`,
30
- orchMergeComplete: (waveNum: number, mergedCount: number, totalSec: number) =>
31
- `🔀 [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`,
32
- orchMergeFailed: (waveNum: number, laneNum: number, reason: string) =>
33
- `❌ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`,
34
- orchMergeSkipped: (waveNum: number) =>
35
- `📝 [Wave ${waveNum}] No successful lanes to merge`,
36
- orchMergePlaceholder: (waveNum: number) =>
37
- `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`,
38
- orchWorktreeReset: (waveNum: number, lanes: number) =>
39
- `🔄 Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
40
- orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number, orchBranch?: string, baseBranch?: string) => {
41
- const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
42
- if (failed > 0 || blocked > 0) {
43
- lines.push("");
44
- if (blocked > 0) {
45
- lines.push(` ${blocked} task(s) were blocked because upstream tasks failed.`);
46
- }
47
- lines.push(" Next steps:");
48
- lines.push(" • /orch-status — review what failed and why");
49
- lines.push(" • /orch-resume — retry from the failed wave");
50
- lines.push(" • /orch-abort — clean up and start fresh");
51
- }
52
- if (orchBranch && succeeded > 0) {
53
- lines.push("");
54
- lines.push(" ┌─────────────────────────────────────────────────┐");
55
- lines.push(` │ Your changes are on branch: ${orchBranch}`);
56
- lines.push(` │ Your ${baseBranch || "working"} branch was not modified.`);
57
- if (baseBranch) {
58
- lines.push(` │ Preview: git log ${baseBranch}..${orchBranch}`);
59
- }
60
- lines.push(" │");
61
- lines.push(" │ 👉 To bring changes into your working branch:");
62
- lines.push(" │");
63
- lines.push(" │ /orch-integrate — merge directly (recommended)");
64
- lines.push(" │ /orch-integrate --pr — create a pull request");
65
- lines.push(" └─────────────────────────────────────────────────┘");
66
- }
67
- return lines.join("\n");
68
- },
69
- orchBatchFailed: (batchId: string, reason: string) =>
70
- `\n❌ Batch ${batchId} failed: ${reason}`,
71
- orchBatchStopped: (batchId: string, policy: string) =>
72
- `\n⛔ Batch ${batchId} stopped by ${policy} policy`,
73
-
74
- // /orch-pause
75
- pauseNoBatch: () => "No active batch is running. Use /orch <areas|all> to start.",
76
- pauseAlreadyPaused: (batchId: string) => `Batch ${batchId} is already paused.`,
77
- pauseActivated: (batchId: string) =>
78
- `⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
79
-
80
- // /orch-sessions
81
- sessionsNone: () => "No active orchestrator sessions found.",
82
- sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
83
-
84
- // /orch orphan detection
85
- orphanDetectionResume: (batchId: string, sessionCount: number) =>
86
- `🔄 Found ${sessionCount} running orchestrator session(s) from batch ${batchId}.\n` +
87
- ` Use /orch-resume to continue, or /orch-abort to clean up.`,
88
- orphanDetectionAbort: (sessionCount: number) =>
89
- `⚠️ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` +
90
- ` Use /orch-abort to clean up before starting a new batch.`,
91
- orphanDetectionCleanup: () =>
92
- `🧹 Cleaned up stale batch state file. Starting fresh.`,
93
-
94
- // /orch-resume
95
- resumeStarting: (batchId: string, phase: string) =>
96
- `🔄 Resuming batch ${batchId} (was: ${phase})...`,
97
- resumeReconciled: (batchId: string, completed: number, pending: number, failed: number, reconnecting: number, reExecuting: number = 0) =>
98
- `📊 Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` +
99
- (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""),
100
- resumeSkippedWaves: (skippedCount: number) =>
101
- `⏭️ Skipping ${skippedCount} completed wave(s)`,
102
- resumeReconnecting: (sessionCount: number) =>
103
- `🔗 Reconnecting to ${sessionCount} alive session(s)...`,
104
- resumeNoState: () =>
105
- `❌ No batch to resume. No batch-state.json file found.\n` +
106
- ` Use /orch <areas|all> to start a new batch.`,
107
- resumeInvalidState: (error: string) =>
108
- `❌ Cannot resume: batch state file is invalid.\n` +
109
- ` Error: ${error}\n` +
110
- ` Delete .pi/batch-state.json and start a new batch.`,
111
- resumePhaseNotResumable: (batchId: string, phase: string, reason: string) =>
112
- `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` +
113
- ` ${reason}`,
114
- resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
115
- `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
116
-
117
- // /orch-resume --force
118
- forceResumeStarting: (batchId: string, phase: string) =>
119
- `⚠️ Force-resuming batch ${batchId} from ${phase} state. Running pre-resume diagnostics...`,
120
- forceResumeDiagnosticsFailed: (batchId: string) =>
121
- `❌ Cannot force-resume batch ${batchId}: pre-resume diagnostics failed.\n` +
122
- ` Fix the issues above, then retry /orch-resume --force.`,
123
-
124
- // /orch-abort
125
- abortGracefulStarting: (batchId: string, sessionCount: number) =>
126
- `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
127
- abortGracefulWaiting: (batchId: string, graceSec: number) =>
128
- `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`,
129
- abortGracefulForceKill: (count: number) =>
130
- `⚠️ Force-killing ${count} session(s) that did not exit within timeout`,
131
- abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) =>
132
- `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`,
133
- abortHardStarting: (batchId: string, sessionCount: number) =>
134
- `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`,
135
- abortHardComplete: (batchId: string, killed: number, durationSec: number) =>
136
- `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`,
137
- abortPartialFailure: (failureCount: number) =>
138
- `⚠️ ${failureCount} error(s) during abort (see details above)`,
139
- abortNoBatch: () =>
140
- `No active batch to abort. Use /orch <areas|all> to start a batch.`,
141
- abortComplete: (mode: AbortMode, sessionsKilled: number) =>
142
- `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
143
- // /orch merge — repo-scoped partial summary (TP-005 Step 1)
144
- orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
145
- `⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`,
146
-
147
- // /orch integration — post-batch integration guidance (TP-022 Step 4)
148
- orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
149
- `✅ Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
150
- orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
151
- `⚠️ Auto-integration skipped: ${reason}\n` +
152
- ` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
153
- ` git log ${baseBranch}..${orchBranch}\n` +
154
- ` git merge ${orchBranch}`,
155
- orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
156
- const lines = [
157
- `ℹ️ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
158
- ` Review and integrate:`,
159
- ` git log ${baseBranch}..${orchBranch}`,
160
- ` git merge ${orchBranch}`,
161
- ];
162
- return lines.join("\n");
163
- },
164
- } as const;
165
-
166
-
167
- // ── Repo-Scoped Merge Summary (TP-005) ──────────────────────────────
168
-
169
- /**
170
- * Status emoji for repo merge outcome.
171
- */
172
- function repoStatusIcon(status: RepoMergeOutcome["status"]): string {
173
- switch (status) {
174
- case "succeeded": return "✅";
175
- case "partial": return "⚠️";
176
- case "failed": return "❌";
177
- default: return "❓";
178
- }
179
- }
180
-
181
- /**
182
- * Format a repo-divergence summary for a partial merge wave result.
183
- *
184
- * Returns null if:
185
- * - repoResults is empty or undefined (mono-repo mode)
186
- * - all repos have the same status (no divergence)
187
- * - there is only one repo group (divergence is meaningless)
188
- *
189
- * When the partial result is caused by mixed-outcome lanes within
190
- * a single repo (not repo divergence), this returns null to avoid
191
- * misleading "cross-repo divergence" messaging.
192
- *
193
- * The returned string is a complete, ready-to-emit message.
194
- *
195
- * @param mergeResult - The MergeWaveResult with status "partial"
196
- * @returns Formatted summary string, or null if no repo-divergence summary applies
197
- */
198
- export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | null {
199
- const repoResults = mergeResult.repoResults;
200
-
201
- // No repo attribution → mono-repo mode, no summary
202
- if (!repoResults || repoResults.length === 0) {
203
- return null;
204
- }
205
-
206
- // Single repo group → divergence is meaningless (partial is lane-level)
207
- if (repoResults.length < 2) {
208
- return null;
209
- }
210
-
211
- // Check for actual divergence: are there different statuses across repos?
212
- const statuses = new Set(repoResults.map(r => r.status));
213
- if (statuses.size < 2) {
214
- // All repos have the same status (e.g., all "partial") —
215
- // the partial is from within-repo lane failures, not cross-repo divergence
216
- return null;
217
- }
218
-
219
- // Build per-repo summary lines (sorted by repoId, which repoResults already is)
220
- const repoLines = repoResults.map(r => {
221
- const repoLabel = r.repoId ?? "(default)";
222
- const icon = repoStatusIcon(r.status);
223
- // TP-032 R006-3: Exclude verification_new_failure lanes from success count
224
- const mergedCount = r.laneResults.filter(
225
- lr => !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"),
226
- ).length;
227
- const totalCount = r.laneResults.length;
228
- let detail = `${mergedCount}/${totalCount} lane(s) merged`;
229
- if (r.failureReason) {
230
- detail += ` — ${r.failureReason.slice(0, 150)}`;
231
- }
232
- return ` ${icon} ${repoLabel}: ${detail}`;
233
- });
234
-
235
- return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines);
236
- }
237
-
238
-
239
- // ── Merge Failure Policy Application (TP-005 Step 2) ─────────────────
240
-
241
- /**
242
- * Result of applying the merge failure policy.
243
- *
244
- * Pure function output — callers use this to perform state mutations
245
- * and notifications consistently. Ensures engine.ts and resume.ts
246
- * apply identical pause/abort transitions.
247
- */
248
- export interface MergeFailurePolicyResult {
249
- /** The applied policy: "pause" or "abort". */
250
- policy: "pause" | "abort";
251
- /** Target phase for batchState.phase. */
252
- targetPhase: "paused" | "stopped";
253
- /** Error message to push to batchState.errors. */
254
- errorMessage: string;
255
- /** Persistence trigger label. */
256
- persistTrigger: "merge-failure-pause" | "merge-failure-abort";
257
- /** User-facing notification message. */
258
- notifyMessage: string;
259
- /** Notification level for onNotify. */
260
- notifyLevel: "error";
261
- /** Comma-separated failed lane identifiers for logging. */
262
- failedLaneIds: string;
263
- /** Structured log details for execLog. */
264
- logDetails: {
265
- failedLane: number;
266
- failedLaneIds: string;
267
- reason: string;
268
- };
269
- }
270
-
271
- /**
272
- * Compute the merge failure policy application result.
273
- *
274
- * This is a **pure function** — it computes all outputs deterministically
275
- * from the merge result and config, without performing any side effects.
276
- *
277
- * Both engine.ts and resume.ts MUST use this function to guarantee
278
- * identical failure attribution, phase transitions, error messages,
279
- * and notifications on repo-scoped merge failures.
280
- *
281
- * Failure attribution rules (priority chain):
282
- * 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
283
- * → formatted as `lane-<N>` (comma-separated).
284
- * 2. Fallback: if no lane-level failures but `mergeResult.failedLane`
285
- * is non-null, uses `lane-<N>` as the identifier.
286
- * 3. Repo-level: if no lane-level failures and failedLane is null
287
- * (repo setup failure), uses `repo:<repoId>` from repoResults
288
- * entries with non-succeeded status. Sorted deterministically.
289
- * - The failure reason is truncated to 200 chars for notifications and
290
- * logged in full in batchState.errors.
291
- *
292
- * @param mergeResult - The merge wave result with status "failed" or "partial"
293
- * @param waveIndex - 0-based wave index (displayed as 1-indexed)
294
- * @param config - Orchestrator configuration (for on_merge_failure policy)
295
- * @returns Policy result object for callers to apply
296
- */
297
- export function computeMergeFailurePolicy(
298
- mergeResult: MergeWaveResult,
299
- waveIndex: number,
300
- config: OrchestratorConfig,
301
- ): MergeFailurePolicyResult {
302
- const waveNum = waveIndex + 1;
303
- const mergeFailurePolicy = config.failure.on_merge_failure;
304
-
305
- // Build failed lane identifiers from lane results.
306
- // Priority chain:
307
- // 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
308
- // 2. Fallback: failedLane from mergeResult (single lane ID)
309
- // 3. Repo-level: repos with non-succeeded status from repoResults
310
- // (catches setup failures where failedLane=null and no lane results)
311
- let failedLaneIds = mergeResult.laneResults
312
- .filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
313
- .map(r => `lane-${r.laneNumber}`)
314
- .join(", ");
315
- if (!failedLaneIds && mergeResult.failedLane !== null) {
316
- failedLaneIds = `lane-${mergeResult.failedLane}`;
317
- }
318
- if (!failedLaneIds && mergeResult.repoResults && mergeResult.repoResults.length > 0) {
319
- // Repo-level fallback for setup failures (no lane results, failedLane=null).
320
- // Uses sorted repoResults order for determinism.
321
- failedLaneIds = mergeResult.repoResults
322
- .filter(r => r.status !== "succeeded")
323
- .map(r => `repo:${r.repoId ?? "default"}`)
324
- .join(", ");
325
- }
326
-
327
- const reason = mergeResult.failureReason || "unknown";
328
- const reasonTruncated = reason.slice(0, 200);
329
-
330
- const logDetails = {
331
- failedLane: mergeResult.failedLane ?? 0,
332
- failedLaneIds,
333
- reason: reasonTruncated,
334
- };
335
-
336
- const errorMessage =
337
- `Merge failed at wave ${waveNum}: ${reason}. ` +
338
- (mergeFailurePolicy === "pause"
339
- ? `Batch paused. Resolve conflicts and use /orch-resume to continue.`
340
- : `Batch aborted by on_merge_failure policy.`);
341
-
342
- const laneDetail = failedLaneIds ? ` (${failedLaneIds})` : "";
343
-
344
- let notifyMessage: string;
345
- if (mergeFailurePolicy === "pause") {
346
- notifyMessage =
347
- `⏸️ Batch paused due to merge failure at wave ${waveNum}${laneDetail}. ` +
348
- `Reason: ${reasonTruncated}. ` +
349
- `Resolve conflicts and resume.`;
350
- } else {
351
- notifyMessage =
352
- `⛔ Batch aborted due to merge failure at wave ${waveNum}${laneDetail}. ` +
353
- `Reason: ${reasonTruncated}.`;
354
- }
355
-
356
- return {
357
- policy: mergeFailurePolicy,
358
- targetPhase: mergeFailurePolicy === "pause" ? "paused" : "stopped",
359
- errorMessage,
360
- persistTrigger: mergeFailurePolicy === "pause" ? "merge-failure-pause" : "merge-failure-abort",
361
- notifyMessage,
362
- notifyLevel: "error",
363
- failedLaneIds,
364
- logDetails,
365
- };
366
- }
367
-
368
-
369
- // ── Cleanup Gate Policy (TP-029 Step 2) ──────────────────────────────
370
-
371
- /**
372
- * Per-repo cleanup failure detail.
373
- * Collected during post-merge inter-wave verification.
374
- */
375
- export interface CleanupGateRepoFailure {
376
- /** Repo root path that has stale worktrees */
377
- repoRoot: string;
378
- /** Repo ID (undefined for primary/repo-mode) */
379
- repoId: string | undefined;
380
- /** Paths of stale worktrees still registered after cleanup */
381
- staleWorktrees: string[];
382
- }
383
-
384
- /**
385
- * Result of applying the cleanup gate policy.
386
- *
387
- * Pure function output — callers use this to perform state mutations
388
- * and notifications consistently. Ensures engine.ts and resume.ts
389
- * apply identical pause transitions on cleanup failure.
390
- */
391
- export interface CleanupGatePolicyResult {
392
- /** Always "pause" — cleanup failures block next wave but preserve merged work */
393
- policy: "pause";
394
- /** Target phase for batchState.phase */
395
- targetPhase: "paused";
396
- /** Error message to push to batchState.errors */
397
- errorMessage: string;
398
- /** Persistence trigger label — matches spec classification naming */
399
- persistTrigger: "cleanup_post_merge_failed";
400
- /** User-facing notification message */
401
- notifyMessage: string;
402
- /** Notification level for onNotify */
403
- notifyLevel: "error";
404
- /** Structured log details for execLog */
405
- logDetails: {
406
- waveNumber: number;
407
- failedRepoCount: number;
408
- totalStaleWorktrees: number;
409
- repos: Array<{ repoId: string; staleCount: number }>;
410
- };
411
- }
412
-
413
- /**
414
- * Compute the cleanup gate policy result for post-merge verification failure.
415
- *
416
- * This is a **pure function** — it computes all outputs deterministically
417
- * from the wave index and per-repo failure details, without performing any
418
- * side effects.
419
- *
420
- * Both engine.ts and resume.ts MUST use this function to guarantee
421
- * identical failure attribution, phase transitions, error messages,
422
- * and notifications when post-merge cleanup leaves stale worktrees.
423
- *
424
- * The cleanup gate always pauses (never aborts) because:
425
- * - Merged commits are already on the orch branch and must not be lost
426
- * - The operator can manually remove stale worktrees and `/orch-resume`
427
- *
428
- * @param waveIndex - 0-based wave index (displayed as 1-indexed)
429
- * @param failures - Per-repo cleanup failure details
430
- * @returns Policy result object for callers to apply
431
- */
432
- export function computeCleanupGatePolicy(
433
- waveIndex: number,
434
- failures: CleanupGateRepoFailure[],
435
- ): CleanupGatePolicyResult {
436
- const waveNum = waveIndex + 1;
437
- const failedRepoCount = failures.length;
438
- const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0);
439
-
440
- const repos = failures.map(f => ({
441
- repoId: f.repoId ?? "(default)",
442
- staleCount: f.staleWorktrees.length,
443
- }));
444
-
445
- const repoDetail = repos.map(r => `${r.repoId} (${r.staleCount} stale)`).join(", ");
446
-
447
- const errorMessage =
448
- `Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` +
449
- `in ${failedRepoCount} repo(s) [${repoDetail}]. ` +
450
- `Batch paused. Remove stale worktrees manually and use /orch-resume to continue.`;
451
-
452
- // Build recovery commands for each failed repo
453
- const recoveryLines: string[] = [];
454
- for (const f of failures) {
455
- const label = f.repoId ?? "default";
456
- for (const wt of f.staleWorktrees) {
457
- recoveryLines.push(` git worktree remove --force "${wt}" # repo: ${label}`);
458
- }
459
- }
460
-
461
- const notifyMessage =
462
- `⏸️ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` +
463
- ` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` +
464
- ` Manual recovery:\n` +
465
- recoveryLines.join("\n") + "\n" +
466
- ` Then: /orch-resume`;
467
-
468
- return {
469
- policy: "pause",
470
- targetPhase: "paused",
471
- errorMessage,
472
- persistTrigger: "cleanup_post_merge_failed",
473
- notifyMessage,
474
- notifyLevel: "error",
475
- logDetails: {
476
- waveNumber: waveNum,
477
- failedRepoCount,
478
- totalStaleWorktrees,
479
- repos,
480
- },
481
- };
482
- }
483
-
484
- // ── Merge Retry Policy (TP-033 Step 2) ───────────────────────────────
485
-
486
- /**
487
- * Classify a merge failure into a MergeFailureClassification.
488
- *
489
- * Inspects the MergeWaveResult — lane errors, failure reasons, and merge
490
- * result statuses — to determine which retry policy class applies.
491
- *
492
- * Classification priority (first match wins):
493
- * 1. `verification_new_failure` — any lane error starts with "verification_new_failure"
494
- * 2. `merge_conflict_unresolved` — any lane result has CONFLICT_UNRESOLVED status
495
- * 3. `cleanup_post_merge_failed` — failure reason contains "cleanup" or "stale worktree"
496
- * 4. `git_lock_file` — failure reason contains "lock" or ".lock"
497
- * 5. `git_worktree_dirty` — failure reason contains "dirty" or "worktree"
498
- * 6. `null` — unclassifiable (treated as non-retriable by callers)
499
- *
500
- * This is a **pure function** — no side effects.
501
- *
502
- * @param mergeResult - The failed MergeWaveResult to classify
503
- * @returns Classification or null if no merge-retry class matches
504
- * @since TP-033
505
- */
506
- export function classifyMergeFailure(mergeResult: MergeWaveResult): MergeFailureClassification | null {
507
- // Check lane-level errors first (most specific)
508
- for (const lr of mergeResult.laneResults) {
509
- if (lr.error && lr.error.startsWith("verification_new_failure")) {
510
- return "verification_new_failure";
511
- }
512
- }
513
-
514
- // Check lane result statuses
515
- for (const lr of mergeResult.laneResults) {
516
- if (lr.result?.status === "CONFLICT_UNRESOLVED") {
517
- return "merge_conflict_unresolved";
518
- }
519
- }
520
-
521
- // Check failure reason string patterns
522
- const reason = (mergeResult.failureReason || "").toLowerCase();
523
-
524
- // Lock file detection: git operations fail with "Unable to create '.../.git/index.lock': File exists"
525
- if (reason.includes("lock") || reason.includes(".lock")) {
526
- return "git_lock_file";
527
- }
528
-
529
- // Cleanup failures: stale worktrees or cleanup errors
530
- if (reason.includes("cleanup") || reason.includes("stale worktree")) {
531
- return "cleanup_post_merge_failed";
532
- }
533
-
534
- // Dirty worktree: git operations fail due to uncommitted changes
535
- if (reason.includes("dirty") || reason.includes("worktree")) {
536
- return "git_worktree_dirty";
537
- }
538
-
539
- return null;
540
- }
541
-
542
- /**
543
- * Compute the retry decision for a merge failure.
544
- *
545
- * Given the failure classification and the current retry count for the
546
- * relevant scope, returns a decision indicating whether to retry, the
547
- * cooldown to wait, or the exhaustion action to take.
548
- *
549
- * This is a **pure function** — both engine.ts and resume.ts MUST use
550
- * this function to guarantee identical retry behavior.
551
- *
552
- * @param classification - The classified merge failure (null = unclassifiable)
553
- * @param currentRetryCount - Current retry attempts for this scope (0 = first failure)
554
- * @returns Retry decision with all fields populated
555
- * @since TP-033
556
- */
557
- export function computeMergeRetryDecision(
558
- classification: MergeFailureClassification | null,
559
- currentRetryCount: number,
560
- ): MergeRetryDecision {
561
- // Unclassifiable failures are never retried
562
- if (classification === null) {
563
- return {
564
- shouldRetry: false,
565
- cooldownMs: 0,
566
- reason: "Unclassifiable merge failure — no retry policy available",
567
- currentAttempt: currentRetryCount,
568
- maxAttempts: 0,
569
- classification: "merge_conflict_unresolved", // placeholder for type safety
570
- exhaustionAction: "pause",
571
- };
572
- }
573
-
574
- const policy: MergeRetryPolicy = MERGE_RETRY_POLICY_MATRIX[classification];
575
-
576
- if (!policy.retriable) {
577
- return {
578
- shouldRetry: false,
579
- cooldownMs: 0,
580
- reason: `${classification} is not retriable — immediate ${policy.exhaustionAction}`,
581
- currentAttempt: currentRetryCount,
582
- maxAttempts: 0,
583
- classification,
584
- exhaustionAction: policy.exhaustionAction,
585
- };
586
- }
587
-
588
- if (currentRetryCount >= policy.maxAttempts) {
589
- return {
590
- shouldRetry: false,
591
- cooldownMs: 0,
592
- reason: `${classification} retry exhausted (${currentRetryCount}/${policy.maxAttempts}) — ${policy.exhaustionAction}`,
593
- currentAttempt: currentRetryCount,
594
- maxAttempts: policy.maxAttempts,
595
- classification,
596
- exhaustionAction: policy.exhaustionAction,
597
- };
598
- }
599
-
600
- return {
601
- shouldRetry: true,
602
- cooldownMs: policy.cooldownMs,
603
- reason: `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` +
604
- (policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""),
605
- currentAttempt: currentRetryCount + 1,
606
- maxAttempts: policy.maxAttempts,
607
- classification,
608
- exhaustionAction: policy.exhaustionAction,
609
- };
610
- }
611
-
612
- /**
613
- * Build the merge retry scope key for persisted retry counters.
614
- *
615
- * Format: `{repoId}:w{waveIndex}:l{laneNumber}`
616
- * - In workspace mode: uses the repo ID (e.g., "api:w0:l1")
617
- * - In repo mode (repoId undefined/null): uses "default" (e.g., "default:w0:l1")
618
- *
619
- * NOTE: This is a different key format from the task-scoped format in v3 types
620
- * (`{taskId}:w{waveIndex}:l{laneNumber}`). The merge retry scope is intentionally
621
- * repo-scoped because merge failures are per-repo, not per-task. Both formats
622
- * coexist in `resilience.retryCountByScope` — the prefix disambiguates them.
623
- *
624
- * @param repoId - Repo ID (undefined/null in repo mode)
625
- * @param waveIndex - 0-based wave index
626
- * @param laneNumber - Lane number
627
- * @returns Scope key string
628
- * @since TP-033
629
- */
630
- export function buildMergeRetryScopeKey(
631
- repoId: string | undefined | null,
632
- waveIndex: number,
633
- laneNumber: number,
634
- ): string {
635
- const repo = repoId ?? "default";
636
- return `${repo}:w${waveIndex}:l${laneNumber}`;
637
- }
638
-
639
- /**
640
- * Extract the repo ID for a failed merge from the MergeWaveResult.
641
- *
642
- * Priority:
643
- * 1. Lane-level: find the failed lane result and use its repoId
644
- * 2. Repo-level: when failedLane is null (setup failure), check repoResults
645
- * for the first failed repo group
646
- * 3. Fallback: undefined (will become "default" in scope key)
647
- *
648
- * This ensures workspace-mode setup failures (e.g., worktree dirty before
649
- * any lane starts) still get repo-scoped counters rather than all collapsing
650
- * into "default:w{N}:l0".
651
- *
652
- * @param mergeResult - The failed MergeWaveResult
653
- * @returns Repo ID or undefined if not determinable
654
- * @since TP-033 R006
655
- */
656
- export function extractFailedRepoId(mergeResult: MergeWaveResult): string | undefined {
657
- const failedLaneNum = mergeResult.failedLane;
658
-
659
- // 1. Try lane-level extraction
660
- if (failedLaneNum !== null && failedLaneNum !== undefined) {
661
- const failedLaneResult = mergeResult.laneResults.find(
662
- lr => lr.laneNumber === failedLaneNum &&
663
- (lr.error || lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE"),
664
- );
665
- if (failedLaneResult?.repoId) return failedLaneResult.repoId;
666
- }
667
-
668
- // 2. Repo-level fallback for setup failures (failedLane === null)
669
- if (mergeResult.repoResults && mergeResult.repoResults.length > 0) {
670
- const failedRepo = mergeResult.repoResults.find(
671
- rr => rr.status === "failed" || rr.status === "partial",
672
- );
673
- if (failedRepo?.repoId) return failedRepo.repoId;
674
- }
675
-
676
- // 3. If failureReason mentions a specific repo path, we could parse it,
677
- // but that's fragile. Return undefined → "default" in scope key.
678
- return undefined;
679
- }
680
-
681
- /**
682
- * Shared merge retry loop used by both engine.ts and resume.ts.
683
- *
684
- * Wraps the retry cycle in a loop: after each failed retry, re-classifies
685
- * the latest mergeResult, recomputes the retry decision using the persisted
686
- * counter, and continues until success, safe-stop, or exhaustion/non-retriable.
687
- *
688
- * This is the **single implementation** of retry loop semantics.
689
- * Engine.ts and resume.ts provide callbacks for their specific side effects
690
- * (persistence, merge invocation, notification) to guarantee parity.
691
- *
692
- * **Important:** On retry exhaustion, this returns `kind: "exhausted"` which
693
- * the caller MUST handle by forcing `paused` phase regardless of
694
- * `on_merge_failure` config. The exhaustion action from the matrix takes
695
- * precedence over config policy.
696
- *
697
- * @param mergeResult - The initial failed merge result
698
- * @param waveIdx - 0-based wave index (for logging)
699
- * @param retryCountByScope - Mutable reference to persisted retry counters
700
- * @param callbacks - Side-effect callbacks for persistence/merge/logging
701
- * @returns Outcome describing what happened during the retry cycle
702
- * @since TP-033 R006
703
- */
704
- export async function applyMergeRetryLoop(
705
- mergeResult: MergeWaveResult,
706
- waveIdx: number,
707
- retryCountByScope: Record<string, number>,
708
- callbacks: MergeRetryCallbacks,
709
- ): Promise<MergeRetryLoopOutcome> {
710
- let currentResult = mergeResult;
711
-
712
- // Classify the initial failure
713
- let classification = classifyMergeFailure(currentResult);
714
- const failedRepoId = extractFailedRepoId(currentResult);
715
- const failedLaneNum = currentResult.failedLane ?? 0;
716
- const scopeKey = buildMergeRetryScopeKey(failedRepoId, waveIdx, failedLaneNum);
717
- const currentRetryCount = retryCountByScope[scopeKey] ?? 0;
718
-
719
- // Check if any retry is possible at all
720
- const initialDecision = computeMergeRetryDecision(classification, currentRetryCount);
721
-
722
- if (!initialDecision.shouldRetry) {
723
- // Non-retriable or already exhausted before we start
724
- if (classification !== null && initialDecision.currentAttempt > 0) {
725
- // Previously had retries — this is exhaustion
726
- return {
727
- kind: "exhausted",
728
- mergeResult: currentResult,
729
- classification,
730
- scopeKey,
731
- lastDecision: initialDecision,
732
- errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${initialDecision.reason}`,
733
- notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${initialDecision.reason}`,
734
- };
735
- }
736
- // No retry was ever possible
737
- return {
738
- kind: "no_retry",
739
- mergeResult: currentResult,
740
- classification,
741
- scopeKey,
742
- };
743
- }
744
-
745
- // Enter retry loop
746
- let lastDecision = initialDecision;
747
-
748
- while (lastDecision.shouldRetry) {
749
- // Increment counter in persisted state
750
- retryCountByScope[scopeKey] = lastDecision.currentAttempt;
751
-
752
- callbacks.log(`merge retry: ${lastDecision.reason}`, {
753
- classification,
754
- scopeKey,
755
- attempt: lastDecision.currentAttempt,
756
- maxAttempts: lastDecision.maxAttempts,
757
- cooldownMs: lastDecision.cooldownMs,
758
- });
759
-
760
- callbacks.persist("merge-retry-increment");
761
-
762
- // Emit Tier 0 attempt event via callback (TP-039 R004: emit only when retry is scheduled)
763
- callbacks.onRetryAttempt?.(lastDecision);
764
-
765
- callbacks.notify(
766
- `🔄 Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` +
767
- (lastDecision.cooldownMs > 0 ? `Waiting ${lastDecision.cooldownMs}ms before retry...` : "Retrying immediately..."),
768
- "warning",
769
- );
770
-
771
- if (lastDecision.cooldownMs > 0) {
772
- await callbacks.sleep(lastDecision.cooldownMs);
773
- }
774
-
775
- // Re-invoke merge
776
- callbacks.persist("merge-retry-start");
777
- currentResult = await callbacks.performMerge();
778
- callbacks.updateMergeResult(currentResult);
779
- callbacks.persist("merge-retry-complete");
780
-
781
- // Check outcome
782
- if (currentResult.status === "succeeded") {
783
- callbacks.notify(`✅ Merge retry succeeded at wave ${waveIdx + 1}.`, "info");
784
- return {
785
- kind: "retry_succeeded",
786
- mergeResult: currentResult,
787
- classification,
788
- scopeKey,
789
- lastDecision,
790
- };
791
- }
792
-
793
- if (currentResult.rollbackFailed) {
794
- // Safe-stop takes priority
795
- const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0;
796
- const persistWarning = hasPersistErrors
797
- ? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.`
798
- : "";
799
-
800
- return {
801
- kind: "safe_stop",
802
- mergeResult: currentResult,
803
- classification,
804
- scopeKey,
805
- lastDecision,
806
- errorMessage:
807
- `Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` +
808
- `Merge worktree and temp branch preserved for recovery.` + persistWarning,
809
- notifyMessage:
810
- `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` +
811
- `Batch force-paused.` + persistWarning,
812
- };
813
- }
814
-
815
- // Retry failed — re-classify and check if we can retry again
816
- classification = classifyMergeFailure(currentResult);
817
- const updatedCount = retryCountByScope[scopeKey] ?? 0;
818
- lastDecision = computeMergeRetryDecision(classification, updatedCount);
819
- }
820
-
821
- // Loop ended: exhaustion
822
- return {
823
- kind: "exhausted",
824
- mergeResult: currentResult,
825
- classification,
826
- scopeKey,
827
- lastDecision,
828
- errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${lastDecision.reason}`,
829
- notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${lastDecision.reason}`,
830
- };
831
- }
832
-
833
- // ── Integrate Cleanup Acceptance (TP-029 Step 3) ─────────────────────
834
-
835
- /**
836
- * Per-repo acceptance check findings after /orch-integrate.
837
- * Collected by scanning all workspace repos (not just repos that had the orch branch).
838
- */
839
- export interface IntegrateCleanupRepoFindings {
840
- /** Repo root path */
841
- repoRoot: string;
842
- /** Repo ID (undefined for repo-mode / primary) */
843
- repoId: string | undefined;
844
- /** Stale lane worktrees still registered (git worktree list matches) */
845
- staleWorktrees: string[];
846
- /** Stale lane branches (task/{opId}-lane-*) */
847
- staleLaneBranches: string[];
848
- /** Stale orch branches (orch/{opId}-{batchId}) */
849
- staleOrchBranches: string[];
850
- /** Batch-scoped autostash entries still present */
851
- staleAutostashEntries: string[];
852
- /** Non-empty .worktrees/ containers */
853
- nonEmptyWorktreeContainers: string[];
854
- }
855
-
856
- /**
857
- * Result of the /orch-integrate cleanup acceptance check.
858
- * Pure function output — callers use this to format the summary notification.
859
- */
860
- export interface IntegrateCleanupResult {
861
- /** True if all repos pass all acceptance criteria */
862
- clean: boolean;
863
- /** Notification severity level: "info" when clean, "warning" when dirty */
864
- notifyLevel: "info" | "warning";
865
- /** Per-repo findings (only repos with at least one finding) */
866
- dirtyRepos: IntegrateCleanupRepoFindings[];
867
- /** User-facing cleanup report (appended to integrate summary) */
868
- report: string;
869
- }
870
-
871
- /**
872
- * Compute the integrate cleanup result from per-repo acceptance findings.
873
- *
874
- * This is a **pure function** — computes all outputs deterministically
875
- * from the per-repo findings without side effects.
876
- *
877
- * The acceptance criteria (roadmap 2d) are:
878
- * 1. No registered lane worktrees remain in any workspace repo
879
- * 2. No lane branches remain (task/{opId}-lane-*)
880
- * 3. No orch branches remain (orch/{opId}-{batchId})
881
- * 4. No stale autostash from current batch remains
882
- * 5. No non-empty .worktrees/ containers remain
883
- *
884
- * @param repoFindings - Per-repo findings from scanning all workspace repos
885
- * @returns Cleanup result with pass/fail verdict and human-readable report
886
- */
887
- export function computeIntegrateCleanupResult(
888
- repoFindings: IntegrateCleanupRepoFindings[],
889
- ): IntegrateCleanupResult {
890
- // Filter to repos that have at least one issue
891
- const dirtyRepos = repoFindings.filter(r =>
892
- r.staleWorktrees.length > 0 ||
893
- r.staleLaneBranches.length > 0 ||
894
- r.staleOrchBranches.length > 0 ||
895
- r.staleAutostashEntries.length > 0 ||
896
- r.nonEmptyWorktreeContainers.length > 0,
897
- );
898
-
899
- if (dirtyRepos.length === 0) {
900
- return {
901
- clean: true,
902
- notifyLevel: "info",
903
- dirtyRepos: [],
904
- report: "🧹 Cleanup verified: no stale worktrees, branches, or autostash entries remain.",
905
- };
906
- }
907
-
908
- // Build per-repo detail lines
909
- const details: string[] = [];
910
- for (const repo of dirtyRepos) {
911
- const label = repo.repoId ?? "(default)";
912
- const issues: string[] = [];
913
- if (repo.staleWorktrees.length > 0) {
914
- issues.push(`${repo.staleWorktrees.length} stale worktree(s)`);
915
- }
916
- if (repo.staleLaneBranches.length > 0) {
917
- issues.push(`${repo.staleLaneBranches.length} lane branch(es)`);
918
- }
919
- if (repo.staleOrchBranches.length > 0) {
920
- issues.push(`${repo.staleOrchBranches.length} orch branch(es)`);
921
- }
922
- if (repo.staleAutostashEntries.length > 0) {
923
- issues.push(`${repo.staleAutostashEntries.length} autostash entr(ies)`);
924
- }
925
- if (repo.nonEmptyWorktreeContainers.length > 0) {
926
- issues.push(`${repo.nonEmptyWorktreeContainers.length} non-empty .worktrees/ container(s)`);
927
- }
928
- details.push(` ${label}: ${issues.join(", ")}`);
929
- }
930
-
931
- // Build recovery commands
932
- const recovery: string[] = [];
933
- for (const repo of dirtyRepos) {
934
- const label = repo.repoId ?? "default";
935
- for (const wt of repo.staleWorktrees) {
936
- recovery.push(` git worktree remove --force "${wt}" # repo: ${label}`);
937
- }
938
- for (const br of repo.staleLaneBranches) {
939
- recovery.push(` git branch -D "${br}" # repo: ${label}`);
940
- }
941
- for (const br of repo.staleOrchBranches) {
942
- recovery.push(` git branch -D "${br}" # repo: ${label}`);
943
- }
944
- for (const entry of repo.staleAutostashEntries) {
945
- recovery.push(` git stash drop "${entry}" # repo: ${label}`);
946
- }
947
- }
948
-
949
- const report =
950
- `⚠️ Cleanup incomplete — residual artifacts found:\n` +
951
- details.join("\n") +
952
- (recovery.length > 0 ? `\n Manual cleanup:\n${recovery.join("\n")}` : "");
953
-
954
- return {
955
- clean: false,
956
- notifyLevel: "warning",
957
- dirtyRepos,
958
- report,
959
- };
960
- }
961
-
962
- // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
963
-
964
- // Note: These are added via extension to the ORCH_MESSAGES object below.
965
-
966
- // ── Resume Orchestration ─────────────────────────────────────────────
967
-
968
- /**
969
- * Resume an interrupted batch from persisted state.
970
- *
971
- * Flow:
972
- * 1. Load and validate batch-state.json
973
- * 2. Check phase eligibility (paused/executing/merging only)
974
- * 3. Check for alive TMUX sessions and .DONE files
975
- * 4. Reconcile persisted state against live signals
976
- * 5. Compute resume point (which wave to start from)
977
- * 6. Reconstruct runtime state and continue execution
978
- *
979
- * @param orchConfig - Orchestrator configuration
980
- * @param runnerConfig - Task runner configuration
981
- * @param cwd - Repository root
982
- * @param batchState - Mutable batch state (will be populated from persisted state)
983
- * @param onNotify - Callback for user-facing messages
984
- * @param onMonitorUpdate - Optional callback for dashboard updates
985
- */
1
+ /**
2
+ * User-facing message templates (ORCH_MESSAGES)
3
+ * @module orch/messages
4
+ */
5
+ import type { AbortMode, MergeFailureClassification, MergeRetryCallbacks, MergeRetryDecision, MergeRetryLoopOutcome, MergeRetryPolicy, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
6
+ import { MERGE_RETRY_POLICY_MATRIX } from "./types.ts";
7
+
8
+ // ── Message Templates ────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Deterministic message templates for user-facing /orch commands.
12
+ * Ensures consistent UX across invocations.
13
+ */
14
+ export const ORCH_MESSAGES = {
15
+ // /orch
16
+ orchStarting: (batchId: string, waves: number, tasks: number) =>
17
+ `🚀 Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`,
18
+ orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) =>
19
+ `\n🌊 Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`,
20
+ orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
21
+ `✅ Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
22
+ orchMergeStart: (waveNum: number, laneCount: number) =>
23
+ `🔀 [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`,
24
+ orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
25
+ ` ✅ Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
26
+ orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
27
+ ` ⚡ Lane ${laneNum} merged with ${conflictCount} auto-resolved conflict(s) (${durationSec}s)`,
28
+ orchMergeLaneFailed: (laneNum: number, reason: string) =>
29
+ ` ❌ Lane ${laneNum} merge failed: ${reason}`,
30
+ orchMergeComplete: (waveNum: number, mergedCount: number, totalSec: number) =>
31
+ `🔀 [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`,
32
+ orchMergeFailed: (waveNum: number, laneNum: number, reason: string) =>
33
+ `❌ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`,
34
+ orchMergeSkipped: (waveNum: number) =>
35
+ `📝 [Wave ${waveNum}] No successful lanes to merge`,
36
+ orchMergePlaceholder: (waveNum: number) =>
37
+ `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`,
38
+ orchWorktreeReset: (waveNum: number, lanes: number) =>
39
+ `🔄 Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
40
+ orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number, orchBranch?: string, baseBranch?: string) => {
41
+ const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
42
+ if (failed > 0 || blocked > 0) {
43
+ lines.push("");
44
+ if (blocked > 0) {
45
+ lines.push(` ${blocked} task(s) were blocked because upstream tasks failed.`);
46
+ }
47
+ lines.push(" Next steps:");
48
+ lines.push(" • /orch-status — review what failed and why");
49
+ lines.push(" • /orch-resume — retry from the failed wave");
50
+ lines.push(" • /orch-abort — clean up and start fresh");
51
+ }
52
+ if (orchBranch && succeeded > 0) {
53
+ lines.push("");
54
+ lines.push(" ┌─────────────────────────────────────────────────┐");
55
+ lines.push(` │ Your changes are on branch: ${orchBranch}`);
56
+ lines.push(` │ Your ${baseBranch || "working"} branch was not modified.`);
57
+ if (baseBranch) {
58
+ lines.push(` │ Preview: git log ${baseBranch}..${orchBranch}`);
59
+ }
60
+ lines.push(" │");
61
+ lines.push(" │ 👉 To bring changes into your working branch:");
62
+ lines.push(" │");
63
+ lines.push(" │ /orch-integrate — merge directly (recommended)");
64
+ lines.push(" │ /orch-integrate --pr — create a pull request");
65
+ lines.push(" └─────────────────────────────────────────────────┘");
66
+ }
67
+ return lines.join("\n");
68
+ },
69
+ orchBatchFailed: (batchId: string, reason: string) =>
70
+ `\n❌ Batch ${batchId} failed: ${reason}`,
71
+ orchBatchStopped: (batchId: string, policy: string) =>
72
+ `\n⛔ Batch ${batchId} stopped by ${policy} policy`,
73
+
74
+ // /orch-pause
75
+ pauseNoBatch: () => "No active batch is running. Use /orch <areas|all> to start.",
76
+ pauseAlreadyPaused: (batchId: string) => `Batch ${batchId} is already paused.`,
77
+ pauseActivated: (batchId: string) =>
78
+ `⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
79
+
80
+ // /orch-sessions
81
+ sessionsNone: () => "No active orchestrator sessions found.",
82
+ sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
83
+
84
+ // /orch orphan detection
85
+ orphanDetectionResume: (batchId: string, sessionCount: number) =>
86
+ `🔄 Found ${sessionCount} running orchestrator session(s) from batch ${batchId}.\n` +
87
+ ` Use /orch-resume to continue, or /orch-abort to clean up.`,
88
+ orphanDetectionAbort: (sessionCount: number) =>
89
+ `⚠️ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` +
90
+ ` Use /orch-abort to clean up before starting a new batch.`,
91
+ orphanDetectionCleanup: () =>
92
+ `🧹 Cleaned up stale batch state file. Starting fresh.`,
93
+
94
+ // /orch-resume
95
+ resumeStarting: (batchId: string, phase: string) =>
96
+ `🔄 Resuming batch ${batchId} (was: ${phase})...`,
97
+ resumeReconciled: (batchId: string, completed: number, pending: number, failed: number, reconnecting: number, reExecuting: number = 0) =>
98
+ `📊 Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` +
99
+ (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""),
100
+ resumeSkippedWaves: (skippedCount: number) =>
101
+ `⏭️ Skipping ${skippedCount} completed wave(s)`,
102
+ resumeReconnecting: (sessionCount: number) =>
103
+ `🔗 Reconnecting to ${sessionCount} alive session(s)...`,
104
+ resumeNoState: () =>
105
+ `❌ No batch to resume. No batch-state.json file found.\n` +
106
+ ` Use /orch <areas|all> to start a new batch.`,
107
+ resumeInvalidState: (error: string) =>
108
+ `❌ Cannot resume: batch state file is invalid.\n` +
109
+ ` Error: ${error}\n` +
110
+ ` Delete .pi/batch-state.json and start a new batch.`,
111
+ resumePhaseNotResumable: (batchId: string, phase: string, reason: string) =>
112
+ `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` +
113
+ ` ${reason}`,
114
+ resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
115
+ `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
116
+
117
+ // /orch-resume --force
118
+ forceResumeStarting: (batchId: string, phase: string) =>
119
+ `⚠️ Force-resuming batch ${batchId} from ${phase} state. Running pre-resume diagnostics...`,
120
+ forceResumeDiagnosticsFailed: (batchId: string) =>
121
+ `❌ Cannot force-resume batch ${batchId}: pre-resume diagnostics failed.\n` +
122
+ ` Fix the issues above, then retry /orch-resume --force.`,
123
+
124
+ // /orch-abort
125
+ abortGracefulStarting: (batchId: string, sessionCount: number) =>
126
+ `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
127
+ abortGracefulWaiting: (batchId: string, graceSec: number) =>
128
+ `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`,
129
+ abortGracefulForceKill: (count: number) =>
130
+ `⚠️ Force-killing ${count} session(s) that did not exit within timeout`,
131
+ abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) =>
132
+ `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`,
133
+ abortHardStarting: (batchId: string, sessionCount: number) =>
134
+ `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`,
135
+ abortHardComplete: (batchId: string, killed: number, durationSec: number) =>
136
+ `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`,
137
+ abortPartialFailure: (failureCount: number) =>
138
+ `⚠️ ${failureCount} error(s) during abort (see details above)`,
139
+ abortNoBatch: () =>
140
+ `No active batch to abort. Use /orch <areas|all> to start a batch.`,
141
+ abortComplete: (mode: AbortMode, sessionsKilled: number) =>
142
+ `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
143
+ // /orch merge — repo-scoped partial summary (TP-005 Step 1)
144
+ orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
145
+ `⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`,
146
+
147
+ // /orch integration — post-batch integration guidance (TP-022 Step 4)
148
+ orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
149
+ `✅ Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
150
+ orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
151
+ `⚠️ Auto-integration skipped: ${reason}\n` +
152
+ ` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
153
+ ` git log ${baseBranch}..${orchBranch}\n` +
154
+ ` git merge ${orchBranch}`,
155
+ orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
156
+ const lines = [
157
+ `ℹ️ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
158
+ ` Review and integrate:`,
159
+ ` git log ${baseBranch}..${orchBranch}`,
160
+ ` git merge ${orchBranch}`,
161
+ ];
162
+ return lines.join("\n");
163
+ },
164
+ } as const;
165
+
166
+
167
+ // ── Repo-Scoped Merge Summary (TP-005) ──────────────────────────────
168
+
169
+ /**
170
+ * Status emoji for repo merge outcome.
171
+ */
172
+ function repoStatusIcon(status: RepoMergeOutcome["status"]): string {
173
+ switch (status) {
174
+ case "succeeded": return "✅";
175
+ case "partial": return "⚠️";
176
+ case "failed": return "❌";
177
+ default: return "❓";
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Format a repo-divergence summary for a partial merge wave result.
183
+ *
184
+ * Returns null if:
185
+ * - repoResults is empty or undefined (mono-repo mode)
186
+ * - all repos have the same status (no divergence)
187
+ * - there is only one repo group (divergence is meaningless)
188
+ *
189
+ * When the partial result is caused by mixed-outcome lanes within
190
+ * a single repo (not repo divergence), this returns null to avoid
191
+ * misleading "cross-repo divergence" messaging.
192
+ *
193
+ * The returned string is a complete, ready-to-emit message.
194
+ *
195
+ * @param mergeResult - The MergeWaveResult with status "partial"
196
+ * @returns Formatted summary string, or null if no repo-divergence summary applies
197
+ */
198
+ export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | null {
199
+ const repoResults = mergeResult.repoResults;
200
+
201
+ // No repo attribution → mono-repo mode, no summary
202
+ if (!repoResults || repoResults.length === 0) {
203
+ return null;
204
+ }
205
+
206
+ // Single repo group → divergence is meaningless (partial is lane-level)
207
+ if (repoResults.length < 2) {
208
+ return null;
209
+ }
210
+
211
+ // Check for actual divergence: are there different statuses across repos?
212
+ const statuses = new Set(repoResults.map(r => r.status));
213
+ if (statuses.size < 2) {
214
+ // All repos have the same status (e.g., all "partial") —
215
+ // the partial is from within-repo lane failures, not cross-repo divergence
216
+ return null;
217
+ }
218
+
219
+ // Build per-repo summary lines (sorted by repoId, which repoResults already is)
220
+ const repoLines = repoResults.map(r => {
221
+ const repoLabel = r.repoId ?? "(default)";
222
+ const icon = repoStatusIcon(r.status);
223
+ // TP-032 R006-3: Exclude verification_new_failure lanes from success count
224
+ const mergedCount = r.laneResults.filter(
225
+ lr => !lr.error && (lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED"),
226
+ ).length;
227
+ const totalCount = r.laneResults.length;
228
+ let detail = `${mergedCount}/${totalCount} lane(s) merged`;
229
+ if (r.failureReason) {
230
+ detail += ` — ${r.failureReason.slice(0, 150)}`;
231
+ }
232
+ return ` ${icon} ${repoLabel}: ${detail}`;
233
+ });
234
+
235
+ return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines);
236
+ }
237
+
238
+
239
+ // ── Merge Failure Policy Application (TP-005 Step 2) ─────────────────
240
+
241
+ /**
242
+ * Result of applying the merge failure policy.
243
+ *
244
+ * Pure function output — callers use this to perform state mutations
245
+ * and notifications consistently. Ensures engine.ts and resume.ts
246
+ * apply identical pause/abort transitions.
247
+ */
248
+ export interface MergeFailurePolicyResult {
249
+ /** The applied policy: "pause" or "abort". */
250
+ policy: "pause" | "abort";
251
+ /** Target phase for batchState.phase. */
252
+ targetPhase: "paused" | "stopped";
253
+ /** Error message to push to batchState.errors. */
254
+ errorMessage: string;
255
+ /** Persistence trigger label. */
256
+ persistTrigger: "merge-failure-pause" | "merge-failure-abort";
257
+ /** User-facing notification message. */
258
+ notifyMessage: string;
259
+ /** Notification level for onNotify. */
260
+ notifyLevel: "error";
261
+ /** Comma-separated failed lane identifiers for logging. */
262
+ failedLaneIds: string;
263
+ /** Structured log details for execLog. */
264
+ logDetails: {
265
+ failedLane: number;
266
+ failedLaneIds: string;
267
+ reason: string;
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Compute the merge failure policy application result.
273
+ *
274
+ * This is a **pure function** — it computes all outputs deterministically
275
+ * from the merge result and config, without performing any side effects.
276
+ *
277
+ * Both engine.ts and resume.ts MUST use this function to guarantee
278
+ * identical failure attribution, phase transitions, error messages,
279
+ * and notifications on repo-scoped merge failures.
280
+ *
281
+ * Failure attribution rules (priority chain):
282
+ * 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
283
+ * → formatted as `lane-<N>` (comma-separated).
284
+ * 2. Fallback: if no lane-level failures but `mergeResult.failedLane`
285
+ * is non-null, uses `lane-<N>` as the identifier.
286
+ * 3. Repo-level: if no lane-level failures and failedLane is null
287
+ * (repo setup failure), uses `repo:<repoId>` from repoResults
288
+ * entries with non-succeeded status. Sorted deterministically.
289
+ * - The failure reason is truncated to 200 chars for notifications and
290
+ * logged in full in batchState.errors.
291
+ *
292
+ * @param mergeResult - The merge wave result with status "failed" or "partial"
293
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
294
+ * @param config - Orchestrator configuration (for on_merge_failure policy)
295
+ * @returns Policy result object for callers to apply
296
+ */
297
+ export function computeMergeFailurePolicy(
298
+ mergeResult: MergeWaveResult,
299
+ waveIndex: number,
300
+ config: OrchestratorConfig,
301
+ ): MergeFailurePolicyResult {
302
+ const waveNum = waveIndex + 1;
303
+ const mergeFailurePolicy = config.failure.on_merge_failure;
304
+
305
+ // Build failed lane identifiers from lane results.
306
+ // Priority chain:
307
+ // 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
308
+ // 2. Fallback: failedLane from mergeResult (single lane ID)
309
+ // 3. Repo-level: repos with non-succeeded status from repoResults
310
+ // (catches setup failures where failedLane=null and no lane results)
311
+ let failedLaneIds = mergeResult.laneResults
312
+ .filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
313
+ .map(r => `lane-${r.laneNumber}`)
314
+ .join(", ");
315
+ if (!failedLaneIds && mergeResult.failedLane !== null) {
316
+ failedLaneIds = `lane-${mergeResult.failedLane}`;
317
+ }
318
+ if (!failedLaneIds && mergeResult.repoResults && mergeResult.repoResults.length > 0) {
319
+ // Repo-level fallback for setup failures (no lane results, failedLane=null).
320
+ // Uses sorted repoResults order for determinism.
321
+ failedLaneIds = mergeResult.repoResults
322
+ .filter(r => r.status !== "succeeded")
323
+ .map(r => `repo:${r.repoId ?? "default"}`)
324
+ .join(", ");
325
+ }
326
+
327
+ const reason = mergeResult.failureReason || "unknown";
328
+ const reasonTruncated = reason.slice(0, 200);
329
+
330
+ const logDetails = {
331
+ failedLane: mergeResult.failedLane ?? 0,
332
+ failedLaneIds,
333
+ reason: reasonTruncated,
334
+ };
335
+
336
+ const errorMessage =
337
+ `Merge failed at wave ${waveNum}: ${reason}. ` +
338
+ (mergeFailurePolicy === "pause"
339
+ ? `Batch paused. Resolve conflicts and use /orch-resume to continue.`
340
+ : `Batch aborted by on_merge_failure policy.`);
341
+
342
+ const laneDetail = failedLaneIds ? ` (${failedLaneIds})` : "";
343
+
344
+ let notifyMessage: string;
345
+ if (mergeFailurePolicy === "pause") {
346
+ notifyMessage =
347
+ `⏸️ Batch paused due to merge failure at wave ${waveNum}${laneDetail}. ` +
348
+ `Reason: ${reasonTruncated}. ` +
349
+ `Resolve conflicts and resume.`;
350
+ } else {
351
+ notifyMessage =
352
+ `⛔ Batch aborted due to merge failure at wave ${waveNum}${laneDetail}. ` +
353
+ `Reason: ${reasonTruncated}.`;
354
+ }
355
+
356
+ return {
357
+ policy: mergeFailurePolicy,
358
+ targetPhase: mergeFailurePolicy === "pause" ? "paused" : "stopped",
359
+ errorMessage,
360
+ persistTrigger: mergeFailurePolicy === "pause" ? "merge-failure-pause" : "merge-failure-abort",
361
+ notifyMessage,
362
+ notifyLevel: "error",
363
+ failedLaneIds,
364
+ logDetails,
365
+ };
366
+ }
367
+
368
+
369
+ // ── Cleanup Gate Policy (TP-029 Step 2) ──────────────────────────────
370
+
371
+ /**
372
+ * Per-repo cleanup failure detail.
373
+ * Collected during post-merge inter-wave verification.
374
+ */
375
+ export interface CleanupGateRepoFailure {
376
+ /** Repo root path that has stale worktrees */
377
+ repoRoot: string;
378
+ /** Repo ID (undefined for primary/repo-mode) */
379
+ repoId: string | undefined;
380
+ /** Paths of stale worktrees still registered after cleanup */
381
+ staleWorktrees: string[];
382
+ }
383
+
384
+ /**
385
+ * Result of applying the cleanup gate policy.
386
+ *
387
+ * Pure function output — callers use this to perform state mutations
388
+ * and notifications consistently. Ensures engine.ts and resume.ts
389
+ * apply identical pause transitions on cleanup failure.
390
+ */
391
+ export interface CleanupGatePolicyResult {
392
+ /** Always "pause" — cleanup failures block next wave but preserve merged work */
393
+ policy: "pause";
394
+ /** Target phase for batchState.phase */
395
+ targetPhase: "paused";
396
+ /** Error message to push to batchState.errors */
397
+ errorMessage: string;
398
+ /** Persistence trigger label — matches spec classification naming */
399
+ persistTrigger: "cleanup_post_merge_failed";
400
+ /** User-facing notification message */
401
+ notifyMessage: string;
402
+ /** Notification level for onNotify */
403
+ notifyLevel: "error";
404
+ /** Structured log details for execLog */
405
+ logDetails: {
406
+ waveNumber: number;
407
+ failedRepoCount: number;
408
+ totalStaleWorktrees: number;
409
+ repos: Array<{ repoId: string; staleCount: number }>;
410
+ };
411
+ }
412
+
413
+ /**
414
+ * Compute the cleanup gate policy result for post-merge verification failure.
415
+ *
416
+ * This is a **pure function** — it computes all outputs deterministically
417
+ * from the wave index and per-repo failure details, without performing any
418
+ * side effects.
419
+ *
420
+ * Both engine.ts and resume.ts MUST use this function to guarantee
421
+ * identical failure attribution, phase transitions, error messages,
422
+ * and notifications when post-merge cleanup leaves stale worktrees.
423
+ *
424
+ * The cleanup gate always pauses (never aborts) because:
425
+ * - Merged commits are already on the orch branch and must not be lost
426
+ * - The operator can manually remove stale worktrees and `/orch-resume`
427
+ *
428
+ * @param waveIndex - 0-based wave index (displayed as 1-indexed)
429
+ * @param failures - Per-repo cleanup failure details
430
+ * @returns Policy result object for callers to apply
431
+ */
432
+ export function computeCleanupGatePolicy(
433
+ waveIndex: number,
434
+ failures: CleanupGateRepoFailure[],
435
+ ): CleanupGatePolicyResult {
436
+ const waveNum = waveIndex + 1;
437
+ const failedRepoCount = failures.length;
438
+ const totalStaleWorktrees = failures.reduce((sum, f) => sum + f.staleWorktrees.length, 0);
439
+
440
+ const repos = failures.map(f => ({
441
+ repoId: f.repoId ?? "(default)",
442
+ staleCount: f.staleWorktrees.length,
443
+ }));
444
+
445
+ const repoDetail = repos.map(r => `${r.repoId} (${r.staleCount} stale)`).join(", ");
446
+
447
+ const errorMessage =
448
+ `Post-merge cleanup failed at wave ${waveNum}: ${totalStaleWorktrees} stale worktree(s) ` +
449
+ `in ${failedRepoCount} repo(s) [${repoDetail}]. ` +
450
+ `Batch paused. Remove stale worktrees manually and use /orch-resume to continue.`;
451
+
452
+ // Build recovery commands for each failed repo
453
+ const recoveryLines: string[] = [];
454
+ for (const f of failures) {
455
+ const label = f.repoId ?? "default";
456
+ for (const wt of f.staleWorktrees) {
457
+ recoveryLines.push(` git worktree remove --force "${wt}" # repo: ${label}`);
458
+ }
459
+ }
460
+
461
+ const notifyMessage =
462
+ `⏸️ Batch paused: post-merge cleanup failed at wave ${waveNum}.\n` +
463
+ ` ${totalStaleWorktrees} stale worktree(s) in ${failedRepoCount} repo(s): ${repoDetail}\n` +
464
+ ` Manual recovery:\n` +
465
+ recoveryLines.join("\n") + "\n" +
466
+ ` Then: /orch-resume`;
467
+
468
+ return {
469
+ policy: "pause",
470
+ targetPhase: "paused",
471
+ errorMessage,
472
+ persistTrigger: "cleanup_post_merge_failed",
473
+ notifyMessage,
474
+ notifyLevel: "error",
475
+ logDetails: {
476
+ waveNumber: waveNum,
477
+ failedRepoCount,
478
+ totalStaleWorktrees,
479
+ repos,
480
+ },
481
+ };
482
+ }
483
+
484
+ // ── Merge Retry Policy (TP-033 Step 2) ───────────────────────────────
485
+
486
+ /**
487
+ * Classify a merge failure into a MergeFailureClassification.
488
+ *
489
+ * Inspects the MergeWaveResult — lane errors, failure reasons, and merge
490
+ * result statuses — to determine which retry policy class applies.
491
+ *
492
+ * Classification priority (first match wins):
493
+ * 1. `verification_new_failure` — any lane error starts with "verification_new_failure"
494
+ * 2. `merge_conflict_unresolved` — any lane result has CONFLICT_UNRESOLVED status
495
+ * 3. `cleanup_post_merge_failed` — failure reason contains "cleanup" or "stale worktree"
496
+ * 4. `git_lock_file` — failure reason contains "lock" or ".lock"
497
+ * 5. `git_worktree_dirty` — failure reason contains "dirty" or "worktree"
498
+ * 6. `null` — unclassifiable (treated as non-retriable by callers)
499
+ *
500
+ * This is a **pure function** — no side effects.
501
+ *
502
+ * @param mergeResult - The failed MergeWaveResult to classify
503
+ * @returns Classification or null if no merge-retry class matches
504
+ * @since TP-033
505
+ */
506
+ export function classifyMergeFailure(mergeResult: MergeWaveResult): MergeFailureClassification | null {
507
+ // Check lane-level errors first (most specific)
508
+ for (const lr of mergeResult.laneResults) {
509
+ if (lr.error && lr.error.startsWith("verification_new_failure")) {
510
+ return "verification_new_failure";
511
+ }
512
+ }
513
+
514
+ // Check lane result statuses
515
+ for (const lr of mergeResult.laneResults) {
516
+ if (lr.result?.status === "CONFLICT_UNRESOLVED") {
517
+ return "merge_conflict_unresolved";
518
+ }
519
+ }
520
+
521
+ // Check failure reason string patterns
522
+ const reason = (mergeResult.failureReason || "").toLowerCase();
523
+
524
+ // Lock file detection: git operations fail with "Unable to create '.../.git/index.lock': File exists"
525
+ if (reason.includes("lock") || reason.includes(".lock")) {
526
+ return "git_lock_file";
527
+ }
528
+
529
+ // Cleanup failures: stale worktrees or cleanup errors
530
+ if (reason.includes("cleanup") || reason.includes("stale worktree")) {
531
+ return "cleanup_post_merge_failed";
532
+ }
533
+
534
+ // Dirty worktree: git operations fail due to uncommitted changes
535
+ if (reason.includes("dirty") || reason.includes("worktree")) {
536
+ return "git_worktree_dirty";
537
+ }
538
+
539
+ return null;
540
+ }
541
+
542
+ /**
543
+ * Compute the retry decision for a merge failure.
544
+ *
545
+ * Given the failure classification and the current retry count for the
546
+ * relevant scope, returns a decision indicating whether to retry, the
547
+ * cooldown to wait, or the exhaustion action to take.
548
+ *
549
+ * This is a **pure function** — both engine.ts and resume.ts MUST use
550
+ * this function to guarantee identical retry behavior.
551
+ *
552
+ * @param classification - The classified merge failure (null = unclassifiable)
553
+ * @param currentRetryCount - Current retry attempts for this scope (0 = first failure)
554
+ * @returns Retry decision with all fields populated
555
+ * @since TP-033
556
+ */
557
+ export function computeMergeRetryDecision(
558
+ classification: MergeFailureClassification | null,
559
+ currentRetryCount: number,
560
+ ): MergeRetryDecision {
561
+ // Unclassifiable failures are never retried
562
+ if (classification === null) {
563
+ return {
564
+ shouldRetry: false,
565
+ cooldownMs: 0,
566
+ reason: "Unclassifiable merge failure — no retry policy available",
567
+ currentAttempt: currentRetryCount,
568
+ maxAttempts: 0,
569
+ classification: "merge_conflict_unresolved", // placeholder for type safety
570
+ exhaustionAction: "pause",
571
+ };
572
+ }
573
+
574
+ const policy: MergeRetryPolicy = MERGE_RETRY_POLICY_MATRIX[classification];
575
+
576
+ if (!policy.retriable) {
577
+ return {
578
+ shouldRetry: false,
579
+ cooldownMs: 0,
580
+ reason: `${classification} is not retriable — immediate ${policy.exhaustionAction}`,
581
+ currentAttempt: currentRetryCount,
582
+ maxAttempts: 0,
583
+ classification,
584
+ exhaustionAction: policy.exhaustionAction,
585
+ };
586
+ }
587
+
588
+ if (currentRetryCount >= policy.maxAttempts) {
589
+ return {
590
+ shouldRetry: false,
591
+ cooldownMs: 0,
592
+ reason: `${classification} retry exhausted (${currentRetryCount}/${policy.maxAttempts}) — ${policy.exhaustionAction}`,
593
+ currentAttempt: currentRetryCount,
594
+ maxAttempts: policy.maxAttempts,
595
+ classification,
596
+ exhaustionAction: policy.exhaustionAction,
597
+ };
598
+ }
599
+
600
+ return {
601
+ shouldRetry: true,
602
+ cooldownMs: policy.cooldownMs,
603
+ reason: `${classification} retry ${currentRetryCount + 1}/${policy.maxAttempts}` +
604
+ (policy.cooldownMs > 0 ? ` (cooldown: ${policy.cooldownMs}ms)` : ""),
605
+ currentAttempt: currentRetryCount + 1,
606
+ maxAttempts: policy.maxAttempts,
607
+ classification,
608
+ exhaustionAction: policy.exhaustionAction,
609
+ };
610
+ }
611
+
612
+ /**
613
+ * Build the merge retry scope key for persisted retry counters.
614
+ *
615
+ * Format: `{repoId}:w{waveIndex}:l{laneNumber}`
616
+ * - In workspace mode: uses the repo ID (e.g., "api:w0:l1")
617
+ * - In repo mode (repoId undefined/null): uses "default" (e.g., "default:w0:l1")
618
+ *
619
+ * NOTE: This is a different key format from the task-scoped format in v3 types
620
+ * (`{taskId}:w{waveIndex}:l{laneNumber}`). The merge retry scope is intentionally
621
+ * repo-scoped because merge failures are per-repo, not per-task. Both formats
622
+ * coexist in `resilience.retryCountByScope` — the prefix disambiguates them.
623
+ *
624
+ * @param repoId - Repo ID (undefined/null in repo mode)
625
+ * @param waveIndex - 0-based wave index
626
+ * @param laneNumber - Lane number
627
+ * @returns Scope key string
628
+ * @since TP-033
629
+ */
630
+ export function buildMergeRetryScopeKey(
631
+ repoId: string | undefined | null,
632
+ waveIndex: number,
633
+ laneNumber: number,
634
+ ): string {
635
+ const repo = repoId ?? "default";
636
+ return `${repo}:w${waveIndex}:l${laneNumber}`;
637
+ }
638
+
639
+ /**
640
+ * Extract the repo ID for a failed merge from the MergeWaveResult.
641
+ *
642
+ * Priority:
643
+ * 1. Lane-level: find the failed lane result and use its repoId
644
+ * 2. Repo-level: when failedLane is null (setup failure), check repoResults
645
+ * for the first failed repo group
646
+ * 3. Fallback: undefined (will become "default" in scope key)
647
+ *
648
+ * This ensures workspace-mode setup failures (e.g., worktree dirty before
649
+ * any lane starts) still get repo-scoped counters rather than all collapsing
650
+ * into "default:w{N}:l0".
651
+ *
652
+ * @param mergeResult - The failed MergeWaveResult
653
+ * @returns Repo ID or undefined if not determinable
654
+ * @since TP-033 R006
655
+ */
656
+ export function extractFailedRepoId(mergeResult: MergeWaveResult): string | undefined {
657
+ const failedLaneNum = mergeResult.failedLane;
658
+
659
+ // 1. Try lane-level extraction
660
+ if (failedLaneNum !== null && failedLaneNum !== undefined) {
661
+ const failedLaneResult = mergeResult.laneResults.find(
662
+ lr => lr.laneNumber === failedLaneNum &&
663
+ (lr.error || lr.result?.status === "CONFLICT_UNRESOLVED" || lr.result?.status === "BUILD_FAILURE"),
664
+ );
665
+ if (failedLaneResult?.repoId) return failedLaneResult.repoId;
666
+ }
667
+
668
+ // 2. Repo-level fallback for setup failures (failedLane === null)
669
+ if (mergeResult.repoResults && mergeResult.repoResults.length > 0) {
670
+ const failedRepo = mergeResult.repoResults.find(
671
+ rr => rr.status === "failed" || rr.status === "partial",
672
+ );
673
+ if (failedRepo?.repoId) return failedRepo.repoId;
674
+ }
675
+
676
+ // 3. If failureReason mentions a specific repo path, we could parse it,
677
+ // but that's fragile. Return undefined → "default" in scope key.
678
+ return undefined;
679
+ }
680
+
681
+ /**
682
+ * Shared merge retry loop used by both engine.ts and resume.ts.
683
+ *
684
+ * Wraps the retry cycle in a loop: after each failed retry, re-classifies
685
+ * the latest mergeResult, recomputes the retry decision using the persisted
686
+ * counter, and continues until success, safe-stop, or exhaustion/non-retriable.
687
+ *
688
+ * This is the **single implementation** of retry loop semantics.
689
+ * Engine.ts and resume.ts provide callbacks for their specific side effects
690
+ * (persistence, merge invocation, notification) to guarantee parity.
691
+ *
692
+ * **Important:** On retry exhaustion, this returns `kind: "exhausted"` which
693
+ * the caller MUST handle by forcing `paused` phase regardless of
694
+ * `on_merge_failure` config. The exhaustion action from the matrix takes
695
+ * precedence over config policy.
696
+ *
697
+ * @param mergeResult - The initial failed merge result
698
+ * @param waveIdx - 0-based wave index (for logging)
699
+ * @param retryCountByScope - Mutable reference to persisted retry counters
700
+ * @param callbacks - Side-effect callbacks for persistence/merge/logging
701
+ * @returns Outcome describing what happened during the retry cycle
702
+ * @since TP-033 R006
703
+ */
704
+ export async function applyMergeRetryLoop(
705
+ mergeResult: MergeWaveResult,
706
+ waveIdx: number,
707
+ retryCountByScope: Record<string, number>,
708
+ callbacks: MergeRetryCallbacks,
709
+ ): Promise<MergeRetryLoopOutcome> {
710
+ let currentResult = mergeResult;
711
+
712
+ // Classify the initial failure
713
+ let classification = classifyMergeFailure(currentResult);
714
+ const failedRepoId = extractFailedRepoId(currentResult);
715
+ const failedLaneNum = currentResult.failedLane ?? 0;
716
+ const scopeKey = buildMergeRetryScopeKey(failedRepoId, waveIdx, failedLaneNum);
717
+ const currentRetryCount = retryCountByScope[scopeKey] ?? 0;
718
+
719
+ // Check if any retry is possible at all
720
+ const initialDecision = computeMergeRetryDecision(classification, currentRetryCount);
721
+
722
+ if (!initialDecision.shouldRetry) {
723
+ // Non-retriable or already exhausted before we start
724
+ if (classification !== null && initialDecision.currentAttempt > 0) {
725
+ // Previously had retries — this is exhaustion
726
+ return {
727
+ kind: "exhausted",
728
+ mergeResult: currentResult,
729
+ classification,
730
+ scopeKey,
731
+ lastDecision: initialDecision,
732
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${initialDecision.reason}`,
733
+ notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${initialDecision.reason}`,
734
+ };
735
+ }
736
+ // No retry was ever possible
737
+ return {
738
+ kind: "no_retry",
739
+ mergeResult: currentResult,
740
+ classification,
741
+ scopeKey,
742
+ };
743
+ }
744
+
745
+ // Enter retry loop
746
+ let lastDecision = initialDecision;
747
+
748
+ while (lastDecision.shouldRetry) {
749
+ // Increment counter in persisted state
750
+ retryCountByScope[scopeKey] = lastDecision.currentAttempt;
751
+
752
+ callbacks.log(`merge retry: ${lastDecision.reason}`, {
753
+ classification,
754
+ scopeKey,
755
+ attempt: lastDecision.currentAttempt,
756
+ maxAttempts: lastDecision.maxAttempts,
757
+ cooldownMs: lastDecision.cooldownMs,
758
+ });
759
+
760
+ callbacks.persist("merge-retry-increment");
761
+
762
+ // Emit Tier 0 attempt event via callback (TP-039 R004: emit only when retry is scheduled)
763
+ callbacks.onRetryAttempt?.(lastDecision);
764
+
765
+ callbacks.notify(
766
+ `🔄 Merge retry (${lastDecision.reason}) at wave ${waveIdx + 1}. ` +
767
+ (lastDecision.cooldownMs > 0 ? `Waiting ${lastDecision.cooldownMs}ms before retry...` : "Retrying immediately..."),
768
+ "warning",
769
+ );
770
+
771
+ if (lastDecision.cooldownMs > 0) {
772
+ await callbacks.sleep(lastDecision.cooldownMs);
773
+ }
774
+
775
+ // Re-invoke merge
776
+ callbacks.persist("merge-retry-start");
777
+ currentResult = await callbacks.performMerge();
778
+ callbacks.updateMergeResult(currentResult);
779
+ callbacks.persist("merge-retry-complete");
780
+
781
+ // Check outcome
782
+ if (currentResult.status === "succeeded") {
783
+ callbacks.notify(`✅ Merge retry succeeded at wave ${waveIdx + 1}.`, "info");
784
+ return {
785
+ kind: "retry_succeeded",
786
+ mergeResult: currentResult,
787
+ classification,
788
+ scopeKey,
789
+ lastDecision,
790
+ };
791
+ }
792
+
793
+ if (currentResult.rollbackFailed) {
794
+ // Safe-stop takes priority
795
+ const hasPersistErrors = currentResult.persistenceErrors && currentResult.persistenceErrors.length > 0;
796
+ const persistWarning = hasPersistErrors
797
+ ? ` WARNING: ${currentResult.persistenceErrors!.length} transaction record(s) failed to persist.`
798
+ : "";
799
+
800
+ return {
801
+ kind: "safe_stop",
802
+ mergeResult: currentResult,
803
+ classification,
804
+ scopeKey,
805
+ lastDecision,
806
+ errorMessage:
807
+ `Safe-stop at wave ${waveIdx + 1}: verification rollback failed after retry. ` +
808
+ `Merge worktree and temp branch preserved for recovery.` + persistWarning,
809
+ notifyMessage:
810
+ `🛑 Safe-stop: verification rollback failed at wave ${waveIdx + 1} after retry. ` +
811
+ `Batch force-paused.` + persistWarning,
812
+ };
813
+ }
814
+
815
+ // Retry failed — re-classify and check if we can retry again
816
+ classification = classifyMergeFailure(currentResult);
817
+ const updatedCount = retryCountByScope[scopeKey] ?? 0;
818
+ lastDecision = computeMergeRetryDecision(classification, updatedCount);
819
+ }
820
+
821
+ // Loop ended: exhaustion
822
+ return {
823
+ kind: "exhausted",
824
+ mergeResult: currentResult,
825
+ classification,
826
+ scopeKey,
827
+ lastDecision,
828
+ errorMessage: `Merge retry exhausted at wave ${waveIdx + 1}: ${lastDecision.reason}`,
829
+ notifyMessage: `⏸️ Merge retry exhausted at wave ${waveIdx + 1}. ${lastDecision.reason}`,
830
+ };
831
+ }
832
+
833
+ // ── Integrate Cleanup Acceptance (TP-029 Step 3) ─────────────────────
834
+
835
+ /**
836
+ * Per-repo acceptance check findings after /orch-integrate.
837
+ * Collected by scanning all workspace repos (not just repos that had the orch branch).
838
+ */
839
+ export interface IntegrateCleanupRepoFindings {
840
+ /** Repo root path */
841
+ repoRoot: string;
842
+ /** Repo ID (undefined for repo-mode / primary) */
843
+ repoId: string | undefined;
844
+ /** Stale lane worktrees still registered (git worktree list matches) */
845
+ staleWorktrees: string[];
846
+ /** Stale lane branches (task/{opId}-lane-*) */
847
+ staleLaneBranches: string[];
848
+ /** Stale orch branches (orch/{opId}-{batchId}) */
849
+ staleOrchBranches: string[];
850
+ /** Batch-scoped autostash entries still present */
851
+ staleAutostashEntries: string[];
852
+ /** Non-empty .worktrees/ containers */
853
+ nonEmptyWorktreeContainers: string[];
854
+ }
855
+
856
+ /**
857
+ * Result of the /orch-integrate cleanup acceptance check.
858
+ * Pure function output — callers use this to format the summary notification.
859
+ */
860
+ export interface IntegrateCleanupResult {
861
+ /** True if all repos pass all acceptance criteria */
862
+ clean: boolean;
863
+ /** Notification severity level: "info" when clean, "warning" when dirty */
864
+ notifyLevel: "info" | "warning";
865
+ /** Per-repo findings (only repos with at least one finding) */
866
+ dirtyRepos: IntegrateCleanupRepoFindings[];
867
+ /** User-facing cleanup report (appended to integrate summary) */
868
+ report: string;
869
+ }
870
+
871
+ /**
872
+ * Compute the integrate cleanup result from per-repo acceptance findings.
873
+ *
874
+ * This is a **pure function** — computes all outputs deterministically
875
+ * from the per-repo findings without side effects.
876
+ *
877
+ * The acceptance criteria (roadmap 2d) are:
878
+ * 1. No registered lane worktrees remain in any workspace repo
879
+ * 2. No lane branches remain (task/{opId}-lane-*)
880
+ * 3. No orch branches remain (orch/{opId}-{batchId})
881
+ * 4. No stale autostash from current batch remains
882
+ * 5. No non-empty .worktrees/ containers remain
883
+ *
884
+ * @param repoFindings - Per-repo findings from scanning all workspace repos
885
+ * @returns Cleanup result with pass/fail verdict and human-readable report
886
+ */
887
+ export function computeIntegrateCleanupResult(
888
+ repoFindings: IntegrateCleanupRepoFindings[],
889
+ ): IntegrateCleanupResult {
890
+ // Filter to repos that have at least one issue
891
+ const dirtyRepos = repoFindings.filter(r =>
892
+ r.staleWorktrees.length > 0 ||
893
+ r.staleLaneBranches.length > 0 ||
894
+ r.staleOrchBranches.length > 0 ||
895
+ r.staleAutostashEntries.length > 0 ||
896
+ r.nonEmptyWorktreeContainers.length > 0,
897
+ );
898
+
899
+ if (dirtyRepos.length === 0) {
900
+ return {
901
+ clean: true,
902
+ notifyLevel: "info",
903
+ dirtyRepos: [],
904
+ report: "🧹 Cleanup verified: no stale worktrees, branches, or autostash entries remain.",
905
+ };
906
+ }
907
+
908
+ // Build per-repo detail lines
909
+ const details: string[] = [];
910
+ for (const repo of dirtyRepos) {
911
+ const label = repo.repoId ?? "(default)";
912
+ const issues: string[] = [];
913
+ if (repo.staleWorktrees.length > 0) {
914
+ issues.push(`${repo.staleWorktrees.length} stale worktree(s)`);
915
+ }
916
+ if (repo.staleLaneBranches.length > 0) {
917
+ issues.push(`${repo.staleLaneBranches.length} lane branch(es)`);
918
+ }
919
+ if (repo.staleOrchBranches.length > 0) {
920
+ issues.push(`${repo.staleOrchBranches.length} orch branch(es)`);
921
+ }
922
+ if (repo.staleAutostashEntries.length > 0) {
923
+ issues.push(`${repo.staleAutostashEntries.length} autostash entr(ies)`);
924
+ }
925
+ if (repo.nonEmptyWorktreeContainers.length > 0) {
926
+ issues.push(`${repo.nonEmptyWorktreeContainers.length} non-empty .worktrees/ container(s)`);
927
+ }
928
+ details.push(` ${label}: ${issues.join(", ")}`);
929
+ }
930
+
931
+ // Build recovery commands
932
+ const recovery: string[] = [];
933
+ for (const repo of dirtyRepos) {
934
+ const label = repo.repoId ?? "default";
935
+ for (const wt of repo.staleWorktrees) {
936
+ recovery.push(` git worktree remove --force "${wt}" # repo: ${label}`);
937
+ }
938
+ for (const br of repo.staleLaneBranches) {
939
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
940
+ }
941
+ for (const br of repo.staleOrchBranches) {
942
+ recovery.push(` git branch -D "${br}" # repo: ${label}`);
943
+ }
944
+ for (const entry of repo.staleAutostashEntries) {
945
+ recovery.push(` git stash drop "${entry}" # repo: ${label}`);
946
+ }
947
+ }
948
+
949
+ const report =
950
+ `⚠️ Cleanup incomplete — residual artifacts found:\n` +
951
+ details.join("\n") +
952
+ (recovery.length > 0 ? `\n Manual cleanup:\n${recovery.join("\n")}` : "");
953
+
954
+ return {
955
+ clean: false,
956
+ notifyLevel: "warning",
957
+ dirtyRepos,
958
+ report,
959
+ };
960
+ }
961
+
962
+ // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
963
+
964
+ // Note: These are added via extension to the ORCH_MESSAGES object below.
965
+
966
+ // ── Resume Orchestration ─────────────────────────────────────────────
967
+
968
+ /**
969
+ * Resume an interrupted batch from persisted state.
970
+ *
971
+ * Flow:
972
+ * 1. Load and validate batch-state.json
973
+ * 2. Check phase eligibility (paused/executing/merging only)
974
+ * 3. Check for alive TMUX sessions and .DONE files
975
+ * 4. Reconcile persisted state against live signals
976
+ * 5. Compute resume point (which wave to start from)
977
+ * 6. Reconstruct runtime state and continue execution
978
+ *
979
+ * @param orchConfig - Orchestrator configuration
980
+ * @param runnerConfig - Task runner configuration
981
+ * @param cwd - Repository root
982
+ * @param batchState - Mutable batch state (will be populated from persisted state)
983
+ * @param onNotify - Callback for user-facing messages
984
+ * @param onMonitorUpdate - Optional callback for dashboard updates
985
+ */