taskplane 0.22.18 → 0.23.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.
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Process Registry — Runtime V2 agent lifecycle management
3
+ *
4
+ * File-backed registry that replaces TMUX session discovery as the
5
+ * authoritative source of truth for agent liveness, identity, and
6
+ * attribution.
7
+ *
8
+ * Key design rules:
9
+ * 1. Parent writes manifest BEFORE child is considered visible.
10
+ * 2. Parent updates manifest on every status transition.
11
+ * 3. Operator tools read the registry, not TMUX.
12
+ * 4. Resume/cleanup validates pid + startedAt for orphan detection.
13
+ *
14
+ * File locations:
15
+ * .pi/runtime/{batchId}/registry.json — batch-level snapshot
16
+ * .pi/runtime/{batchId}/agents/{agentId}/manifest.json — per-agent
17
+ *
18
+ * @module taskplane/process-registry
19
+ * @since TP-104
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, appendFileSync, renameSync } from "fs";
23
+ import { join, dirname } from "path";
24
+
25
+ import {
26
+ TERMINAL_AGENT_STATUSES,
27
+ runtimeRoot,
28
+ runtimeAgentDir,
29
+ runtimeManifestPath,
30
+ runtimeRegistryPath,
31
+ runtimeAgentEventsPath,
32
+ runtimeLaneSnapshotPath,
33
+ validateAgentManifest,
34
+ type RuntimeAgentId,
35
+ type RuntimeAgentManifest,
36
+ type RuntimeAgentRole,
37
+ type RuntimeAgentStatus,
38
+ type RuntimeRegistry,
39
+ type PacketPaths,
40
+ } from "./types.ts";
41
+
42
+ // ── Manifest Lifecycle ───────────────────────────────────────────────
43
+
44
+ /**
45
+ * Write or update an agent manifest atomically.
46
+ *
47
+ * Uses write-to-temp + rename for crash safety. Creates parent
48
+ * directories if they don't exist.
49
+ *
50
+ * @since TP-104
51
+ */
52
+ export function writeManifest(stateRoot: string, manifest: RuntimeAgentManifest): void {
53
+ const dir = runtimeAgentDir(stateRoot, manifest.batchId, manifest.agentId);
54
+ mkdirSync(dir, { recursive: true });
55
+ const path = runtimeManifestPath(stateRoot, manifest.batchId, manifest.agentId);
56
+ const tmpPath = path + ".tmp";
57
+ writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
58
+ // Atomic rename (same directory = safe on all platforms)
59
+ renameSync(tmpPath, path);
60
+ }
61
+
62
+ /**
63
+ * Read an agent manifest. Returns null if not found or malformed.
64
+ *
65
+ * @since TP-104
66
+ */
67
+ export function readManifest(stateRoot: string, batchId: string, agentId: RuntimeAgentId): RuntimeAgentManifest | null {
68
+ const path = runtimeManifestPath(stateRoot, batchId, agentId);
69
+ if (!existsSync(path)) return null;
70
+ try {
71
+ const raw = readFileSync(path, "utf-8");
72
+ const parsed = JSON.parse(raw);
73
+ const errors = validateAgentManifest(parsed);
74
+ if (errors.length > 0) {
75
+ console.error(`[process-registry] invalid manifest ${agentId}: ${errors.join(", ")}`);
76
+ return null;
77
+ }
78
+ return parsed as RuntimeAgentManifest;
79
+ } catch (err: any) {
80
+ console.error(`[process-registry] failed to read manifest ${agentId}: ${err?.message}`);
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Update an agent's status in its manifest.
87
+ *
88
+ * Reads the current manifest, updates the status field, and writes
89
+ * it back atomically. No-op if manifest doesn't exist.
90
+ *
91
+ * @since TP-104
92
+ */
93
+ export function updateManifestStatus(
94
+ stateRoot: string,
95
+ batchId: string,
96
+ agentId: RuntimeAgentId,
97
+ status: RuntimeAgentStatus,
98
+ ): void {
99
+ const manifest = readManifest(stateRoot, batchId, agentId);
100
+ if (!manifest) return;
101
+ manifest.status = status;
102
+ writeManifest(stateRoot, manifest);
103
+ }
104
+
105
+ /**
106
+ * Create a fresh RuntimeAgentManifest with required fields.
107
+ *
108
+ * @since TP-104
109
+ */
110
+ export function createManifest(opts: {
111
+ batchId: string;
112
+ agentId: RuntimeAgentId;
113
+ role: RuntimeAgentRole;
114
+ laneNumber: number | null;
115
+ taskId: string | null;
116
+ repoId: string;
117
+ pid: number;
118
+ parentPid: number;
119
+ cwd: string;
120
+ packet: PacketPaths | null;
121
+ }): RuntimeAgentManifest {
122
+ return {
123
+ batchId: opts.batchId,
124
+ agentId: opts.agentId,
125
+ role: opts.role,
126
+ laneNumber: opts.laneNumber,
127
+ taskId: opts.taskId,
128
+ repoId: opts.repoId,
129
+ pid: opts.pid,
130
+ parentPid: opts.parentPid,
131
+ startedAt: Date.now(),
132
+ status: "spawning",
133
+ cwd: opts.cwd,
134
+ packet: opts.packet,
135
+ };
136
+ }
137
+
138
+ // ── Registry Snapshot ────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Build a registry snapshot from all agent manifests in a batch.
142
+ *
143
+ * Scans the agents/ directory under the runtime root and reads all
144
+ * valid manifests.
145
+ *
146
+ * @since TP-104
147
+ */
148
+ export function buildRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry {
149
+ const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
150
+ const agents: Record<RuntimeAgentId, RuntimeAgentManifest> = {};
151
+
152
+ if (existsSync(agentsDir)) {
153
+ try {
154
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
155
+ for (const entry of entries) {
156
+ if (!entry.isDirectory()) continue;
157
+ const agentId = entry.name;
158
+ const manifest = readManifest(stateRoot, batchId, agentId);
159
+ if (manifest) {
160
+ agents[agentId] = manifest;
161
+ }
162
+ }
163
+ } catch (err: any) {
164
+ console.error(`[process-registry] failed to scan agents dir: ${err?.message}`);
165
+ }
166
+ }
167
+
168
+ return {
169
+ batchId,
170
+ updatedAt: Date.now(),
171
+ agents,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Write the registry snapshot to disk.
177
+ *
178
+ * @since TP-104
179
+ */
180
+ export function writeRegistrySnapshot(stateRoot: string, registry: RuntimeRegistry): void {
181
+ const path = runtimeRegistryPath(stateRoot, registry.batchId);
182
+ mkdirSync(dirname(path), { recursive: true });
183
+ const tmpPath = path + ".tmp";
184
+ writeFileSync(tmpPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
185
+ renameSync(tmpPath, path);
186
+ }
187
+
188
+ /**
189
+ * Read the registry snapshot from disk. Returns null if not found.
190
+ *
191
+ * @since TP-104
192
+ */
193
+ export function readRegistrySnapshot(stateRoot: string, batchId: string): RuntimeRegistry | null {
194
+ const path = runtimeRegistryPath(stateRoot, batchId);
195
+ if (!existsSync(path)) return null;
196
+ try {
197
+ return JSON.parse(readFileSync(path, "utf-8"));
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ // ── Liveness Checks ──────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Check whether a process with the given PID is still alive.
207
+ *
208
+ * Uses `process.kill(pid, 0)` which sends no signal but checks existence.
209
+ * Returns false for PID 0, negative PIDs, and dead processes.
210
+ *
211
+ * @since TP-104
212
+ */
213
+ export function isProcessAlive(pid: number): boolean {
214
+ if (!pid || pid <= 0 || !Number.isFinite(pid)) return false;
215
+ try {
216
+ process.kill(pid, 0);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Determine if an agent is in a terminal (non-alive) state.
225
+ *
226
+ * @since TP-104
227
+ */
228
+ export function isTerminalStatus(status: RuntimeAgentStatus): boolean {
229
+ return TERMINAL_AGENT_STATUSES.has(status);
230
+ }
231
+
232
+ /**
233
+ * Get all live (non-terminal) agents from a registry snapshot.
234
+ *
235
+ * @since TP-104
236
+ */
237
+ export function getLiveAgents(registry: RuntimeRegistry): RuntimeAgentManifest[] {
238
+ return Object.values(registry.agents).filter(m => !isTerminalStatus(m.status));
239
+ }
240
+
241
+ /**
242
+ * Get all agents matching a specific role from a registry snapshot.
243
+ *
244
+ * @since TP-104
245
+ */
246
+ export function getAgentsByRole(registry: RuntimeRegistry, role: RuntimeAgentRole): RuntimeAgentManifest[] {
247
+ return Object.values(registry.agents).filter(m => m.role === role);
248
+ }
249
+
250
+ // ── Orphan Detection ─────────────────────────────────────────────────
251
+
252
+ /**
253
+ * Detect orphaned agents — manifests that claim to be running but whose
254
+ * process is no longer alive.
255
+ *
256
+ * Returns agent IDs of orphans. Caller decides whether to terminate,
257
+ * update manifest status, or log.
258
+ *
259
+ * @since TP-104
260
+ */
261
+ export function detectOrphans(registry: RuntimeRegistry): RuntimeAgentId[] {
262
+ const orphans: RuntimeAgentId[] = [];
263
+ for (const manifest of Object.values(registry.agents)) {
264
+ if (isTerminalStatus(manifest.status)) continue;
265
+ if (!isProcessAlive(manifest.pid)) {
266
+ orphans.push(manifest.agentId);
267
+ }
268
+ }
269
+ return orphans;
270
+ }
271
+
272
+ /**
273
+ * Mark detected orphans as crashed in their manifests.
274
+ *
275
+ * @since TP-104
276
+ */
277
+ export function markOrphansCrashed(stateRoot: string, batchId: string, orphanIds: RuntimeAgentId[]): void {
278
+ for (const agentId of orphanIds) {
279
+ updateManifestStatus(stateRoot, batchId, agentId, "crashed");
280
+ }
281
+ }
282
+
283
+ // ── Cleanup ──────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Remove all runtime artifacts for a batch.
287
+ *
288
+ * Best-effort: logs errors but doesn't throw.
289
+ *
290
+ * @since TP-104
291
+ */
292
+ export function cleanupBatchRuntime(stateRoot: string, batchId: string): { removed: boolean; error?: string } {
293
+ const root = runtimeRoot(stateRoot, batchId);
294
+ if (!existsSync(root)) return { removed: false };
295
+ try {
296
+ rmSync(root, { recursive: true, force: true });
297
+ return { removed: true };
298
+ } catch (err: any) {
299
+ console.error(`[process-registry] failed to cleanup batch runtime: ${err?.message}`);
300
+ return { removed: false, error: err?.message };
301
+ }
302
+ }
303
+
304
+ // ── Normalized Event Helpers ─────────────────────────────────────────
305
+
306
+ /**
307
+ * Append a normalized event to an agent's event log.
308
+ *
309
+ * Creates the events file and parent directories if they don't exist.
310
+ * Best-effort: logs errors but doesn't throw.
311
+ *
312
+ * @since TP-104
313
+ */
314
+ export function appendAgentEvent(
315
+ stateRoot: string,
316
+ batchId: string,
317
+ agentId: RuntimeAgentId,
318
+ event: Record<string, unknown>,
319
+ ): void {
320
+ const path = runtimeAgentEventsPath(stateRoot, batchId, agentId);
321
+ mkdirSync(dirname(path), { recursive: true });
322
+ try {
323
+ appendFileSync(path, JSON.stringify(event) + "\n", "utf-8");
324
+ } catch (err: any) {
325
+ console.error(`[process-registry] failed to append event for ${agentId}: ${err?.message}`);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Write a lane snapshot to disk.
331
+ *
332
+ * @since TP-104
333
+ */
334
+ export function writeLaneSnapshot(
335
+ stateRoot: string,
336
+ batchId: string,
337
+ laneNumber: number,
338
+ snapshot: Record<string, unknown>,
339
+ ): void {
340
+ const path = runtimeLaneSnapshotPath(stateRoot, batchId, laneNumber);
341
+ mkdirSync(dirname(path), { recursive: true });
342
+ const tmpPath = path + ".tmp";
343
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
344
+ renameSync(tmpPath, path);
345
+ }
@@ -8,8 +8,30 @@ import { join } from "path";
8
8
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
9
9
  import { runDiscovery } from "./discovery.ts";
10
10
  import { executeOrchBatch } from "./engine.ts";
11
- import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
12
- import type { MonitorUpdateCallback } from "./execution.ts";
11
+ import { computeTransitiveDependents, execLog, executeLaneV2, executeWave, pollUntilTaskComplete, resolveCanonicalTaskPaths, spawnLaneSession, tmuxHasSession } from "./execution.ts";
12
+ import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
13
+ import { selectRuntimeBackend } from "./engine.ts";
14
+ import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
15
+
16
+ /**
17
+ * TP-112: Terminate any alive V2 agents for a lane before re-execution.
18
+ * Per Runtime V2 spec §7.3: detect + terminate + rehydrate.
19
+ * Prevents duplicate concurrent agents for the same lane/task on resume.
20
+ */
21
+ function terminateAliveV2Agents(stateRoot: string, batchId: string, sessionName: string): void {
22
+ const registry = readRegistrySnapshot(stateRoot, batchId);
23
+ if (!registry) return;
24
+ for (const suffix of ["-worker", "-reviewer", ""]) {
25
+ const key = `${sessionName}${suffix}`;
26
+ const manifest = registry.agents[key];
27
+ if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
28
+ try {
29
+ process.kill(manifest.pid, "SIGTERM");
30
+ execLog("resume", key, `terminated alive V2 agent (PID ${manifest.pid}) before re-execute`);
31
+ } catch { /* already dead */ }
32
+ }
33
+ }
34
+ }
13
35
  import { getCurrentBranch, runGit } from "./git.ts";
14
36
  import { mergeWaveByRepo } from "./merge.ts";
15
37
  import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
@@ -851,20 +873,64 @@ export async function resumeOrchBatch(
851
873
  "info",
852
874
  );
853
875
 
876
+ // TP-108/112: Runtime V2 backend selection for resumed batches.
877
+ // MUST be computed before any backend-aware branch (section 3+).
878
+ const resumeBackend: RuntimeBackend = selectRuntimeBackend(
879
+ "all",
880
+ persistedState.wavePlan,
881
+ workspaceConfig,
882
+ ).backend;
883
+ execLog("resume", batchState.batchId, `runtime backend for resumed execution: ${resumeBackend}`);
884
+
854
885
  // ── 3. Discover live signals ─────────────────────────────────
855
- // Check TMUX sessions
886
+ // TP-112: Backend-aware session liveness check.
887
+ // V2: check process registry (pid + status). Legacy: check TMUX.
856
888
  const aliveSessions = new Set<string>();
857
- for (const task of persistedState.tasks) {
858
- if (task.sessionName && tmuxHasSession(task.sessionName)) {
859
- aliveSessions.add(task.sessionName);
889
+ if (resumeBackend === "v2") {
890
+ const registry = readRegistrySnapshot(stateRoot, persistedState.batchId);
891
+ if (registry) {
892
+ for (const manifest of Object.values(registry.agents)) {
893
+ if (!isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
894
+ aliveSessions.add(manifest.agentId);
895
+ // TP-112: Also add the lane session name (without role suffix)
896
+ // so reconciliation matches persisted task.sessionName.
897
+ // e.g., "orch-op-lane-1-worker" -> also add "orch-op-lane-1"
898
+ const laneSession = manifest.agentId.replace(/-(worker|reviewer)$/, "");
899
+ if (laneSession !== manifest.agentId) aliveSessions.add(laneSession);
900
+ }
901
+ }
902
+ }
903
+ } else {
904
+ for (const task of persistedState.tasks) {
905
+ if (task.sessionName && tmuxHasSession(task.sessionName)) {
906
+ aliveSessions.add(task.sessionName);
907
+ }
860
908
  }
861
909
  }
862
910
 
863
- // Check .DONE files
911
+ // Check .DONE files — check both original path and worktree-relative path.
912
+ // TP-109: In workspace mode or V2 execution, .DONE is written in the worktree
913
+ // at the resolved packet path, not the original discovery path. Resume must
914
+ // check both locations for authoritative completion detection.
864
915
  const doneTaskIds = new Set<string>();
865
916
  for (const task of persistedState.tasks) {
917
+ // Check original task folder path
866
918
  if (task.taskFolder && hasTaskDoneMarker(task.taskFolder)) {
867
919
  doneTaskIds.add(task.taskId);
920
+ continue;
921
+ }
922
+ // Check worktree-relative path (packet-home authority)
923
+ const laneRec = persistedState.lanes.find(l => l.taskIds.includes(task.taskId));
924
+ if (laneRec?.worktreePath && task.taskFolder) {
925
+ const resolved = resolveCanonicalTaskPaths(
926
+ task.taskFolder,
927
+ laneRec.worktreePath,
928
+ repoRoot,
929
+ !!workspaceConfig,
930
+ );
931
+ if (existsSync(resolved.donePath)) {
932
+ doneTaskIds.add(task.taskId);
933
+ }
868
934
  }
869
935
  }
870
936
 
@@ -1035,6 +1101,7 @@ export async function resumeOrchBatch(
1035
1101
  const depGraph = buildDependencyGraph(discovery.pending, discovery.completed);
1036
1102
  batchState.dependencyGraph = depGraph;
1037
1103
 
1104
+
1038
1105
  // ── 8. Handle alive sessions (reconnect) ─────────────────────
1039
1106
  // For tasks with alive sessions, we need to wait for them to complete.
1040
1107
  // We poll each alive session's .DONE file.
@@ -1076,44 +1143,82 @@ export async function resumeOrchBatch(
1076
1143
  // Resolve per-lane repo root for workspace mode (v1/repo mode: falls back to repoRoot)
1077
1144
  const laneRepoRoot = resolveRepoRoot(laneRecord.repoId, repoRoot, workspaceConfig);
1078
1145
 
1079
- execLog("resume", task.taskId, "reconnecting to alive session", {
1080
- session: laneRecord.tmuxSessionName,
1081
- repoId: laneRecord.repoId ?? "(default)",
1082
- });
1146
+ // TP-112: Backend-aware reconnect.
1147
+ // V2: re-execute via executeLaneV2 (agent-host doesn't survive restart).
1148
+ // Per spec §7.3: "detect + terminate + rehydrate".
1149
+ // Legacy: poll the still-alive TMUX session.
1150
+ if (resumeBackend === "v2") {
1151
+ execLog("resume", task.taskId, "V2 reconnect: terminate + rehydrate via lane-runner", {
1152
+ repoId: laneRecord.repoId ?? "(default)",
1153
+ });
1154
+ // TP-112 §7.3: detect + terminate + rehydrate.
1155
+ // Kill any alive V2 agent before re-executing to prevent duplicates.
1156
+ terminateAliveV2Agents(stateRoot, persistedState.batchId, laneRecord.tmuxSessionName);
1157
+ try {
1158
+ const laneResult = await executeLaneV2(
1159
+ lane, orchConfig, laneRepoRoot, batchState.pauseSignal,
1160
+ workspaceRoot, !!workspaceConfig,
1161
+ { ORCH_BATCH_ID: batchState.batchId },
1162
+ emitAlert,
1163
+ );
1164
+ const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
1165
+ if (taskResult?.status === "succeeded") {
1166
+ reconnectFinalStatus.set(task.taskId, "succeeded");
1167
+ completedTaskSet.add(task.taskId);
1168
+ failedTaskSet.delete(task.taskId);
1169
+ reconnectTaskSet.delete(task.taskId);
1170
+ batchState.succeededTasks++;
1171
+ } else {
1172
+ reconnectFinalStatus.set(task.taskId, "failed");
1173
+ failedTaskSet.add(task.taskId);
1174
+ completedTaskSet.delete(task.taskId);
1175
+ reconnectTaskSet.delete(task.taskId);
1176
+ batchState.failedTasks++;
1177
+ }
1178
+ } catch (err: unknown) {
1179
+ reconnectFinalStatus.set(task.taskId, "failed");
1180
+ failedTaskSet.add(task.taskId);
1181
+ completedTaskSet.delete(task.taskId);
1182
+ reconnectTaskSet.delete(task.taskId);
1183
+ batchState.failedTasks++;
1184
+ execLog("resume", task.taskId, `V2 reconnect error: ${err instanceof Error ? err.message : String(err)}`);
1185
+ }
1186
+ } else {
1187
+ execLog("resume", task.taskId, "reconnecting to alive session", {
1188
+ session: laneRecord.tmuxSessionName,
1189
+ repoId: laneRecord.repoId ?? "(default)",
1190
+ });
1083
1191
 
1084
- // Poll until task completes
1085
- try {
1086
- const pollResult = await pollUntilTaskComplete(
1087
- lane,
1088
- allocatedTask,
1089
- orchConfig,
1090
- laneRepoRoot,
1091
- batchState.pauseSignal,
1092
- );
1192
+ try {
1193
+ const pollResult = await pollUntilTaskComplete(
1194
+ lane,
1195
+ allocatedTask,
1196
+ orchConfig,
1197
+ laneRepoRoot,
1198
+ batchState.pauseSignal,
1199
+ );
1093
1200
 
1094
- if (pollResult.status === "succeeded") {
1095
- reconnectFinalStatus.set(task.taskId, "succeeded");
1096
- completedTaskSet.add(task.taskId);
1097
- failedTaskSet.delete(task.taskId);
1098
- reconnectTaskSet.delete(task.taskId);
1099
- batchState.succeededTasks++;
1100
- execLog("resume", task.taskId, "reconnected task succeeded");
1101
- } else {
1201
+ if (pollResult.status === "succeeded") {
1202
+ reconnectFinalStatus.set(task.taskId, "succeeded");
1203
+ completedTaskSet.add(task.taskId);
1204
+ failedTaskSet.delete(task.taskId);
1205
+ reconnectTaskSet.delete(task.taskId);
1206
+ batchState.succeededTasks++;
1207
+ } else {
1208
+ reconnectFinalStatus.set(task.taskId, "failed");
1209
+ failedTaskSet.add(task.taskId);
1210
+ completedTaskSet.delete(task.taskId);
1211
+ reconnectTaskSet.delete(task.taskId);
1212
+ batchState.failedTasks++;
1213
+ }
1214
+ } catch (err: unknown) {
1102
1215
  reconnectFinalStatus.set(task.taskId, "failed");
1103
1216
  failedTaskSet.add(task.taskId);
1104
1217
  completedTaskSet.delete(task.taskId);
1105
1218
  reconnectTaskSet.delete(task.taskId);
1106
1219
  batchState.failedTasks++;
1107
- execLog("resume", task.taskId, `reconnected task ${pollResult.status}: ${pollResult.exitReason}`);
1220
+ execLog("resume", task.taskId, `reconnection error: ${err instanceof Error ? err.message : String(err)}`);
1108
1221
  }
1109
- } catch (err: unknown) {
1110
- reconnectFinalStatus.set(task.taskId, "failed");
1111
- failedTaskSet.add(task.taskId);
1112
- completedTaskSet.delete(task.taskId);
1113
- reconnectTaskSet.delete(task.taskId);
1114
- batchState.failedTasks++;
1115
- const msg = err instanceof Error ? err.message : String(err);
1116
- execLog("resume", task.taskId, `reconnection error: ${msg}`);
1117
1222
  }
1118
1223
  }
1119
1224
  }
@@ -1167,16 +1272,35 @@ export async function resumeOrchBatch(
1167
1272
  });
1168
1273
 
1169
1274
  try {
1170
- spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot, undefined, {
1171
- ORCH_BATCH_ID: batchState.batchId,
1172
- });
1173
- const pollResult = await pollUntilTaskComplete(
1174
- lane,
1175
- allocatedTask,
1176
- orchConfig,
1177
- reExecRepoRoot,
1178
- batchState.pauseSignal,
1179
- );
1275
+ // TP-112: Backend-aware re-execution.
1276
+ let pollResult: { status: LaneTaskStatus; exitReason: string; doneFileFound: boolean };
1277
+ if (resumeBackend === "v2") {
1278
+ // TP-112: terminate any alive V2 agent before re-execute
1279
+ terminateAliveV2Agents(stateRoot, batchState.batchId, laneRecord.tmuxSessionName);
1280
+ const laneResult = await executeLaneV2(
1281
+ lane, orchConfig, reExecRepoRoot, batchState.pauseSignal,
1282
+ workspaceRoot, !!workspaceConfig,
1283
+ { ORCH_BATCH_ID: batchState.batchId },
1284
+ emitAlert,
1285
+ );
1286
+ const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
1287
+ pollResult = {
1288
+ status: taskResult?.status ?? "failed",
1289
+ exitReason: taskResult?.exitReason ?? "V2 re-execution completed",
1290
+ doneFileFound: taskResult?.doneFileFound ?? false,
1291
+ };
1292
+ } else {
1293
+ spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot, undefined, {
1294
+ ORCH_BATCH_ID: batchState.batchId,
1295
+ });
1296
+ pollResult = await pollUntilTaskComplete(
1297
+ lane,
1298
+ allocatedTask,
1299
+ orchConfig,
1300
+ reExecRepoRoot,
1301
+ batchState.pauseSignal,
1302
+ );
1303
+ }
1180
1304
 
1181
1305
  if (pollResult.status === "succeeded") {
1182
1306
  reExecuteFinalStatus.set(task.taskId, "succeeded");
@@ -1275,6 +1399,9 @@ export async function resumeOrchBatch(
1275
1399
  stateRoot,
1276
1400
  agentRoot,
1277
1401
  runnerConfig.testing_commands,
1402
+ undefined, // healthMonitor
1403
+ undefined, // forceMixedOutcome
1404
+ resumeBackend,
1278
1405
  );
1279
1406
 
1280
1407
  if (reExecMergeResult.status === "succeeded") {
@@ -1530,6 +1657,9 @@ export async function resumeOrchBatch(
1530
1657
  stateRoot,
1531
1658
  agentRoot,
1532
1659
  runnerConfig.testing_commands,
1660
+ undefined, // healthMonitor
1661
+ undefined, // forceMixedOutcome
1662
+ resumeBackend,
1533
1663
  );
1534
1664
  batchState.mergeResults.push(mergeRetryResult);
1535
1665
 
@@ -1603,6 +1733,8 @@ export async function resumeOrchBatch(
1603
1733
  }
1604
1734
  },
1605
1735
  workspaceConfig,
1736
+ resumeBackend,
1737
+ emitAlert,
1606
1738
  );
1607
1739
 
1608
1740
  batchState.waveResults.push(waveResult);
@@ -1740,6 +1872,9 @@ export async function resumeOrchBatch(
1740
1872
  stateRoot,
1741
1873
  agentRoot,
1742
1874
  runnerConfig.testing_commands,
1875
+ undefined, // healthMonitor
1876
+ undefined, // forceMixedOutcome
1877
+ resumeBackend,
1743
1878
  );
1744
1879
  batchState.mergeResults.push(mergeResult);
1745
1880
 
@@ -1904,6 +2039,9 @@ export async function resumeOrchBatch(
1904
2039
  stateRoot,
1905
2040
  agentRoot,
1906
2041
  runnerConfig.testing_commands,
2042
+ undefined, // healthMonitor
2043
+ undefined, // forceMixedOutcome
2044
+ resumeBackend,
1907
2045
  );
1908
2046
  },
1909
2047
  persist: (trigger) => persistRuntimeState(trigger, batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot),