taskplane 0.30.4 → 0.30.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.
@@ -120,6 +120,14 @@ export interface EngineWorkerData {
120
120
  force?: boolean;
121
121
  /** Supervisor autonomy mode propagated to worker bridge tools. */
122
122
  supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
123
+ /**
124
+ * #631: the batch this engine is AUTHORIZED to drive. Preallocated by the
125
+ * parent so the engine identity (pid) is published BEFORE the engine starts
126
+ * — for a fresh batch this is the id the engine must adopt instead of
127
+ * generating its own; for resume it is the persisted/reconstructed target
128
+ * the parent gated ownership against, and resume verifies it matches.
129
+ */
130
+ authorizedBatchId?: string;
123
131
  }
124
132
 
125
133
  // ── Serialization helpers (used by both main thread and worker) ──────
@@ -211,13 +219,57 @@ export function applySerializedState(
211
219
 
212
220
  // Guard: only run engine main when launched via fork() with the sentinel env var.
213
221
  if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
222
+ // #631: a send() on a CLOSED channel does not throw synchronously — Node emits
223
+ // an asynchronous ERR_IPC_CHANNEL_CLOSED on `process`, which would surface as
224
+ // an uncaughtException and route an orphaned engine into reportFatalAndExit
225
+ // instead of its graceful paused wind-down. Gate every send on
226
+ // `process.connected`, and absorb any stray channel error.
214
227
  const send = (msg: WorkerToMainMessage) => {
228
+ if (!process.connected) return;
215
229
  try {
216
230
  process.send?.(msg);
217
231
  } catch {
218
232
  // best effort only
219
233
  }
220
234
  };
235
+ const safeDisconnect = () => {
236
+ if (!process.connected) return;
237
+ try {
238
+ process.disconnect?.();
239
+ } catch {
240
+ /* already closed */
241
+ }
242
+ };
243
+ process.on("error", (err: unknown) => {
244
+ const code = (err as { code?: string } | null)?.code;
245
+ if (code === "ERR_IPC_CHANNEL_CLOSED" || code === "EPIPE") return; // parent gone — expected while orphaned
246
+ throw err;
247
+ });
248
+
249
+ // #631: orphan detection must be armed BEFORE the async module imports below
250
+ // (a parent can die during engine startup). `batchState` is hoisted so the
251
+ // handler can pause it once it exists.
252
+ let batchState: OrchBatchRuntimeState | null = null;
253
+ let orphanedBeforeInit = false;
254
+ process.on("disconnect", () => {
255
+ if (!batchState) {
256
+ // Parent vanished before init/planning produced any state: nothing to
257
+ // checkpoint, nothing another session could inherit. Exit quietly.
258
+ orphanedBeforeInit = true;
259
+ process.exit(0);
260
+ return;
261
+ }
262
+ // We call process.disconnect() ourselves after a terminal state — that is
263
+ // not an orphaning; only act while the batch is still active.
264
+ const p = batchState.phase;
265
+ if (p === "completed" || p === "failed" || p === "paused" || p === "stopped") return;
266
+ batchState.pauseSignal.paused = true;
267
+ batchState.pauseSignal.cause = "operator";
268
+ process.stderr.write(
269
+ `[orch] engine-worker: supervisor disconnected (parent pid gone) — winding down as paused (#631)
270
+ `,
271
+ );
272
+ });
221
273
 
222
274
  const sendWithAck = (msg: WorkerToMainMessage, onFlushed: () => void) => {
223
275
  if (typeof process.send !== "function" || !process.connected) {
@@ -255,8 +307,8 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
255
307
  // Wait for the init message carrying workerData, then start the engine.
256
308
  process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
257
309
  if (initMsg?.type !== "init") return;
310
+ if (orphanedBeforeInit) return;
258
311
 
259
- let batchState: OrchBatchRuntimeState | null = null;
260
312
  let fatalHandled = false;
261
313
  const reportFatalAndExit = (source: WorkerErrorSource, err: unknown) => {
262
314
  if (fatalHandled) return;
@@ -292,6 +344,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
292
344
 
293
345
  // Create a fresh batch state for this process
294
346
  batchState = freshOrchBatchState();
347
+ if (data.authorizedBatchId) batchState.batchId = data.authorizedBatchId; // #631
295
348
  batchState.phase = "launching";
296
349
  batchState.startedAt = Date.now();
297
350
 
@@ -306,12 +359,15 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
306
359
  switch (msg.type) {
307
360
  case "pause":
308
361
  batchState.pauseSignal.paused = true;
362
+ batchState.pauseSignal.cause = "operator";
309
363
  break;
310
364
  case "resume":
311
365
  batchState.pauseSignal.paused = false;
366
+ batchState.pauseSignal.cause = undefined;
312
367
  break;
313
368
  case "abort":
314
369
  batchState.pauseSignal.paused = true;
370
+ batchState.pauseSignal.cause = "abort";
315
371
  break;
316
372
  }
317
373
  });
@@ -392,7 +448,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
392
448
  const finalState = serializeBatchState(batchState);
393
449
  send({ type: "complete", state: finalState });
394
450
  // Disconnect IPC so the child process can exit cleanly
395
- process.disconnect?.();
451
+ safeDisconnect();
396
452
  })
397
453
  .catch((err: unknown) => {
398
454
  const normalized = normalizeError(err);
@@ -409,7 +465,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
409
465
  message: normalized.message,
410
466
  stack: normalized.stack,
411
467
  });
412
- process.disconnect?.();
468
+ safeDisconnect();
413
469
  });
414
470
  });
415
471
  }
@@ -10,6 +10,7 @@ import {
10
10
  buildReviewerEnv,
11
11
  buildWorkerEnv,
12
12
  buildWorkerExcludeEnv,
13
+ batchTaskScope,
13
14
  computeTransitiveDependents,
14
15
  execLog,
15
16
  executeLaneV2,
@@ -21,7 +22,7 @@ import type { RuntimeBackend } from "./execution.ts";
21
22
  import type { MonitorUpdateCallback } from "./execution.ts";
22
23
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
23
24
  // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput).
24
- import { getCurrentBranch, runGit } from "./git.ts";
25
+ import { describeOrchBranchStateAcrossRepos, getCurrentBranch, runGit } from "./git.ts";
25
26
  import { killAllMergeAgentsV2, mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts";
26
27
  import {
27
28
  applyMergeRetryLoop,
@@ -1581,6 +1582,18 @@ async function attemptWorkerCrashRetry(
1581
1582
  continue;
1582
1583
  }
1583
1584
 
1585
+ // #629: a finalize refusal over an outstanding REVISE/RETHINK is a
1586
+ // governance outcome, not a fault. Re-running the same worker without
1587
+ // the review file changing cannot succeed; the supervisor adjudicates.
1588
+ if (classification === "review_gate_refusal") {
1589
+ execLog(
1590
+ "batch",
1591
+ batchState.batchId,
1592
+ `tier0: task ${taskId} review_gate_refusal — awaiting adjudication, NOT auto-retrying (#629)`,
1593
+ );
1594
+ continue;
1595
+ }
1596
+
1584
1597
  // Check if retryable
1585
1598
  if (!TIER0_RETRYABLE_CLASSIFICATIONS.has(classification)) {
1586
1599
  execLog(
@@ -2341,6 +2354,8 @@ async function attemptStaleWorktreeRecovery(
2341
2354
  thinking: runnerConfig?.reviewer?.thinking || "",
2342
2355
  tools: runnerConfig?.reviewer?.tools || "",
2343
2356
  excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
2357
+ severityLabels: runnerConfig?.reviewer?.severityLabels,
2358
+ spiral: runnerConfig?.reviewer?.spiral,
2344
2359
  },
2345
2360
  runnerConfig?.worker
2346
2361
  ? {
@@ -2533,7 +2548,9 @@ export async function executeOrchBatch(
2533
2548
 
2534
2549
  // ── Phase 1: Planning ────────────────────────────────────────
2535
2550
  batchState.phase = "planning";
2536
- batchState.batchId = generateBatchId();
2551
+ // #631: honour a parent-preallocated (authorized) batchId so the engine
2552
+ // identity could be published before this process started.
2553
+ batchState.batchId = batchState.batchId || generateBatchId();
2537
2554
  // Preserve startedAt if set during "launching" phase (TP-040)
2538
2555
  if (!batchState.startedAt) batchState.startedAt = Date.now();
2539
2556
  // Preserve pauseSignal if already set during "launching" phase (TP-040)
@@ -2887,6 +2904,7 @@ export async function executeOrchBatch(
2887
2904
  // Check pause signal before starting each wave
2888
2905
  if (batchState.pauseSignal.paused) {
2889
2906
  batchState.phase = "paused";
2907
+ preserveWorktreesForResume = true; // every pause exit preserves recovery worktrees
2890
2908
  execLog("batch", batchState.batchId, `batch paused before wave ${waveIdx + 1}`);
2891
2909
  {
2892
2910
  const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
@@ -3111,6 +3129,8 @@ export async function executeOrchBatch(
3111
3129
  thinking: runnerConfig?.reviewer?.thinking || "",
3112
3130
  tools: runnerConfig?.reviewer?.tools || "",
3113
3131
  excludeExtensions: runnerConfig?.reviewer?.excludeExtensions ?? [],
3132
+ severityLabels: runnerConfig?.reviewer?.severityLabels,
3133
+ spiral: runnerConfig?.reviewer?.spiral,
3114
3134
  },
3115
3135
  runnerConfig?.worker
3116
3136
  ? {
@@ -3243,7 +3263,11 @@ export async function executeOrchBatch(
3243
3263
  if (modelFallbackOutcome.succeededRetries.length > 0) {
3244
3264
  // Recompute blocked tasks after model fallback successes
3245
3265
  if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
3246
- const recomputed = computeTransitiveDependents(new Set(waveResult.failedTaskIds), depGraph);
3266
+ const recomputed = computeTransitiveDependents(
3267
+ new Set(waveResult.failedTaskIds),
3268
+ depGraph,
3269
+ batchTaskScope(wavePlan),
3270
+ );
3247
3271
  waveResult.blockedTaskIds = [...recomputed].sort();
3248
3272
  } else if (waveResult.failedTaskIds.length === 0) {
3249
3273
  waveResult.blockedTaskIds = [];
@@ -3285,7 +3309,11 @@ export async function executeOrchBatch(
3285
3309
  // attemptWorkerCrashRetry already updated waveResult.failedTaskIds
3286
3310
  // and waveResult.succeededTaskIds in-place.
3287
3311
  if (waveResult.policyApplied === "skip-dependents" && waveResult.failedTaskIds.length > 0) {
3288
- const recomputed = computeTransitiveDependents(new Set(waveResult.failedTaskIds), depGraph);
3312
+ const recomputed = computeTransitiveDependents(
3313
+ new Set(waveResult.failedTaskIds),
3314
+ depGraph,
3315
+ batchTaskScope(wavePlan),
3316
+ );
3289
3317
  waveResult.blockedTaskIds = [...recomputed].sort();
3290
3318
  } else if (waveResult.failedTaskIds.length === 0) {
3291
3319
  // All failures recovered — no blocked tasks
@@ -3312,9 +3340,13 @@ export async function executeOrchBatch(
3312
3340
  if (
3313
3341
  waveResult.failedTaskIds.length === 0 &&
3314
3342
  batchState.pauseSignal.paused &&
3343
+ // Positive check: Tier-0 clears ONLY a policy-caused pause. Operator,
3344
+ // abort and merge-failure pauses are never cleared by a recovered retry.
3345
+ (batchState.pauseSignal.cause === undefined || batchState.pauseSignal.cause === "stop-wave") &&
3315
3346
  waveResult.policyApplied === "stop-wave"
3316
3347
  ) {
3317
3348
  batchState.pauseSignal.paused = false;
3349
+ batchState.pauseSignal.cause = undefined;
3318
3350
  execLog(
3319
3351
  "batch",
3320
3352
  batchState.batchId,
@@ -3748,9 +3780,14 @@ export async function executeOrchBatch(
3748
3780
  batchState.failedTasks += waveResult.failedTaskIds.length;
3749
3781
  batchState.skippedTasks += waveResult.skippedTaskIds.length;
3750
3782
 
3751
- // Add newly blocked tasks (after retry so recovered tasks don't block dependents)
3752
- for (const blocked of waveResult.blockedTaskIds) {
3753
- batchState.blockedTaskIds.add(blocked);
3783
+ // Add newly blocked tasks (after retry so recovered tasks don't block dependents).
3784
+ // #629: the dependency graph is repo-wide — only record IDs that belong to
3785
+ // this batch (the blockedTasks counter is already wave-scoped).
3786
+ {
3787
+ const scope = batchTaskScope(wavePlan);
3788
+ for (const blocked of waveResult.blockedTaskIds) {
3789
+ if (scope.has(blocked)) batchState.blockedTaskIds.add(blocked);
3790
+ }
3754
3791
  }
3755
3792
 
3756
3793
  // ── TP-040: Emit task_complete / task_failed events ──────
@@ -3919,6 +3956,51 @@ export async function executeOrchBatch(
3919
3956
  break;
3920
3957
  }
3921
3958
 
3959
+ // ── Pause finalizer (penster 20260906T194514) ───────────────
3960
+ // A pause that lands DURING a wave leaves tasks pending (never skipped).
3961
+ // Previously pause was only honoured before the NEXT wave, so a paused
3962
+ // single-wave batch "completed" 0/1, merged nothing and cleaned up its
3963
+ // worktree. Finalize as paused here: persist the pending outcomes and
3964
+ // frontier, preserve worktrees, emit batch_paused, and stop — no merge.
3965
+ // A stop-all abort also leaves peers pending — that is the abort path's
3966
+ // business, never a pause. The exclusion applies to the WHOLE predicate.
3967
+ const notAborting =
3968
+ batchState.pauseSignal.cause !== "abort" && waveResult.overallStatus !== "aborted";
3969
+ const operatorPaused = batchState.pauseSignal.paused && notAborting;
3970
+ if (notAborting && ((waveResult.pausedTaskIds?.length ?? 0) > 0 || operatorPaused)) {
3971
+ batchState.phase = "paused";
3972
+ preserveWorktreesForResume = true;
3973
+ const pausedIds = waveResult.pausedTaskIds ?? [];
3974
+ execLog("batch", batchState.batchId, `batch paused during wave ${waveIdx + 1}`, {
3975
+ cause: batchState.pauseSignal.cause ?? "operator",
3976
+ pendingTasks: pausedIds.join(",") || "(none)",
3977
+ succeeded: waveResult.succeededTaskIds.length,
3978
+ failed: waveResult.failedTaskIds.length,
3979
+ });
3980
+ // (Succeeded/failed/skipped counters were already accumulated above; paused
3981
+ // tasks are pending and intentionally not counted.)
3982
+ persistRuntimeState(
3983
+ "pause-during-wave",
3984
+ batchState,
3985
+ wavePlan,
3986
+ latestAllocatedLanes,
3987
+ allTaskOutcomes,
3988
+ discoveryRef,
3989
+ stateRoot,
3990
+ );
3991
+ {
3992
+ const { displayWave } = resolveDisplayWaveNumber(waveIdx, roundToTaskWave, taskLevelWaveCount);
3993
+ onNotify(
3994
+ `⏸️ Batch paused during wave ${displayWave}: ${pausedIds.length} task(s) remain pending` +
3995
+ `${waveResult.succeededTaskIds.length > 0 ? `, ${waveResult.succeededTaskIds.length} succeeded (unmerged until resume)` : ""}. ` +
3996
+ `Worktrees preserved. Use orch_resume() to continue.`,
3997
+ "warning",
3998
+ );
3999
+ }
4000
+ emitTerminalEvent(`Paused during wave ${waveIdx + 1}`);
4001
+ break;
4002
+ }
4003
+
3922
4004
  // ── TS-009: Persist state after wave execution ──
3923
4005
  persistRuntimeState(
3924
4006
  "wave-execution-complete",
@@ -4915,12 +4997,28 @@ export async function executeOrchBatch(
4915
4997
  });
4916
4998
  // If reset fails, remove this worktree so the next wave can recreate it cleanly.
4917
4999
  try {
4918
- removeWorktree(wt, perRepoRoot);
4919
- execLog(
4920
- "batch",
4921
- batchState.batchId,
4922
- `removed unrecoverable worktree for lane ${wt.laneNumber}`,
4923
- );
5000
+ const rm = removeWorktree(wt, perRepoRoot);
5001
+ if (rm.refusedDirty) {
5002
+ // #628: refusal is NOT success — the worktree still exists with
5003
+ // uncommitted work. Track it for the cleanup gate and surface loudly;
5004
+ // do NOT force-clean (that would destroy the work being protected).
5005
+ execLog(
5006
+ "batch",
5007
+ batchState.batchId,
5008
+ `worktree removal REFUSED for lane ${wt.laneNumber}: ${rm.dirtyFileCount} uncommitted change(s) — preserve progress before cleanup (#628)`,
5009
+ { path: wt.path },
5010
+ );
5011
+ if (!failedRemovalWorktrees.has(perRepoRoot)) {
5012
+ failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
5013
+ }
5014
+ failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
5015
+ } else {
5016
+ execLog(
5017
+ "batch",
5018
+ batchState.batchId,
5019
+ `removed unrecoverable worktree for lane ${wt.laneNumber}`,
5020
+ );
5021
+ }
4924
5022
  } catch (removeErr: unknown) {
4925
5023
  execLog(
4926
5024
  "batch",
@@ -5841,14 +5939,36 @@ export async function executeOrchBatch(
5841
5939
  ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s`
5842
5940
  : "unknown";
5843
5941
  if (batchState.phase === "completed" && batchState.failedTasks === 0) {
5942
+ // Report outcomes and branch state SEPARATELY and truthfully (penster
5943
+ // 20260906T194514 saw "Merged … Ready for integration" on 0/1 succeeded
5944
+ // with an empty orch branch). Never say "merged" unless the orch branch is
5945
+ // verifiably ahead of base; a failed comparison is "unknown", not "nothing".
5946
+ const branchState = describeOrchBranchStateAcrossRepos(
5947
+ batchState.orchBranch,
5948
+ batchState.baseBranch,
5949
+ encounteredRepoRoots.keys(),
5950
+ );
5951
+ const hasSuccess = batchState.succeededTasks > 0;
5952
+ const outcomeLine =
5953
+ ` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded` +
5954
+ (batchState.skippedTasks > 0 ? `, ${batchState.skippedTasks} skipped` : "") +
5955
+ "\n";
5956
+ const nextStep =
5957
+ hasSuccess && branchState.kind === "ahead"
5958
+ ? `Ready for integration. Run orch_integrate() or review first.`
5959
+ : branchState.kind === "ahead"
5960
+ ? `⚠️ No task succeeded, yet ${branchState.detail} — partial work was merged; inspect before integrating.`
5961
+ : branchState.kind === "unknown"
5962
+ ? `⚠️ Could not verify the orch branch (${branchState.detail}). Inspect before integrating.`
5963
+ : `Nothing to integrate: ${branchState.detail}.`;
5844
5964
  emitAlert({
5845
5965
  category: "batch-complete",
5846
5966
  summary:
5847
- `✅ Batch ${batchState.batchId} completed\n` +
5848
- ` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded\n` +
5967
+ `${hasSuccess ? "✅" : "⚠️"} Batch ${batchState.batchId} completed\n` +
5968
+ outcomeLine +
5849
5969
  ` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
5850
- ` Merged to orch branch: ${batchState.orchBranch}\n\n` +
5851
- `Ready for integration. Run orch_integrate() or review first.`,
5970
+ ` Orch branch ${batchState.orchBranch}: ${branchState.detail}\n\n` +
5971
+ nextStep,
5852
5972
  context: {
5853
5973
  batchProgress: buildBatchProgressSnapshot(batchState),
5854
5974
  batchDurationMs,
@@ -35,6 +35,7 @@ import type {
35
35
  ParsedTask,
36
36
  TaskMonitorSnapshot,
37
37
  WaveExecutionResult,
38
+ PauseSignal,
38
39
  WorkspaceConfig,
39
40
  ExecutionUnit,
40
41
  PacketPaths,
@@ -1198,7 +1199,7 @@ export async function monitorLanes(
1198
1199
  lanes: AllocatedLane[],
1199
1200
  config: OrchestratorConfig,
1200
1201
  repoRoot: string,
1201
- pauseSignal: { paused: boolean },
1202
+ pauseSignal: PauseSignal,
1202
1203
  waveNumber: number = 1,
1203
1204
  onUpdate?: MonitorUpdateCallback,
1204
1205
  isWorkspaceMode?: boolean,
@@ -1521,15 +1522,25 @@ export async function monitorLanes(
1521
1522
  * The failed tasks themselves are NOT included in the output — only their
1522
1523
  * downstream dependents.
1523
1524
  *
1525
+ * #629 side-effect 3: the dependency graph is REPO-WIDE (built from all
1526
+ * discovered tasks), so without a scope the result can name tasks that are
1527
+ * not in the batch at all ("blocked=TP-2047" for a single-task batch). When
1528
+ * `scope` is given, traversal still walks THROUGH out-of-scope nodes (a
1529
+ * transitive dependent reached via one is still blocked) but only in-scope
1530
+ * task IDs are REPORTED.
1531
+ *
1524
1532
  * @param failedTaskIds - Set of task IDs that failed
1525
1533
  * @param dependencyGraph - Dependency graph with dependents map
1534
+ * @param scope - Optional batch task set; only these IDs are reported
1526
1535
  * @returns Set of task IDs transitively blocked (excludes the failed tasks themselves)
1527
1536
  */
1528
1537
  export function computeTransitiveDependents(
1529
1538
  failedTaskIds: Set<string>,
1530
1539
  dependencyGraph: DependencyGraph,
1540
+ scope?: Set<string>,
1531
1541
  ): Set<string> {
1532
1542
  const blocked = new Set<string>();
1543
+ const visited = new Set<string>(); // traversal set — distinct from the reported set
1533
1544
  const queue = [...failedTaskIds];
1534
1545
 
1535
1546
  while (queue.length > 0) {
@@ -1540,9 +1551,10 @@ export function computeTransitiveDependents(
1540
1551
  const sortedDependents = [...dependents].sort();
1541
1552
 
1542
1553
  for (const dep of sortedDependents) {
1543
- if (blocked.has(dep)) continue;
1554
+ if (visited.has(dep)) continue;
1544
1555
  if (failedTaskIds.has(dep)) continue; // Don't re-add failed tasks
1545
- blocked.add(dep);
1556
+ visited.add(dep);
1557
+ if (!scope || scope.has(dep)) blocked.add(dep);
1546
1558
  queue.push(dep); // Continue BFS for transitive closure
1547
1559
  }
1548
1560
  }
@@ -1550,6 +1562,13 @@ export function computeTransitiveDependents(
1550
1562
  return blocked;
1551
1563
  }
1552
1564
 
1565
+ /** Flatten a wave plan into the set of task IDs that belong to the batch. */
1566
+ export function batchTaskScope(wavePlan: string[][] | undefined | null): Set<string> {
1567
+ const scope = new Set<string>();
1568
+ for (const wave of wavePlan ?? []) for (const id of wave) scope.add(id);
1569
+ return scope;
1570
+ }
1571
+
1553
1572
  // ── Pre-flight: Commit Untracked Task Files ─────────────────────────
1554
1573
 
1555
1574
  /**
@@ -1914,7 +1933,7 @@ export async function executeWave(
1914
1933
  config: OrchestratorConfig,
1915
1934
  repoRoot: string,
1916
1935
  batchId: string,
1917
- pauseSignal: { paused: boolean },
1936
+ pauseSignal: PauseSignal,
1918
1937
  dependencyGraph: DependencyGraph,
1919
1938
  orchBranch: string,
1920
1939
  onMonitorUpdate?: MonitorUpdateCallback,
@@ -1928,6 +1947,8 @@ export async function executeWave(
1928
1947
  thinking?: string;
1929
1948
  tools?: string;
1930
1949
  excludeExtensions?: string[];
1950
+ severityLabels?: string[];
1951
+ spiral?: import("./config-schema.ts").ReviewSpiralConfig;
1931
1952
  },
1932
1953
  workerConfig?: {
1933
1954
  model?: string;
@@ -2166,6 +2187,7 @@ export async function executeWave(
2166
2187
  // ── Stage 5: Build WaveExecutionResult ───────────────────────
2167
2188
  const failedTaskIds: string[] = [];
2168
2189
  const skippedTaskIds: string[] = [];
2190
+ const pausedTaskIds: string[] = [];
2169
2191
  const succeededTaskIds: string[] = [];
2170
2192
 
2171
2193
  for (const lr of laneResults) {
@@ -2176,6 +2198,8 @@ export async function executeWave(
2176
2198
  failedTaskIds.push(t.taskId);
2177
2199
  } else if (t.status === "skipped") {
2178
2200
  skippedTaskIds.push(t.taskId);
2201
+ } else if (t.status === "pending") {
2202
+ pausedTaskIds.push(t.taskId);
2179
2203
  }
2180
2204
  }
2181
2205
  }
@@ -2184,6 +2208,7 @@ export async function executeWave(
2184
2208
  failedTaskIds.sort();
2185
2209
  skippedTaskIds.sort();
2186
2210
  succeededTaskIds.sort();
2211
+ pausedTaskIds.sort();
2187
2212
 
2188
2213
  // Compute blocked tasks for future waves (skip-dependents policy)
2189
2214
  let blockedTaskIds: string[] = [];
@@ -2210,6 +2235,10 @@ export async function executeWave(
2210
2235
  let overallStatus: WaveExecutionResult["overallStatus"];
2211
2236
  if (policy === "stop-all" && failedTaskIds.length > 0) {
2212
2237
  overallStatus = "aborted";
2238
+ } else if (pausedTaskIds.length > 0 && failedTaskIds.length === 0) {
2239
+ // Interrupted, not succeeded: a wave with paused (pending) tasks is not
2240
+ // complete. The engine finalizes as `paused` and does not merge.
2241
+ overallStatus = succeededTaskIds.length > 0 ? "partial" : "failed";
2213
2242
  } else if (failedTaskIds.length === 0) {
2214
2243
  overallStatus = "succeeded";
2215
2244
  } else if (succeededTaskIds.length > 0) {
@@ -2225,6 +2254,7 @@ export async function executeWave(
2225
2254
  succeeded: succeededTaskIds.length,
2226
2255
  failed: failedTaskIds.length,
2227
2256
  skipped: skippedTaskIds.length,
2257
+ paused: pausedTaskIds.length,
2228
2258
  blocked: blockedTaskIds.length,
2229
2259
  elapsed: `${elapsedSec}s`,
2230
2260
  stoppedEarly,
@@ -2238,6 +2268,7 @@ export async function executeWave(
2238
2268
  policyApplied: policy,
2239
2269
  stoppedEarly,
2240
2270
  failedTaskIds,
2271
+ pausedTaskIds,
2241
2272
  skippedTaskIds,
2242
2273
  succeededTaskIds,
2243
2274
  blockedTaskIds,
@@ -2269,7 +2300,7 @@ export async function executeWave(
2269
2300
  export async function executeWithStopAll(
2270
2301
  lanes: AllocatedLane[],
2271
2302
  lanePromises: Promise<LaneExecutionResult>[],
2272
- pauseSignal: { paused: boolean },
2303
+ pauseSignal: PauseSignal,
2273
2304
  waveIndex: number,
2274
2305
  ): Promise<LaneExecutionResult[]> {
2275
2306
  // Track results as they complete
@@ -2290,6 +2321,7 @@ export async function executeWithStopAll(
2290
2321
  // First failure detected — trigger stop-all
2291
2322
  abortTriggered = true;
2292
2323
  pauseSignal.paused = true;
2324
+ pauseSignal.cause = "abort";
2293
2325
 
2294
2326
  // Determine which task failed first for logging
2295
2327
  const firstFailed = result.tasks
@@ -2325,6 +2357,7 @@ export async function executeWithStopAll(
2325
2357
  if (!abortTriggered) {
2326
2358
  abortTriggered = true;
2327
2359
  pauseSignal.paused = true;
2360
+ pauseSignal.cause = "abort";
2328
2361
  execLog(
2329
2362
  "wave",
2330
2363
  `W${waveIndex}`,
@@ -2758,6 +2791,8 @@ export function buildReviewerEnv(
2758
2791
  thinking?: string;
2759
2792
  tools?: string;
2760
2793
  excludeExtensions?: string[];
2794
+ severityLabels?: string[];
2795
+ spiral?: import("./config-schema.ts").ReviewSpiralConfig;
2761
2796
  } | null,
2762
2797
  ): Record<string, string> {
2763
2798
  const env: Record<string, string> = {};
@@ -2768,6 +2803,17 @@ export function buildReviewerEnv(
2768
2803
  if (reviewerConfig?.excludeExtensions && reviewerConfig.excludeExtensions.length > 0) {
2769
2804
  env.TASKPLANE_REVIEWER_EXCLUDE_EXTENSIONS = JSON.stringify(reviewerConfig.excludeExtensions);
2770
2805
  }
2806
+ // Review-boundary notifications: forward the severity vocabulary + spiral
2807
+ // tuning as one JSON blob so the lane-runner can analyze reviews and detect
2808
+ // spirals. Absent fields fall back to lane-runner defaults.
2809
+ const analysis: Record<string, unknown> = {};
2810
+ if (reviewerConfig?.severityLabels && reviewerConfig.severityLabels.length > 0) {
2811
+ analysis.severityLabels = reviewerConfig.severityLabels;
2812
+ }
2813
+ if (reviewerConfig?.spiral) analysis.spiral = reviewerConfig.spiral;
2814
+ if (Object.keys(analysis).length > 0) {
2815
+ env.TASKPLANE_REVIEW_ANALYSIS = JSON.stringify(analysis);
2816
+ }
2771
2817
  return env;
2772
2818
  }
2773
2819
 
@@ -2785,12 +2831,21 @@ export function buildWorkerEnv(
2785
2831
  thinking?: string;
2786
2832
  tools?: string;
2787
2833
  excludeExtensions?: string[];
2834
+ exitInterceptTimeoutSec?: number;
2788
2835
  } | null,
2789
2836
  ): Record<string, string> {
2790
2837
  const env: Record<string, string> = {};
2791
2838
  if (workerConfig?.model) env.TASKPLANE_WORKER_MODEL = workerConfig.model;
2792
2839
  if (workerConfig?.thinking) env.TASKPLANE_WORKER_THINKING = workerConfig.thinking;
2793
2840
  if (workerConfig?.tools) env.TASKPLANE_WORKER_TOOLS = workerConfig.tools;
2841
+ if (
2842
+ typeof workerConfig?.exitInterceptTimeoutSec === "number" &&
2843
+ Number.isFinite(workerConfig.exitInterceptTimeoutSec)
2844
+ ) {
2845
+ env.TASKPLANE_EXIT_INTERCEPT_TIMEOUT_SEC = String(
2846
+ Math.min(1800, Math.max(15, Math.round(workerConfig.exitInterceptTimeoutSec))),
2847
+ );
2848
+ }
2794
2849
 
2795
2850
  return env;
2796
2851
  }
@@ -2813,7 +2868,7 @@ export async function executeLaneV2(
2813
2868
  lane: AllocatedLane,
2814
2869
  config: OrchestratorConfig,
2815
2870
  repoRoot: string,
2816
- pauseSignal: { paused: boolean },
2871
+ pauseSignal: PauseSignal,
2817
2872
  workspaceRoot?: string,
2818
2873
  isWorkspaceMode?: boolean,
2819
2874
  extraEnvVars?: Record<string, string>,
@@ -2892,12 +2947,14 @@ export async function executeLaneV2(
2892
2947
  for (const task of lane.tasks) {
2893
2948
  const taskSegmentId = task.task.activeSegmentId ?? null;
2894
2949
  if (shouldSkipRemaining || pauseSignal.paused) {
2950
+ // A pause leaves the remaining lane tasks PENDING (they never ran); only a
2951
+ // prior failure in the lane skips them.
2895
2952
  const reason = pauseSignal.paused
2896
- ? "Skipped due to pause signal"
2953
+ ? "Paused by user"
2897
2954
  : "Skipped due to prior task failure in lane";
2898
2955
  outcomes.push({
2899
2956
  taskId: task.taskId,
2900
- status: "skipped",
2957
+ status: pauseSignal.paused && !shouldSkipRemaining ? "pending" : "skipped",
2901
2958
  segmentId: taskSegmentId,
2902
2959
  startTime: null,
2903
2960
  endTime: null,
@@ -2920,6 +2977,24 @@ export async function executeLaneV2(
2920
2977
  ? (rawAutonomy as LaneRunnerConfig["supervisorAutonomy"])
2921
2978
  : "autonomous";
2922
2979
 
2980
+ // Review-boundary notifications: parse the severity vocabulary + spiral
2981
+ // tuning forwarded by buildReviewerEnv (best-effort; lane-runner applies
2982
+ // defaults when absent or unparseable).
2983
+ let reviewSeverityLabels: string[] | undefined;
2984
+ let reviewSpiral: import("./config-schema.ts").ReviewSpiralConfig | undefined;
2985
+ if (extraEnvVars?.TASKPLANE_REVIEW_ANALYSIS) {
2986
+ try {
2987
+ const parsed = JSON.parse(extraEnvVars.TASKPLANE_REVIEW_ANALYSIS) as {
2988
+ severityLabels?: string[];
2989
+ spiral?: import("./config-schema.ts").ReviewSpiralConfig;
2990
+ };
2991
+ if (Array.isArray(parsed.severityLabels)) reviewSeverityLabels = parsed.severityLabels;
2992
+ if (parsed.spiral && typeof parsed.spiral === "object") reviewSpiral = parsed.spiral;
2993
+ } catch {
2994
+ /* best effort — fall back to lane-runner defaults */
2995
+ }
2996
+ }
2997
+
2923
2998
  const laneRunnerConfig: LaneRunnerConfig = {
2924
2999
  batchId,
2925
3000
  agentIdPrefix,
@@ -2928,12 +3003,18 @@ export async function executeLaneV2(
2928
3003
  branch: lane.branch,
2929
3004
  repoId: lane.repoId ?? "default",
2930
3005
  stateRoot,
3006
+ reviewSeverityLabels,
3007
+ reviewSpiral,
2931
3008
  workerModel: extraEnvVars?.TASKPLANE_WORKER_MODEL || "",
2932
3009
  // TP-184: This is the user-tools default. Engine bridge tools are NOT
2933
3010
  // added here — buildWorkerToolsAllowlist() at the lane-runner spawn
2934
3011
  // site appends ENGINE_BRIDGE_TOOLS exactly once, regardless of source.
2935
3012
  workerTools: extraEnvVars?.TASKPLANE_WORKER_TOOLS || DEFAULT_WORKER_USER_TOOLS,
2936
3013
  workerThinking: extraEnvVars?.TASKPLANE_WORKER_THINKING || "",
3014
+ exitInterceptTimeoutSec: (() => {
3015
+ const n = Number.parseInt(extraEnvVars?.TASKPLANE_EXIT_INTERCEPT_TIMEOUT_SEC ?? "", 10);
3016
+ return Number.isFinite(n) && n >= 15 ? Math.min(1800, n) : 60;
3017
+ })(),
2937
3018
  workerSystemPrompt,
2938
3019
  workerSegmentPrompt,
2939
3020
  reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",