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.
@@ -12,10 +12,16 @@ import {
12
12
  resolveDisplayWaveNumber,
13
13
  buildSpawnFailureAlertExtras,
14
14
  } from "./engine.ts";
15
+ import {
16
+ advanceActiveSegment,
17
+ applyReExecutionOutcomeToSegments,
18
+ taskSegmentsAllSucceeded,
19
+ } from "./segment-recovery.ts";
15
20
  import {
16
21
  buildReviewerEnv,
17
22
  buildWorkerEnv,
18
23
  buildWorkerExcludeEnv,
24
+ batchTaskScope,
19
25
  computeTransitiveDependents,
20
26
  execLog,
21
27
  executeLaneV2,
@@ -31,23 +37,99 @@ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./proces
31
37
  * Per Runtime V2 spec §7.3: detect + terminate + rehydrate.
32
38
  * Prevents duplicate concurrent agents for the same lane/task on resume.
33
39
  */
34
- function terminateAliveV2Agents(stateRoot: string, batchId: string, sessionName: string): void {
40
+ /** #631: how long to wait for SIGTERM, then SIGKILL, before declaring termination unconfirmed. */
41
+ const TERMINATE_GRACE_MS = 5_000;
42
+ const TERMINATE_KILL_MS = 3_000;
43
+ const TERMINATE_POLL_MS = 200;
44
+
45
+ function sleep(ms: number): Promise<void> {
46
+ return new Promise((r) => setTimeout(r, ms));
47
+ }
48
+
49
+ async function waitForExit(pids: number[], timeoutMs: number): Promise<number[]> {
50
+ const deadline = Date.now() + timeoutMs;
51
+ let survivors = pids.filter((pid) => isProcessAlive(pid));
52
+ while (survivors.length > 0 && Date.now() < deadline) {
53
+ await sleep(TERMINATE_POLL_MS);
54
+ survivors = survivors.filter((pid) => isProcessAlive(pid));
55
+ }
56
+ return survivors;
57
+ }
58
+
59
+ /**
60
+ * Terminate the lane's still-alive V2 agents (worker/reviewer) before this
61
+ * resume re-executes the lane in the SAME worktree.
62
+ *
63
+ * #631: termination is VERIFIED, not fire-and-forget. SIGTERM → bounded wait →
64
+ * SIGKILL → bounded wait. If any agent is still alive after that, THROW: a
65
+ * signal-resistant or permission-protected worker would otherwise keep writing
66
+ * the worktree alongside its replacement. The callers' existing catch blocks
67
+ * mark the task failed with this reason instead of re-executing.
68
+ */
69
+ async function terminateAliveV2Agents(
70
+ stateRoot: string,
71
+ batchId: string,
72
+ sessionName: string,
73
+ ): Promise<void> {
35
74
  const registry = readRegistrySnapshot(stateRoot, batchId);
36
75
  if (!registry) return;
76
+ const targets: Array<{ key: string; pid: number }> = [];
37
77
  for (const suffix of ["-worker", "-reviewer", ""]) {
38
78
  const key = `${sessionName}${suffix}`;
39
79
  const manifest = registry.agents[key];
40
80
  if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
81
+ targets.push({ key, pid: manifest.pid });
82
+ }
83
+ }
84
+ if (targets.length === 0) return;
85
+
86
+ for (const t of targets) {
87
+ try {
88
+ process.kill(t.pid, "SIGTERM");
89
+ execLog("resume", t.key, `SIGTERM sent to alive V2 agent (PID ${t.pid}) before re-execute`);
90
+ } catch {
91
+ /* already dead or not signalable — verified below */
92
+ }
93
+ }
94
+ let survivors = await waitForExit(
95
+ targets.map((t) => t.pid),
96
+ TERMINATE_GRACE_MS,
97
+ );
98
+ if (survivors.length > 0) {
99
+ for (const pid of survivors) {
41
100
  try {
42
- process.kill(manifest.pid, "SIGTERM");
43
- execLog("resume", key, `terminated alive V2 agent (PID ${manifest.pid}) before re-execute`);
101
+ process.kill(pid, "SIGKILL");
102
+ execLog(
103
+ "resume",
104
+ sessionName,
105
+ `SIGKILL sent to V2 agent PID ${pid} (did not exit within ${TERMINATE_GRACE_MS}ms)`,
106
+ );
44
107
  } catch {
45
- /* already dead */
108
+ /* verified below */
46
109
  }
47
110
  }
111
+ survivors = await waitForExit(survivors, TERMINATE_KILL_MS);
48
112
  }
113
+ if (survivors.length > 0) {
114
+ const list = targets
115
+ .filter((t) => survivors.includes(t.pid))
116
+ .map((t) => `${t.key} (PID ${t.pid})`)
117
+ .join(", ");
118
+ throw new Error(
119
+ `cannot confirm termination of ${list} after SIGTERM+SIGKILL — refusing to re-execute the lane alongside a live agent (#631). ` +
120
+ `Terminate the process manually and resume again.`,
121
+ );
122
+ }
123
+ execLog(
124
+ "resume",
125
+ sessionName,
126
+ `verified termination of ${targets.length} V2 agent(s) before re-execute`,
127
+ {
128
+ pids: targets.map((t) => t.pid).join(","),
129
+ },
130
+ );
49
131
  }
50
- import { getCurrentBranch, runGit } from "./git.ts";
132
+ import { getCurrentBranch, runGit, describeOrchBranchStateAcrossRepos } from "./git.ts";
51
133
  import { mergeWaveByRepo } from "./merge.ts";
52
134
  import {
53
135
  applyMergeRetryLoop,
@@ -128,6 +210,41 @@ import {
128
210
  * @param workspaceConfig - Workspace configuration (null in repo mode)
129
211
  * @returns Array of unique absolute repo root paths
130
212
  */
213
+ /**
214
+ * Catch-up merge eligibility (resume step 8d), pure: which persisted lanes hold
215
+ * succeeded-but-unmerged work? A lane qualifies when ALL its tasks are
216
+ * `succeeded`, none were re-executed in this resume pass, and the (LAST) wave
217
+ * of every task has no `succeeded` merge record. Branch existence is checked by
218
+ * the caller (needs git).
219
+ */
220
+ export function selectCatchUpLanes(
221
+ persistedState: Pick<PersistedBatchState, "lanes" | "tasks" | "mergeResults">,
222
+ wavePlan: string[][],
223
+ reExecutedTaskIds: ReadonlySet<string>,
224
+ ): PersistedBatchState["lanes"] {
225
+ // LATEST merge status per wave (the same rule computeResumePoint uses) — an
226
+ // older succeeded record must not mask a later failure (success → failure
227
+ // while a third task was still pending left succeeded work permanently
228
+ // unmerged).
229
+ const waveMerged = (w: number) =>
230
+ getMergeStatusForWave(persistedState.mergeResults ?? [], w) === "succeeded";
231
+ const waveOfTask = new Map<string, number>();
232
+ wavePlan.forEach((wave, i) => {
233
+ for (const id of wave) waveOfTask.set(id, i);
234
+ });
235
+ const succeededById = new Map(
236
+ persistedState.tasks.map((t) => [t.taskId, t.status === "succeeded"]),
237
+ );
238
+ return persistedState.lanes.filter((laneRecord) => {
239
+ if (laneRecord.taskIds.length === 0) return false;
240
+ if (!laneRecord.taskIds.every((id) => succeededById.get(id))) return false;
241
+ if (laneRecord.taskIds.some((id) => reExecutedTaskIds.has(id))) return false;
242
+ const waves = laneRecord.taskIds.map((id) => waveOfTask.get(id));
243
+ if (waves.some((w) => w === undefined || waveMerged(w))) return false;
244
+ return true;
245
+ });
246
+ }
247
+
131
248
  export function collectRepoRoots(
132
249
  persistedState: PersistedBatchState,
133
250
  defaultRepoRoot: string,
@@ -1267,6 +1384,22 @@ export async function resumeOrchBatch(
1267
1384
  batchState.phase = "idle";
1268
1385
  return;
1269
1386
  }
1387
+ // #631: the parent gated engine ownership for a SPECIFIC batch. If the
1388
+ // deterministic reconstruction picked a different one, refuse BEFORE any
1389
+ // write — persisting reconstructed state for an ungated batch is itself an
1390
+ // unauthorized recovery mutation.
1391
+ if (batchState.batchId && reconstruction.batchId !== batchState.batchId) {
1392
+ const msg =
1393
+ `resume target mismatch: this engine was authorized for batch ${batchState.batchId} but reconstruction ` +
1394
+ `selected ${reconstruction.batchId}. Refusing without writing (ownership of ${reconstruction.batchId} was never verified).`;
1395
+ execLog("resume", batchState.batchId, msg);
1396
+ onNotify(`❌ ${msg}`, "error");
1397
+ batchState.phase = "failed";
1398
+ batchState.endedAt = Date.now();
1399
+ batchState.errors.push(msg);
1400
+ return;
1401
+ }
1402
+
1270
1403
  // Successful reconstruction: persist so the rest of resumeOrchBatch
1271
1404
  // proceeds with a normal on-disk batch-state.json picture.
1272
1405
  onNotify(
@@ -1291,6 +1424,22 @@ export async function resumeOrchBatch(
1291
1424
  }
1292
1425
 
1293
1426
  // ── 2. Check eligibility ─────────────────────────────────────
1427
+ // #631: the parent gated engine ownership against a specific target and
1428
+ // published this engine's identity for it. Never resume a DIFFERENT batch
1429
+ // than the one authorized (e.g. reconstruction selecting another runtime
1430
+ // dir) — that batch's engine was never checked.
1431
+ if (batchState.batchId && persistedState.batchId !== batchState.batchId) {
1432
+ const msg =
1433
+ `resume target mismatch: this engine was authorized for batch ${batchState.batchId} but the ` +
1434
+ `persisted/reconstructed state is ${persistedState.batchId}. Refusing (ownership of ${persistedState.batchId} was never verified).`;
1435
+ execLog("resume", batchState.batchId, msg);
1436
+ onNotify(`❌ ${msg}`, "error");
1437
+ batchState.phase = "failed";
1438
+ batchState.endedAt = Date.now();
1439
+ batchState.errors.push(msg);
1440
+ return;
1441
+ }
1442
+
1294
1443
  const eligibility = checkResumeEligibility(persistedState, force);
1295
1444
  if (!eligibility.eligible) {
1296
1445
  onNotify(
@@ -1557,6 +1706,23 @@ export async function resumeOrchBatch(
1557
1706
  // v3: Carry forward resilience and diagnostics from persisted state
1558
1707
  batchState.resilience = persistedState.resilience;
1559
1708
  batchState.diagnostics = persistedState.diagnostics;
1709
+ // Carry forward merge HISTORY. The runtime starts fresh on every resume, so
1710
+ // without this the next checkpoint serialized only the merges appended by
1711
+ // this pass and earlier waves' records were dropped — a later resume then
1712
+ // re-flagged already-merged waves for retry (and catch-up re-selected their
1713
+ // lanes). Persisted records are 0-based; runtime is 1-based (converted once
1714
+ // here, back once in serializeBatchState). Order preserved (latest-wins reads).
1715
+ batchState.mergeResults = (persistedState.mergeResults ?? []).map(
1716
+ (mr) =>
1717
+ ({
1718
+ waveIndex: mr.waveIndex + 1,
1719
+ status: mr.status,
1720
+ laneResults: [],
1721
+ failedLane: mr.failedLane,
1722
+ failureReason: mr.failureReason,
1723
+ totalDurationMs: 0,
1724
+ }) as MergeWaveResult,
1725
+ );
1560
1726
  // v4: Carry forward segment records (including dynamically expanded segments)
1561
1727
  batchState.segments = [...(persistedState.segments ?? [])];
1562
1728
  // Carry forward unknown fields for roundtrip preservation
@@ -1682,8 +1848,8 @@ export async function resumeOrchBatch(
1682
1848
  execLog("resume", task.taskId, "V2 reconnect: terminate + rehydrate via lane-runner", {
1683
1849
  repoId: laneRecord.repoId ?? "(default)",
1684
1850
  });
1685
- terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.laneSessionId);
1686
1851
  try {
1852
+ await terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.laneSessionId);
1687
1853
  const laneResult = await executeLaneV2(
1688
1854
  lane,
1689
1855
  orchConfig,
@@ -1693,6 +1859,7 @@ export async function resumeOrchBatch(
1693
1859
  !!workspaceConfig,
1694
1860
  {
1695
1861
  ORCH_BATCH_ID: batchState.batchId,
1862
+ ...buildWorkerEnv(runnerConfig.worker),
1696
1863
  ...buildReviewerEnv(runnerConfig.reviewer),
1697
1864
  ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions),
1698
1865
  },
@@ -1729,7 +1896,14 @@ export async function resumeOrchBatch(
1729
1896
 
1730
1897
  // ── 8b. Handle re-execute tasks (dead session + existing worktree) ──
1731
1898
  const reExecuteTasks = reconciledTasks.filter((t) => t.action === "re-execute");
1899
+ // Worktree preservation flag (declared before 8c/8d so a merge failure on
1900
+ // resume can set it; consumed by terminal cleanup).
1901
+ let preserveWorktreesForResume = false;
1732
1902
  const reExecuteFinalStatus = new Map<string, LaneTaskStatus>();
1903
+ // #629: the REAL lane outcome of a re-executed task (telemetry, exit
1904
+ // diagnostic, timestamps). Previously discarded — the synthesized outcome
1905
+ // below carried the persisted (already-cleared) diagnostic instead.
1906
+ const reExecuteOutcome = new Map<string, LaneTaskOutcome>();
1733
1907
  const reExecAllocatedLanes: AllocatedLane[] = [];
1734
1908
 
1735
1909
  if (reExecuteTasks.length > 0) {
@@ -1775,7 +1949,7 @@ export async function resumeOrchBatch(
1775
1949
 
1776
1950
  try {
1777
1951
  // TP-112: Runtime V2 re-execution.
1778
- terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.laneSessionId);
1952
+ await terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.laneSessionId);
1779
1953
  const laneResult = await executeLaneV2(
1780
1954
  lane,
1781
1955
  orchConfig,
@@ -1785,6 +1959,7 @@ export async function resumeOrchBatch(
1785
1959
  !!workspaceConfig,
1786
1960
  {
1787
1961
  ORCH_BATCH_ID: batchState.batchId,
1962
+ ...buildWorkerEnv(runnerConfig.worker),
1788
1963
  ...buildReviewerEnv(runnerConfig.reviewer),
1789
1964
  ...buildWorkerExcludeEnv(runnerConfig.workerExcludeExtensions),
1790
1965
  },
@@ -1796,8 +1971,80 @@ export async function resumeOrchBatch(
1796
1971
  exitReason: taskResult?.exitReason ?? "V2 re-execution completed",
1797
1972
  doneFileFound: taskResult?.doneFileFound ?? false,
1798
1973
  };
1974
+ // #629: a PAUSE during re-execution surfaces as `skipped` from
1975
+ // executeLaneV2. That is not a terminal outcome: leave the task and
1976
+ // its segments pending/re-executable (the previous code marked the
1977
+ // task failed; treating the real outcome verbatim would make it an
1978
+ // unretryable `skipped`). Nothing else changes; the next resume
1979
+ // reconciles it again.
1980
+ if (
1981
+ pollResult.status === "pending" ||
1982
+ (pollResult.status === "skipped" && /paused/i.test(pollResult.exitReason))
1983
+ ) {
1984
+ reExecuteFinalStatus.set(task.taskId, "pending");
1985
+ execLog(
1986
+ "resume",
1987
+ task.taskId,
1988
+ "re-execution paused — task remains pending for the next resume",
1989
+ );
1990
+ continue;
1991
+ }
1992
+ if (taskResult) reExecuteOutcome.set(task.taskId, taskResult);
1993
+
1994
+ // #629: keep SEGMENT authority — transition the EXECUTED segment record
1995
+ // to the re-execution result. Re-execution runs the unit built from the
1996
+ // task's activeSegmentId, i.e. ONE segment (segmentId null = whole
1997
+ // single-segment/legacy task). Without this a successful retry persisted
1998
+ // task=succeeded / segment=pending and the next resume normalized the
1999
+ // task straight back to pending.
2000
+ const segFinal: "succeeded" | "failed" =
2001
+ pollResult.status === "succeeded" ? "succeeded" : "failed";
2002
+ const executedSegmentId =
2003
+ taskResult?.segmentId ??
2004
+ persistedState.tasks.find((t) => t.taskId === task.taskId)?.activeSegmentId ??
2005
+ null;
2006
+ const touchedSegments = applyReExecutionOutcomeToSegments(
2007
+ batchState.segments,
2008
+ task.taskId,
2009
+ segFinal,
2010
+ {
2011
+ startTime: taskResult?.startTime,
2012
+ endTime: taskResult?.endTime,
2013
+ exitReason: pollResult.exitReason,
2014
+ exitDiagnostic: taskResult?.exitDiagnostic,
2015
+ },
2016
+ executedSegmentId,
2017
+ );
2018
+ if (touchedSegments.length > 0) {
2019
+ execLog("resume", task.taskId, `re-execution: segment records → ${segFinal}`, {
2020
+ segments: touchedSegments.join(","),
2021
+ executedSegmentId: executedSegmentId ?? "(whole task)",
2022
+ });
2023
+ }
1799
2024
 
1800
- if (pollResult.status === "succeeded") {
2025
+ // Task completion is derived from the segment FRONTIER, not from one
2026
+ // segment's success: a non-final segment succeeding leaves the task
2027
+ // pending with downstream segments still to run.
2028
+ const frontierComplete = taskSegmentsAllSucceeded(batchState.segments, task.taskId);
2029
+ if (pollResult.status === "succeeded" && frontierComplete === false) {
2030
+ reExecuteFinalStatus.set(task.taskId, "pending");
2031
+ reExecuteOutcome.delete(task.taskId); // not a task-level outcome yet
2032
+ reExecuteTaskSet.delete(task.taskId);
2033
+ reExecAllocatedLanes.push(lane);
2034
+ // Advance the frontier so the next execution runs the NEXT segment (on
2035
+ // both the persisted record and the parsed task the wave loop will use).
2036
+ const nextSeg = advanceActiveSegment(
2037
+ { tasks: persistedState.tasks, segments: batchState.segments } as never,
2038
+ task.taskId,
2039
+ );
2040
+ const parsedForFrontier = discovery.pending.get(task.taskId);
2041
+ if (parsedForFrontier) parsedForFrontier.activeSegmentId = nextSeg;
2042
+ execLog(
2043
+ "resume",
2044
+ task.taskId,
2045
+ `re-executed segment ${executedSegmentId} succeeded — task has further segments pending`,
2046
+ );
2047
+ } else if (pollResult.status === "succeeded") {
1801
2048
  reExecuteFinalStatus.set(task.taskId, "succeeded");
1802
2049
  completedTaskSet.add(task.taskId);
1803
2050
  failedTaskSet.delete(task.taskId);
@@ -1825,6 +2072,9 @@ export async function resumeOrchBatch(
1825
2072
  batchState.failedTasks++;
1826
2073
  const msg = err instanceof Error ? err.message : String(err);
1827
2074
  execLog("resume", task.taskId, `re-execution error: ${msg}`);
2075
+ applyReExecutionOutcomeToSegments(batchState.segments, task.taskId, "failed", {
2076
+ exitReason: `re-execution error: ${msg}`,
2077
+ });
1828
2078
  }
1829
2079
  }
1830
2080
  }
@@ -1919,13 +2169,206 @@ export async function resumeOrchBatch(
1919
2169
  }
1920
2170
  }
1921
2171
  } else {
2172
+ // Fail closed (mirrors 8d): a re-executed task that succeeded but whose
2173
+ // branch did not merge must not let the batch proceed to a state that
2174
+ // reads as complete with its work unmerged. Pause (resumable), preserve
2175
+ // worktrees; the next resume re-executes nothing for it (it is succeeded)
2176
+ // but 8d's catch-up merge picks its lane up.
1922
2177
  onNotify(
1923
- `⚠️ Re-executed branch merge ${reExecMergeResult.status}: ${reExecMergeResult.failureReason || "unknown"}`,
2178
+ `⚠️ Re-executed branch merge ${reExecMergeResult.status}: ${reExecMergeResult.failureReason || "unknown"} — batch paused with worktrees preserved; fix the conflict and orch_resume() to retry the merge.`,
1924
2179
  "warning",
1925
2180
  );
2181
+ batchState.pauseSignal.paused = true;
2182
+ batchState.pauseSignal.cause = "merge-failure";
2183
+ preserveWorktreesForResume = true;
2184
+ emitAlert({
2185
+ category: "merge-failure",
2186
+ summary:
2187
+ `🔴 Merge of re-executed task(s) ${succeededReExecTaskIds.join(", ")} failed on resume: ${reExecMergeResult.failureReason || "unknown"}
2188
+ ` +
2189
+ ` The work remains on the lane branch(es); the batch is paused with worktrees preserved.
2190
+ ` +
2191
+ ` Resolve the conflict, then orch_resume() — the catch-up merge re-runs automatically.`,
2192
+ context: {
2193
+ batchProgress: buildBatchProgressSnapshot(batchState),
2194
+ mergeError: reExecMergeResult.failureReason ?? undefined,
2195
+ },
2196
+ });
1926
2197
  }
1927
2198
 
1928
- batchState.mergeResults.push(reExecMergeResult);
2199
+ // Attribute the outcome to the ACTUAL original wave(s) of the re-executed
2200
+ // tasks (runtime 1-indexed), not the -1 sentinel: persistence clamps the
2201
+ // sentinel to wave 0, which mis-attributed a second-wave merge failure to
2202
+ // wave 0 and let the real wave keep an older success record.
2203
+ // (LAST wave per task — the same mapping selectCatchUpLanes uses for a
2204
+ // multi-segment task's lane.)
2205
+ const lastWaveOf = new Map<string, number>();
2206
+ runtimeWavePlan.forEach((wave, i) => {
2207
+ for (const id of wave) lastWaveOf.set(id, i);
2208
+ });
2209
+ const reExecWaves = new Set<number>();
2210
+ for (const id of succeededReExecTaskIds) {
2211
+ const w = lastWaveOf.get(id);
2212
+ if (w !== undefined) reExecWaves.add(w);
2213
+ }
2214
+ if (reExecWaves.size === 0) {
2215
+ batchState.mergeResults.push(reExecMergeResult);
2216
+ } else {
2217
+ for (const w of reExecWaves) {
2218
+ batchState.mergeResults.push({ ...reExecMergeResult, waveIndex: w + 1 });
2219
+ }
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ // ── 8d. Catch-up merge: succeeded-but-unmerged lane work ─────
2225
+ // A pause (or crash) that lands DURING a wave can leave tasks that succeeded
2226
+ // with their lane branches never merged — the wave's merge step did not run.
2227
+ // On resume the wave loop only merges what it re-executes, so that work was
2228
+ // silently dropped (Sage review of the owned-batch pause fix). Merge every
2229
+ // persisted lane whose tasks are all succeeded and whose wave has no
2230
+ // successful merge record. Idempotent: an already-merged branch is a no-op.
2231
+ // Skipped entirely when 8c already raised a merge-failure pause: a successful
2232
+ // subset catch-up must never certify a wave whose other lane work (the failed
2233
+ // 8c merge) remains unmerged — the next resume retries BOTH via this step.
2234
+ if (batchState.pauseSignal.cause !== "merge-failure") {
2235
+ const waveOfTask = new Map<string, number>();
2236
+ runtimeWavePlan.forEach((wave, i) => {
2237
+ for (const id of wave) waveOfTask.set(id, i);
2238
+ });
2239
+ const catchUpLanes: AllocatedLane[] = [];
2240
+ for (const laneRecord of selectCatchUpLanes(
2241
+ persistedState,
2242
+ runtimeWavePlan,
2243
+ new Set(reExecuteFinalStatus.keys()),
2244
+ )) {
2245
+ const laneRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
2246
+ if (
2247
+ !runGit(["rev-parse", "--verify", "--quiet", `refs/heads/${laneRecord.branch}`], laneRepoRoot)
2248
+ .ok
2249
+ ) {
2250
+ continue; // branch gone — nothing to merge
2251
+ }
2252
+ catchUpLanes.push({
2253
+ laneNumber: laneRecord.laneNumber,
2254
+ laneId: laneRecord.laneId,
2255
+ laneSessionId: laneRecord.laneSessionId,
2256
+ worktreePath: laneRecord.worktreePath,
2257
+ branch: laneRecord.branch,
2258
+ tasks: laneRecord.taskIds
2259
+ // A task that succeeded may already be in the completed set (no ParsedTask).
2260
+ // Enrich a stub from the persisted record so merge staging can still find
2261
+ // the task folder (.DONE / STATUS / review artifacts).
2262
+ .map((id) => {
2263
+ const parsed = discovery.pending.get(id);
2264
+ if (parsed) return parsed;
2265
+ const rec = persistedState.tasks.find((t) => t.taskId === id);
2266
+ return {
2267
+ taskId: id,
2268
+ taskName: id,
2269
+ taskFolder: rec?.taskFolder ?? "",
2270
+ promptPath: rec?.taskFolder ? join(rec.taskFolder, "PROMPT.md") : "",
2271
+ fileScope: [],
2272
+ dependencies: [],
2273
+ } as unknown as ParsedTask;
2274
+ })
2275
+ .map((t) => ({ taskId: t.taskId, order: 0, task: t, estimatedMinutes: 0 })),
2276
+ strategy: "round-robin",
2277
+ estimatedLoad: 0,
2278
+ estimatedMinutes: 0,
2279
+ ...(laneRecord.repoId !== undefined ? { repoId: laneRecord.repoId } : {}),
2280
+ });
2281
+ }
2282
+ if (catchUpLanes.length > 0) {
2283
+ const ids = catchUpLanes.flatMap((l) => l.tasks.map((t) => t.taskId));
2284
+ onNotify(
2285
+ `🔀 Merging ${catchUpLanes.length} succeeded-but-unmerged lane branch(es) from the interrupted wave (${ids.join(", ")})...`,
2286
+ "info",
2287
+ );
2288
+ const CATCH_UP_WAVE_INDEX = -1;
2289
+ const synthetic: WaveExecutionResult = {
2290
+ waveIndex: CATCH_UP_WAVE_INDEX,
2291
+ startedAt: Date.now(),
2292
+ endedAt: Date.now(),
2293
+ laneResults: catchUpLanes.map((lane) => ({
2294
+ laneNumber: lane.laneNumber,
2295
+ laneId: lane.laneId,
2296
+ tasks: lane.tasks.map((t) => ({
2297
+ taskId: t.taskId,
2298
+ status: "succeeded" as LaneTaskStatus,
2299
+ startTime: Date.now(),
2300
+ endTime: Date.now(),
2301
+ exitReason: "Succeeded before pause; merged on resume",
2302
+ sessionName: lane.laneSessionId,
2303
+ doneFileFound: true,
2304
+ laneNumber: lane.laneNumber,
2305
+ })),
2306
+ overallStatus: "succeeded" as const,
2307
+ startTime: Date.now(),
2308
+ endTime: Date.now(),
2309
+ })),
2310
+ policyApplied: orchConfig.failure.on_task_failure,
2311
+ stoppedEarly: false,
2312
+ failedTaskIds: [],
2313
+ skippedTaskIds: [],
2314
+ succeededTaskIds: ids,
2315
+ blockedTaskIds: [],
2316
+ laneCount: catchUpLanes.length,
2317
+ overallStatus: "succeeded",
2318
+ finalMonitorState: null,
2319
+ allocatedLanes: catchUpLanes,
2320
+ };
2321
+ const catchUp = await mergeWaveByRepo(
2322
+ catchUpLanes,
2323
+ synthetic,
2324
+ CATCH_UP_WAVE_INDEX,
2325
+ orchConfig,
2326
+ repoRoot,
2327
+ batchState.batchId,
2328
+ batchState.orchBranch,
2329
+ workspaceConfig,
2330
+ stateRoot,
2331
+ agentRoot,
2332
+ runnerConfig.testing_commands,
2333
+ undefined,
2334
+ undefined,
2335
+ resumeBackend,
2336
+ );
2337
+ if (catchUp.status === "succeeded") {
2338
+ onNotify(`✅ Catch-up merge complete: ${catchUp.laneResults.length} lane(s) merged`, "info");
2339
+ } else {
2340
+ // Normal merge-failure handling: the batch must NOT proceed to a state
2341
+ // that can read as complete while succeeded work sits unmerged. Pause
2342
+ // (resumable), preserve worktrees, and stop before the wave loop; the
2343
+ // next resume re-runs this catch-up (it is the retry mechanism).
2344
+ onNotify(
2345
+ `⚠️ Catch-up merge ${catchUp.status}: ${catchUp.failureReason || "unknown"} — the succeeded work remains on its lane branch(es). Batch paused; fix the conflict and orch_resume() to retry.`,
2346
+ "warning",
2347
+ );
2348
+ batchState.pauseSignal.paused = true;
2349
+ batchState.pauseSignal.cause = "merge-failure";
2350
+ preserveWorktreesForResume = true;
2351
+ emitAlert({
2352
+ category: "merge-failure",
2353
+ summary:
2354
+ `🔴 Catch-up merge failed on resume for ${ids.join(", ")}: ${catchUp.failureReason || "unknown"}
2355
+ ` +
2356
+ ` The succeeded work remains on its lane branch(es); the batch is paused with worktrees preserved.
2357
+ ` +
2358
+ ` Resolve the conflict, then orch_resume() — the catch-up merge re-runs automatically.`,
2359
+ context: {
2360
+ batchProgress: buildBatchProgressSnapshot(batchState),
2361
+ mergeError: catchUp.failureReason ?? undefined,
2362
+ },
2363
+ });
2364
+ }
2365
+ // Record it against the ORIGINAL wave(s) so the wave loop's merge-retry
2366
+ // logic sees those waves as merged (or as needing retry on failure).
2367
+ for (const w of new Set(
2368
+ catchUpLanes.flatMap((l) => l.tasks.map((t) => waveOfTask.get(t.taskId) ?? 0)),
2369
+ )) {
2370
+ batchState.mergeResults.push({ ...catchUp, waveIndex: w + 1 });
2371
+ }
1929
2372
  }
1930
2373
  }
1931
2374
 
@@ -1959,6 +2402,22 @@ export async function resumeOrchBatch(
1959
2402
  const persistedTask = persistedState.tasks.find((t) => t.taskId === task.taskId);
1960
2403
  const reconnectStatus = reconnectFinalStatus.get(task.taskId);
1961
2404
  const reExecuteStatus = reExecuteFinalStatus.get(task.taskId);
2405
+ // #629: a re-executed task has a REAL outcome — use it verbatim (telemetry,
2406
+ // diagnostic, timestamps) rather than synthesizing one from stale state.
2407
+ const realOutcome = task.action === "re-execute" ? reExecuteOutcome.get(task.taskId) : undefined;
2408
+ if (realOutcome && (realOutcome.status === "succeeded" || realOutcome.status === "failed")) {
2409
+ allTaskOutcomes.push({
2410
+ ...realOutcome,
2411
+ laneNumber: realOutcome.laneNumber ?? persistedTask?.laneNumber,
2412
+ // Preserve persisted partial-progress metadata when the fresh outcome
2413
+ // did not set it (recovery metadata; the stale diagnostic is NOT restored).
2414
+ partialProgressCommits:
2415
+ realOutcome.partialProgressCommits ?? persistedTask?.partialProgressCommits,
2416
+ partialProgressBranch:
2417
+ realOutcome.partialProgressBranch ?? persistedTask?.partialProgressBranch,
2418
+ });
2419
+ continue;
2420
+ }
1962
2421
  const status =
1963
2422
  task.action === "reconnect"
1964
2423
  ? reconnectStatus || "running"
@@ -2006,7 +2465,11 @@ export async function resumeOrchBatch(
2006
2465
  // (mark-failed) or resolved during reconnect/re-execute must propagate
2007
2466
  // to their transitive dependents BEFORE the wave loop begins.
2008
2467
  if (orchConfig.failure.on_task_failure === "skip-dependents" && failedTaskSet.size > 0) {
2009
- const reconciledBlocked = computeTransitiveDependents(failedTaskSet, depGraph);
2468
+ const reconciledBlocked = computeTransitiveDependents(
2469
+ failedTaskSet,
2470
+ depGraph,
2471
+ batchTaskScope(wavePlan),
2472
+ );
2010
2473
  for (const taskId of reconciledBlocked) {
2011
2474
  batchState.blockedTaskIds.add(taskId);
2012
2475
  }
@@ -2037,7 +2500,6 @@ export async function resumeOrchBatch(
2037
2500
  // We need to execute remaining waves starting from resumeWaveIndex.
2038
2501
  // For waves where some tasks are already done, we filter them out.
2039
2502
 
2040
- let preserveWorktreesForResume = false;
2041
2503
  const persistedStatusByTaskId = new Map(
2042
2504
  persistedState.tasks.map((task) => [task.taskId, task.status] as const),
2043
2505
  );
@@ -2050,6 +2512,7 @@ export async function resumeOrchBatch(
2050
2512
  // Check pause signal
2051
2513
  if (batchState.pauseSignal.paused) {
2052
2514
  batchState.phase = "paused";
2515
+ preserveWorktreesForResume = true; // every pause exit preserves recovery worktrees
2053
2516
  persistRuntimeState(
2054
2517
  "pause-before-wave",
2055
2518
  batchState,
@@ -2403,8 +2866,12 @@ export async function resumeOrchBatch(
2403
2866
  reconnectTaskSet.delete(taskId);
2404
2867
  }
2405
2868
 
2406
- for (const blocked of waveResult.blockedTaskIds) {
2407
- batchState.blockedTaskIds.add(blocked);
2869
+ {
2870
+ // #629: scope to batch tasks (dependency graph is repo-wide)
2871
+ const scope = batchTaskScope(wavePlan);
2872
+ for (const blocked of waveResult.blockedTaskIds) {
2873
+ if (scope.has(blocked)) batchState.blockedTaskIds.add(blocked);
2874
+ }
2408
2875
  }
2409
2876
 
2410
2877
  // ── TP-076: Emit supervisor alerts for task failures ────
@@ -2480,6 +2947,38 @@ export async function resumeOrchBatch(
2480
2947
  });
2481
2948
  }
2482
2949
 
2950
+ // ── Pause finalizer (mirrors engine.ts; penster 20260906T194514) ──
2951
+ // Paused tasks are pending, never skipped; a wave with any is not complete.
2952
+ // Finalize as paused (worktrees preserved, no merge) and stop.
2953
+ {
2954
+ const pausedIds = waveResult.pausedTaskIds ?? [];
2955
+ const notAborting =
2956
+ batchState.pauseSignal.cause !== "abort" && waveResult.overallStatus !== "aborted";
2957
+ const operatorPaused = batchState.pauseSignal.paused && notAborting;
2958
+ if (notAborting && (pausedIds.length > 0 || operatorPaused)) {
2959
+ batchState.phase = "paused";
2960
+ preserveWorktreesForResume = true;
2961
+ execLog("resume", batchState.batchId, `batch paused during wave ${waveIdx + 1}`, {
2962
+ cause: batchState.pauseSignal.cause ?? "operator",
2963
+ pendingTasks: pausedIds.join(",") || "(none)",
2964
+ });
2965
+ persistRuntimeState(
2966
+ "pause-during-wave",
2967
+ batchState,
2968
+ wavePlan,
2969
+ latestAllocatedLanes,
2970
+ allTaskOutcomes,
2971
+ discovery,
2972
+ stateRoot,
2973
+ );
2974
+ onNotify(
2975
+ `⏸️ Batch paused during wave ${waveIdx + 1}: ${pausedIds.length} task(s) remain pending. Worktrees preserved. Use orch_resume() to continue.`,
2976
+ "warning",
2977
+ );
2978
+ break;
2979
+ }
2980
+ }
2981
+
2483
2982
  persistRuntimeState(
2484
2983
  "wave-execution-complete",
2485
2984
  batchState,
@@ -3140,17 +3639,32 @@ export async function resumeOrchBatch(
3140
3639
 
3141
3640
  const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
3142
3641
  if (!resetResult.success) {
3143
- try {
3144
- removeWorktree(wt, perRepoRoot);
3145
- } catch {
3146
- forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
3147
- // Track this worktree for the cleanup gate — it may still be registered
3642
+ // Track for the cleanup gate on ANY non-removal outcome: throw,
3643
+ // OR a #628 dirty refusal (refusal is NOT success — the worktree
3644
+ // still exists, preserving uncommitted work; never force-clean it).
3645
+ const trackFailedRemoval = () => {
3148
3646
  const perRepoId =
3149
3647
  perRepoRoot === repoRoot ? undefined : resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
3150
3648
  if (!failedRemovalWorktrees.has(perRepoRoot)) {
3151
3649
  failedRemovalWorktrees.set(perRepoRoot, { repoId: perRepoId, paths: [] });
3152
3650
  }
3153
3651
  failedRemovalWorktrees.get(perRepoRoot)!.paths.push(wt.path);
3652
+ };
3653
+ try {
3654
+ const rm = removeWorktree(wt, perRepoRoot);
3655
+ if (rm.refusedDirty) {
3656
+ execLog(
3657
+ "batch",
3658
+ batchState.batchId,
3659
+ `worktree removal REFUSED for lane ${wt.laneNumber}: ${rm.dirtyFileCount} uncommitted change(s) — preserve progress before cleanup (#628)`,
3660
+ { path: wt.path },
3661
+ );
3662
+ trackFailedRemoval();
3663
+ }
3664
+ } catch {
3665
+ forceCleanupWorktree(wt, perRepoRoot, batchState.batchId);
3666
+ // Track this worktree for the cleanup gate — it may still be registered
3667
+ trackFailedRemoval();
3154
3668
  }
3155
3669
  }
3156
3670
  }
@@ -3408,14 +3922,36 @@ export async function resumeOrchBatch(
3408
3922
  ? `${Math.floor(batchDurationMs / 60000)}m ${Math.round((batchDurationMs % 60000) / 1000)}s`
3409
3923
  : "unknown";
3410
3924
  if (batchState.phase === "completed" && batchState.failedTasks === 0) {
3925
+ // Report outcomes and branch state SEPARATELY and truthfully (penster
3926
+ // 20260906T194514 saw "Merged … Ready for integration" on 0/1 succeeded
3927
+ // with an empty orch branch). Never say "merged" unless the orch branch is
3928
+ // verifiably ahead of base; a failed comparison is "unknown", not "nothing".
3929
+ const branchState = describeOrchBranchStateAcrossRepos(
3930
+ batchState.orchBranch,
3931
+ batchState.baseBranch,
3932
+ encounteredRepoRoots.keys(),
3933
+ );
3934
+ const hasSuccess = batchState.succeededTasks > 0;
3935
+ const outcomeLine =
3936
+ ` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded` +
3937
+ (batchState.skippedTasks > 0 ? `, ${batchState.skippedTasks} skipped` : "") +
3938
+ "\n";
3939
+ const nextStep =
3940
+ hasSuccess && branchState.kind === "ahead"
3941
+ ? `Ready for integration. Run orch_integrate() or review first.`
3942
+ : branchState.kind === "ahead"
3943
+ ? `⚠️ No task succeeded, yet ${branchState.detail} — partial work was merged; inspect before integrating.`
3944
+ : branchState.kind === "unknown"
3945
+ ? `⚠️ Could not verify the orch branch (${branchState.detail}). Inspect before integrating.`
3946
+ : `Nothing to integrate: ${branchState.detail}.`;
3411
3947
  emitAlert({
3412
3948
  category: "batch-complete",
3413
3949
  summary:
3414
- `✅ Batch ${batchState.batchId} completed\n` +
3415
- ` ${batchState.succeededTasks}/${batchState.totalTasks} tasks succeeded\n` +
3950
+ `${hasSuccess ? "✅" : "⚠️"} Batch ${batchState.batchId} completed\n` +
3951
+ outcomeLine +
3416
3952
  ` ${batchState.taskLevelWaveCount ?? batchState.totalWaves} wave(s), duration: ${durationStr}\n` +
3417
- ` Merged to orch branch: ${batchState.orchBranch}\n\n` +
3418
- `Ready for integration. Run orch_integrate() or review first.`,
3953
+ ` Orch branch ${batchState.orchBranch}: ${branchState.detail}\n\n` +
3954
+ nextStep,
3419
3955
  context: {
3420
3956
  batchProgress: buildBatchProgressSnapshot(batchState),
3421
3957
  batchDurationMs,