taskplane 0.26.1 → 0.28.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.
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "f
6
6
  import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
- import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents } from "./execution.ts";
9
+ import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
10
10
  import type { RuntimeBackend } from "./execution.ts";
11
11
  import type { MonitorUpdateCallback } from "./execution.ts";
12
12
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
@@ -23,7 +23,7 @@ import { buildBatchProgressSnapshot, buildEngineEventBase, buildSegmentId, build
23
23
  import type { AllocatedLane, AllocatedTask, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, EngineEventCallback, EscalationContext, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedSegmentRecord, SegmentExpansionRequest, SupervisorAlert, SupervisorAlertCallback, TaskRunnerConfig, TaskSegmentPlan, TaskSegmentPlanMap, TaskSegmentNode, Tier0EscalationPattern, Tier0RecoveryPattern, TokenCounts, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
24
24
  import { buildDependencyGraph, computeWaveAssignments, resolveBaseBranch, resolveRepoRoot, validateGraph } from "./waves.ts";
25
25
  import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, preserveFailedLaneProgress, preserveSkippedLaneProgress, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
26
- import { runPreflightCleanup, formatPreflightCleanup } from "./cleanup.ts";
26
+ import { runPreflightCleanup, formatPreflightCleanup, enforceTelemetrySizeCap, formatSizeCap, cleanupPriorBatchArtifacts, formatPriorBatchCleanup } from "./cleanup.ts";
27
27
 
28
28
  // ── Tier 0: Automatic Recovery Helpers (TP-039) ─────────────────────
29
29
 
@@ -147,17 +147,31 @@ function buildSegmentDependencyMap(plan: TaskSegmentPlan): Map<string, string[]>
147
147
  return depsBySegmentId;
148
148
  }
149
149
 
150
- function resolveTaskWorkerAgentId(
150
+ export function resolveTaskWorkerAgentId(
151
151
  taskId: string,
152
152
  allTaskOutcomes: LaneTaskOutcome[],
153
153
  laneByTaskId: Map<string, AllocatedLane>,
154
+ agentIdPrefix?: string,
154
155
  ): string | null {
155
156
  const outcome = allTaskOutcomes.find((candidate) => candidate.taskId === taskId);
156
157
  if (outcome?.sessionName) {
157
158
  return outcome.sessionName;
158
159
  }
160
+ // TP-165: The fallback must derive the *worker* agent ID, not the lane
161
+ // session ID. The outbox lives under the worker agent ID
162
+ // (e.g., "orch-op-lane-2-worker"), not the lane session
163
+ // (e.g., "orch-op-api-lane-1"). In workspace mode these differ because
164
+ // laneSessionId uses repo-scoped local numbering while the worker ID
165
+ // uses the global laneNumber.
159
166
  const lane = laneByTaskId.get(taskId);
160
- return lane?.laneSessionId ?? null;
167
+ if (!lane) return null;
168
+ if (agentIdPrefix) {
169
+ // Canonical path: reconstruct the exact same ID that executeLaneV2 builds
170
+ // via buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker").
171
+ return `${agentIdPrefix}-lane-${lane.laneNumber}-worker`;
172
+ }
173
+ // Legacy/defensive fallback when prefix is unavailable.
174
+ return `${lane.laneSessionId}-worker`;
161
175
  }
162
176
 
163
177
  function listPendingSegmentExpansionRequestFiles(stateRoot: string, batchId: string, agentId: string): string[] {
@@ -1069,12 +1083,65 @@ export function linearizeTaskSegmentPlan(plan: TaskSegmentPlan): TaskSegmentNode
1069
1083
  return ordered;
1070
1084
  }
1071
1085
 
1086
+ /**
1087
+ * Result of `buildSegmentFrontierWaves()`. Contains both the expanded
1088
+ * segment rounds and task-level wave metadata for correct display.
1089
+ *
1090
+ * @since TP-166
1091
+ */
1092
+ export interface SegmentFrontierResult {
1093
+ /** Expanded segment rounds (execution-level) */
1094
+ waves: string[][];
1095
+ /** Per-task segment frontier state */
1096
+ taskStateById: Map<string, SegmentFrontierTaskState>;
1097
+ /**
1098
+ * Number of original dependency-driven task-level waves.
1099
+ * Use this for operator-facing "Wave X of Y" display.
1100
+ */
1101
+ taskLevelWaveCount: number;
1102
+ /**
1103
+ * Maps each segment round index (0-based) to its parent task-level
1104
+ * wave index (0-based). When continuation rounds are dynamically
1105
+ * inserted via `scheduleContinuationSegmentRound`, the caller must
1106
+ * also insert the corresponding task-level wave index into this array.
1107
+ */
1108
+ roundToTaskWave: number[];
1109
+ }
1110
+
1111
+ /**
1112
+ * Resolve the 1-indexed task-level wave number for display from a
1113
+ * segment-round index. Falls back to `roundIdx + 1` when the mapping
1114
+ * is missing or out of bounds.
1115
+ *
1116
+ * @param roundIdx - Current segment round index (0-based)
1117
+ * @param roundToTaskWave - Mapping from round index to task-level wave (0-based)
1118
+ * @param taskLevelWaveCount - Number of original task-level waves
1119
+ * @param fallbackTotal - Optional fallback total (e.g., batchState.totalWaves) for
1120
+ * legacy state files that lack TP-166 metadata
1121
+ * @since TP-166
1122
+ */
1123
+ export function resolveDisplayWaveNumber(
1124
+ roundIdx: number,
1125
+ roundToTaskWave: number[] | undefined,
1126
+ taskLevelWaveCount: number | undefined,
1127
+ fallbackTotal?: number,
1128
+ ): { displayWave: number; displayTotal: number } {
1129
+ const taskWaveIdx = roundToTaskWave?.[roundIdx];
1130
+ const displayWave = (taskWaveIdx != null) ? taskWaveIdx + 1 : roundIdx + 1;
1131
+ const displayTotal = taskLevelWaveCount ?? fallbackTotal ?? (roundIdx + 1);
1132
+ return { displayWave, displayTotal };
1133
+ }
1134
+
1072
1135
  /**
1073
1136
  * Expand task waves into segment-frontier rounds.
1074
1137
  *
1075
1138
  * Each original task-wave becomes N rounds where N is the max segment count
1076
1139
  * among tasks in that wave. A task with fewer segments simply drops out once
1077
1140
  * its segment list is exhausted.
1141
+ *
1142
+ * Returns both the expanded rounds and a mapping from segment round index
1143
+ * to task-level wave index, enabling correct "Wave X of Y" display
1144
+ * without inflating wave count with segment rounds (TP-166).
1078
1145
  */
1079
1146
  export function buildSegmentFrontierWaves(
1080
1147
  baseTaskWaves: string[][],
@@ -1082,7 +1149,7 @@ export function buildSegmentFrontierWaves(
1082
1149
  segmentPlans?: TaskSegmentPlanMap,
1083
1150
  packetRepoId?: string,
1084
1151
  workspaceRoot?: string,
1085
- ): { waves: string[][]; taskStateById: Map<string, SegmentFrontierTaskState> } {
1152
+ ): SegmentFrontierResult {
1086
1153
  const taskStateById = new Map<string, SegmentFrontierTaskState>();
1087
1154
 
1088
1155
  for (const [taskId, task] of pending.entries()) {
@@ -1112,7 +1179,11 @@ export function buildSegmentFrontierWaves(
1112
1179
  }
1113
1180
 
1114
1181
  const expanded: string[][] = [];
1115
- for (const waveTasks of baseTaskWaves) {
1182
+ // TP-166: Track which task-level wave each segment round belongs to.
1183
+ // roundToTaskWave[i] = 0-based task-level wave index for segment round i.
1184
+ const roundToTaskWave: number[] = [];
1185
+ for (let taskWaveIdx = 0; taskWaveIdx < baseTaskWaves.length; taskWaveIdx++) {
1186
+ const waveTasks = baseTaskWaves[taskWaveIdx];
1116
1187
  let maxSegmentsInWave = 0;
1117
1188
  for (const taskId of waveTasks) {
1118
1189
  const state = taskStateById.get(taskId);
@@ -1131,6 +1202,7 @@ export function buildSegmentFrontierWaves(
1131
1202
  }
1132
1203
  if (segmentRound.length > 0) {
1133
1204
  expanded.push(segmentRound);
1205
+ roundToTaskWave.push(taskWaveIdx);
1134
1206
  }
1135
1207
  }
1136
1208
  }
@@ -1138,6 +1210,8 @@ export function buildSegmentFrontierWaves(
1138
1210
  return {
1139
1211
  waves: expanded,
1140
1212
  taskStateById,
1213
+ taskLevelWaveCount: baseTaskWaves.length,
1214
+ roundToTaskWave,
1141
1215
  };
1142
1216
  }
1143
1217
 
@@ -2009,11 +2083,12 @@ export async function executeOrchBatch(
2009
2083
  return;
2010
2084
  }
2011
2085
 
2012
- // ── TP-065: Preflight artifact cleanup (Layer 2 + Layer 3) ───
2013
- // Sweep stale artifacts and rotate oversized logs before batch starts.
2086
+ // ── TP-065/TP-168: Preflight artifact cleanup (Layers 2–5) ───
2087
+ // Sweep stale artifacts, rotate oversized logs, enforce size cap,
2088
+ // and clean prior batch artifacts before batch starts.
2014
2089
  // Always non-fatal — failures warn but never block batch execution.
2015
2090
  try {
2016
- // Layer 2: Age-based sweep of stale telemetry/merge artifacts (>7 days)
2091
+ // Layer 2: Age-based sweep of stale telemetry/merge/verification/conversation artifacts (>3 days)
2017
2092
  const sweepResult = sweepStaleArtifacts(stateRoot, {
2018
2093
  isBatchActive: () => {
2019
2094
  // Check persisted state — a prior batch may still be active
@@ -2038,6 +2113,21 @@ export async function executeOrchBatch(
2038
2113
  if (rotationMsg) {
2039
2114
  onNotify(rotationMsg, "info");
2040
2115
  }
2116
+ // Layer 4: Telemetry directory size cap (TP-168)
2117
+ const sizeCapResult = enforceTelemetrySizeCap(stateRoot);
2118
+ const sizeCapMsg = formatSizeCap(sizeCapResult);
2119
+ if (sizeCapMsg) {
2120
+ onNotify(sizeCapMsg, "info");
2121
+ }
2122
+
2123
+ // Layer 5: Clean up prior batch artifacts (TP-168)
2124
+ if (batchState.batchId) {
2125
+ const priorCleanup = cleanupPriorBatchArtifacts(stateRoot, batchState.batchId);
2126
+ const priorMsg = formatPriorBatchCleanup(priorCleanup);
2127
+ if (priorMsg) {
2128
+ onNotify(priorMsg, "info");
2129
+ }
2130
+ }
2041
2131
  } catch {
2042
2132
  // Non-fatal — never block batch start for cleanup errors
2043
2133
  }
@@ -2140,7 +2230,14 @@ export async function executeOrchBatch(
2140
2230
  const rawWaves = frontier.waves;
2141
2231
  segmentStateByTask = frontier.taskStateById;
2142
2232
 
2233
+ // TP-166: Track task-level wave metadata for correct display.
2234
+ // roundToTaskWave maps each segment round index to its parent task-level wave.
2235
+ let roundToTaskWave = frontier.roundToTaskWave;
2236
+ const taskLevelWaveCount = frontier.taskLevelWaveCount;
2237
+
2143
2238
  batchState.totalWaves = rawWaves.length;
2239
+ batchState.taskLevelWaveCount = taskLevelWaveCount;
2240
+ batchState.roundToTaskWave = [...roundToTaskWave];
2144
2241
  batchState.totalTasks = discovery.pending.size;
2145
2242
 
2146
2243
  // Store wave plan and discovery for state persistence
@@ -2154,6 +2251,8 @@ export async function executeOrchBatch(
2154
2251
  // The orch branch isolates all batch work from the user's current branch.
2155
2252
  // Worktrees branch from it; merges target it via update-ref.
2156
2253
  const opId = resolveOperatorId(orchConfig);
2254
+ const sessionPrefix = orchConfig.orchestrator?.sessionPrefix ?? "orch";
2255
+ const agentIdPrefix = `${sessionPrefix}-${opId}`;
2157
2256
  const orchBranch = `orch/${opId}-${batchState.batchId}`;
2158
2257
 
2159
2258
  // In workspace mode, create the orch branch in every repo that might
@@ -2193,8 +2292,9 @@ export async function executeOrchBatch(
2193
2292
  }
2194
2293
  batchState.orchBranch = orchBranch;
2195
2294
 
2295
+ // TP-166: Report task-level wave count, not segment round count
2196
2296
  onNotify(
2197
- ORCH_MESSAGES.orchStarting(batchState.batchId, rawWaves.length, batchState.totalTasks),
2297
+ ORCH_MESSAGES.orchStarting(batchState.batchId, taskLevelWaveCount, batchState.totalTasks),
2198
2298
  "info",
2199
2299
  );
2200
2300
 
@@ -2224,7 +2324,10 @@ export async function executeOrchBatch(
2224
2324
  if (batchState.pauseSignal.paused) {
2225
2325
  batchState.phase = "paused";
2226
2326
  execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`);
2227
- onNotify(`⏸️ Batch paused before wave ${waveIdx + 1}. Resume not yet implemented (TS-009).`, "warning");
2327
+ {
2328
+ const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
2329
+ onNotify(`⏸️ Batch paused before wave ${displayWave}. Resume not yet implemented (TS-009).`, "warning");
2330
+ }
2228
2331
  // ── TS-009: Persist state on pause ──
2229
2332
  persistRuntimeState("pause-before-wave", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
2230
2333
  // TP-040: Emit batch_paused event (via terminal helper for dedup)
@@ -2309,9 +2412,10 @@ export async function executeOrchBatch(
2309
2412
  latestAllocatedLanes = lanes;
2310
2413
  batchState.currentLanes = lanes;
2311
2414
 
2312
- // Emit wave_start with actual lane count (post-affinity grouping)
2415
+ // TP-166: Use task-level wave number for operator display
2416
+ const { displayWave, displayTotal } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
2313
2417
  onNotify(
2314
- ORCH_MESSAGES.orchWaveStart(waveIdx + 1, runtimeSegmentRounds.length, waveTasks.length, lanes.length),
2418
+ ORCH_MESSAGES.orchWaveStart(displayWave, displayTotal, waveTasks.length, lanes.length),
2315
2419
  "info",
2316
2420
  );
2317
2421
  // TP-148: Build per-task segment context for the wave_start event
@@ -2579,7 +2683,7 @@ export async function executeOrchBatch(
2579
2683
  segmentState.statusBySegmentId.set(activeSegmentId, "succeeded");
2580
2684
  upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "succeeded", outcome, laneByTaskId.get(taskId));
2581
2685
 
2582
- const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
2686
+ const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId, agentIdPrefix);
2583
2687
  if (workerAgentId) {
2584
2688
  const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId);
2585
2689
  if (pendingExpansionFiles.length > 0) {
@@ -2669,16 +2773,30 @@ export async function executeOrchBatch(
2669
2773
  // segments have been added and must execute first.
2670
2774
  // Only delete if segments were actually inserted (avoid
2671
2775
  // reopening a completed task on no-op mutations).
2672
- const doneDir = task.packetTaskPath || task.taskFolder;
2673
- if (doneDir && mutation.insertedSegmentIds.length > 0) {
2674
- const donePath = join(doneDir, ".DONE");
2675
- if (existsSync(donePath)) {
2676
- try {
2677
- unlinkSync(donePath);
2678
- execLog("batch", batchState.batchId, "removed premature .DONE after segment expansion", {
2679
- taskId, donePath, requestId,
2680
- });
2681
- } catch { /* non-fatal */ }
2776
+ //
2777
+ // TP-165: Resolve .DONE path via the lane worktree, not
2778
+ // task.packetTaskPath/task.taskFolder (which may point to the
2779
+ // workspace root, not the worktree where .DONE was created).
2780
+ if (mutation.insertedSegmentIds.length > 0) {
2781
+ const lane = laneByTaskId.get(taskId);
2782
+ const doneDir = lane
2783
+ ? resolveCanonicalTaskPaths(
2784
+ task.taskFolder,
2785
+ lane.worktreePath,
2786
+ repoRoot,
2787
+ !!workspaceConfig,
2788
+ ).taskFolderResolved
2789
+ : task.packetTaskPath || task.taskFolder;
2790
+ if (doneDir) {
2791
+ const donePath = join(doneDir, ".DONE");
2792
+ if (existsSync(donePath)) {
2793
+ try {
2794
+ unlinkSync(donePath);
2795
+ execLog("batch", batchState.batchId, "removed premature .DONE after segment expansion", {
2796
+ taskId, donePath, requestId,
2797
+ });
2798
+ } catch { /* non-fatal */ }
2799
+ }
2682
2800
  }
2683
2801
  }
2684
2802
 
@@ -2733,6 +2851,11 @@ export async function executeOrchBatch(
2733
2851
  }
2734
2852
  if (continuationTaskIds.size > 0) {
2735
2853
  const continuationWave = scheduleContinuationSegmentRound(runtimeSegmentRounds, waveIdx, continuationTaskIds);
2854
+ // TP-166: Maintain roundToTaskWave mapping for the inserted continuation round.
2855
+ // The continuation belongs to the same task-level wave as the current round.
2856
+ const parentTaskWave = roundToTaskWave[waveIdx] ?? 0;
2857
+ roundToTaskWave.splice(waveIdx + 1, 0, parentTaskWave);
2858
+ batchState.roundToTaskWave = [...roundToTaskWave];
2736
2859
  execLog("batch", batchState.batchId, "scheduled continuation segment round for expanded task frontier", {
2737
2860
  waveIndex: waveIdx,
2738
2861
  taskIds: continuationWave.join(","),
@@ -2750,7 +2873,7 @@ export async function executeOrchBatch(
2750
2873
  segmentState.statusBySegmentId.set(activeSegmentId, "failed");
2751
2874
  upsertTerminalSegmentRecord(batchState, task, segmentState, activeSegmentId, "failed", failOutcome, laneByTaskId.get(taskId));
2752
2875
 
2753
- const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId);
2876
+ const workerAgentId = resolveTaskWorkerAgentId(taskId, allTaskOutcomes, laneByTaskId, agentIdPrefix);
2754
2877
  if (workerAgentId) {
2755
2878
  const pendingExpansionFiles = listPendingSegmentExpansionRequestFiles(stateRoot, batchState.batchId, workerAgentId);
2756
2879
  if (pendingExpansionFiles.length > 0) {
@@ -2900,7 +3023,7 @@ export async function executeOrchBatch(
2900
3023
  frontierSummary +
2901
3024
  ` Lane: ${laneForTask?.laneId ?? "unknown"} (lane ${laneForTask?.laneNumber ?? "?"})\n` +
2902
3025
  ` Partial progress preserved: ${hasPartialProgress ? "yes" : "no"}\n` +
2903
- ` Batch: wave ${waveIdx + 1}/${batchState.totalWaves}, ` +
3026
+ ` Batch: wave ${resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave}/${taskLevelWaveCount}, ` +
2904
3027
  `${batchState.succeededTasks} succeeded, ${batchState.failedTasks} failed\n\n` +
2905
3028
  `Available actions:\n` +
2906
3029
  ` - orch_status() to inspect current state\n` +
@@ -2925,16 +3048,19 @@ export async function executeOrchBatch(
2925
3048
  persistRuntimeState("wave-execution-complete", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
2926
3049
 
2927
3050
  const elapsedSec = Math.round((waveResult.endedAt - waveResult.startedAt) / 1000);
2928
- onNotify(
2929
- ORCH_MESSAGES.orchWaveComplete(
2930
- waveIdx + 1,
2931
- waveResult.succeededTaskIds.length,
2932
- waveResult.failedTaskIds.length,
2933
- waveResult.skippedTaskIds.length,
2934
- elapsedSec,
2935
- ),
2936
- waveResult.failedTaskIds.length > 0 ? "warning" : "info",
2937
- );
3051
+ {
3052
+ const { displayWave: completeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
3053
+ onNotify(
3054
+ ORCH_MESSAGES.orchWaveComplete(
3055
+ completeDisplayWave,
3056
+ waveResult.succeededTaskIds.length,
3057
+ waveResult.failedTaskIds.length,
3058
+ waveResult.skippedTaskIds.length,
3059
+ elapsedSec,
3060
+ ),
3061
+ waveResult.failedTaskIds.length > 0 ? "warning" : "info",
3062
+ );
3063
+ }
2938
3064
 
2939
3065
  // NOTE: No explicit wave_complete event in the spec event set. The supervisor
2940
3066
  // infers wave completion from the sequence of task_complete/task_failed events
@@ -3037,7 +3163,7 @@ export async function executeOrchBatch(
3037
3163
  batchState.phase = "merging";
3038
3164
  // ── TS-009: Persist state on executing→merging transition ──
3039
3165
  persistRuntimeState("merge-start", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
3040
- onNotify(ORCH_MESSAGES.orchMergeStart(waveIdx + 1, mergeableLaneCount), "info");
3166
+ onNotify(ORCH_MESSAGES.orchMergeStart(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeableLaneCount), "info");
3041
3167
  // TP-040: Emit merge_start event
3042
3168
  emitEvent(stateRoot, {
3043
3169
  ...buildEngineEventBase("merge_start", batchState.batchId, waveIdx, batchState.phase),
@@ -3132,18 +3258,19 @@ export async function executeOrchBatch(
3132
3258
  const mergeTotalSec = Math.round(mergeResult.totalDurationMs / 1000);
3133
3259
 
3134
3260
  if (mergeResult.status === "succeeded") {
3135
- onNotify(ORCH_MESSAGES.orchMergeComplete(waveIdx + 1, mergedCount, mergeTotalSec), "info");
3261
+ const { displayWave: mergeDisplayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
3262
+ onNotify(ORCH_MESSAGES.orchMergeComplete(mergeDisplayWave, mergedCount, mergeTotalSec), "info");
3136
3263
 
3137
3264
  // TP-040: Emit merge_success event
3138
3265
  emitEvent(stateRoot, {
3139
3266
  ...buildEngineEventBase("merge_success", batchState.batchId, waveIdx, batchState.phase),
3140
3267
  laneCount: mergedCount,
3141
3268
  durationMs: mergeResult.totalDurationMs,
3142
- totalWaves: runtimeSegmentRounds.length,
3269
+ totalWaves: taskLevelWaveCount,
3143
3270
  }, onEngineEvent);
3144
3271
  } else {
3145
3272
  onNotify(
3146
- ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
3273
+ ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane ?? 0, mergeResult.failureReason || "unknown"),
3147
3274
  "error",
3148
3275
  );
3149
3276
 
@@ -3184,7 +3311,7 @@ export async function executeOrchBatch(
3184
3311
  allMergeResults.push(mergeResult);
3185
3312
  batchState.mergeResults.push(mergeResult);
3186
3313
  onNotify(
3187
- ORCH_MESSAGES.orchMergeFailed(waveIdx + 1, mergeResult.failedLane, mergeResult.failureReason || "unknown"),
3314
+ ORCH_MESSAGES.orchMergeFailed(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, mergeResult.failedLane, mergeResult.failureReason || "unknown"),
3188
3315
  "error",
3189
3316
  );
3190
3317
 
@@ -3196,11 +3323,11 @@ export async function executeOrchBatch(
3196
3323
  }, onEngineEvent);
3197
3324
  } else {
3198
3325
  // No mergeable lanes and no mixed outcomes (e.g., only skipped tasks)
3199
- onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
3326
+ onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info");
3200
3327
  }
3201
3328
  } else {
3202
3329
  // No succeeded tasks — skip merge entirely
3203
- onNotify(ORCH_MESSAGES.orchMergeSkipped(waveIdx + 1), "info");
3330
+ onNotify(ORCH_MESSAGES.orchMergeSkipped(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave), "info");
3204
3331
  }
3205
3332
 
3206
3333
  // ── TP-033: Safe-stop on rollback failure ─────────────────
@@ -3644,7 +3771,7 @@ export async function executeOrchBatch(
3644
3771
 
3645
3772
  if (totalResetWorktrees > 0) {
3646
3773
  onNotify(
3647
- ORCH_MESSAGES.orchWorktreeReset(waveIdx + 1, totalResetWorktrees),
3774
+ ORCH_MESSAGES.orchWorktreeReset(resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount).displayWave, totalResetWorktrees),
3648
3775
  "info",
3649
3776
  );
3650
3777
  }
@@ -3900,10 +4027,18 @@ export async function executeOrchBatch(
3900
4027
  legacyLaneTokensByKey,
3901
4028
  );
3902
4029
 
4030
+ // TP-171: Map outcome status to valid BatchTaskSummary status.
4031
+ // Non-terminal statuses ("running", "pending") can appear if batch
4032
+ // was paused/aborted mid-wave. Map them to appropriate history values.
4033
+ const validStatuses: Set<string> = new Set(["succeeded", "failed", "skipped", "blocked", "stalled", "pending"]);
4034
+ const historyStatus: BatchTaskSummary["status"] = validStatuses.has(to.status)
4035
+ ? (to.status as BatchTaskSummary["status"])
4036
+ : "pending"; // "running" or unknown → "pending" in history
4037
+
3903
4038
  return {
3904
4039
  taskId: to.taskId,
3905
4040
  taskName: to.taskId,
3906
- status: to.status as BatchTaskSummary["status"],
4041
+ status: historyStatus,
3907
4042
  wave,
3908
4043
  lane,
3909
4044
  durationMs,
@@ -3993,7 +4128,7 @@ export async function executeOrchBatch(
3993
4128
  startedAt: batchState.startedAt,
3994
4129
  endedAt: Date.now(),
3995
4130
  durationMs: Date.now() - batchState.startedAt,
3996
- totalWaves: wavePlan.length,
4131
+ totalWaves: taskLevelWaveCount,
3997
4132
  totalTasks: actualTotalTasks,
3998
4133
  succeededTasks: batchState.succeededTasks,
3999
4134
  failedTasks: batchState.failedTasks,
@@ -4306,7 +4441,7 @@ export async function executeOrchBatch(
4306
4441
  summary:
4307
4442
  `✅ Batch ${batchState.batchId} completed\n` +
4308
4443
  ` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded\n` +
4309
- ` ${batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
4444
+ ` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
4310
4445
  ` Merged to orch branch: ${batchState.orchBranch}\n\n` +
4311
4446
  `Ready for integration. Run orch_integrate() or review first.`,
4312
4447
  context: {