taskplane 0.29.2 → 0.30.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.
- package/bin/gitignore-patterns.mjs +11 -8
- package/bin/rpc-wrapper.mjs +410 -357
- package/bin/taskplane.mjs +533 -250
- package/extensions/reviewer-extension.ts +17 -11
- package/extensions/taskplane/abort.ts +50 -18
- package/extensions/taskplane/agent-bridge-extension.ts +232 -105
- package/extensions/taskplane/agent-host.ts +224 -97
- package/extensions/taskplane/cleanup.ts +71 -42
- package/extensions/taskplane/config-loader.ts +142 -58
- package/extensions/taskplane/config-schema.ts +6 -13
- package/extensions/taskplane/config.ts +10 -2
- package/extensions/taskplane/diagnostic-reports.ts +59 -47
- package/extensions/taskplane/diagnostics.ts +13 -13
- package/extensions/taskplane/discovery.ts +35 -61
- package/extensions/taskplane/engine-worker.ts +53 -46
- package/extensions/taskplane/engine.ts +1760 -602
- package/extensions/taskplane/execution.ts +426 -206
- package/extensions/taskplane/extension.ts +1073 -598
- package/extensions/taskplane/formatting.ts +136 -124
- package/extensions/taskplane/git.ts +0 -2
- package/extensions/taskplane/lane-runner.ts +542 -311
- package/extensions/taskplane/mailbox.ts +57 -49
- package/extensions/taskplane/merge.ts +662 -383
- package/extensions/taskplane/messages.ts +109 -51
- package/extensions/taskplane/migrations.ts +1 -1
- package/extensions/taskplane/path-resolver.ts +8 -9
- package/extensions/taskplane/persistence.ts +425 -262
- package/extensions/taskplane/process-registry.ts +36 -7
- package/extensions/taskplane/quality-gate.ts +107 -55
- package/extensions/taskplane/resume.ts +774 -267
- package/extensions/taskplane/sessions.ts +1 -1
- package/extensions/taskplane/settings-tui.ts +505 -164
- package/extensions/taskplane/sidecar-telemetry.ts +25 -10
- package/extensions/taskplane/supervisor.ts +477 -270
- package/extensions/taskplane/task-executor-core.ts +178 -53
- package/extensions/taskplane/types.ts +186 -108
- package/extensions/taskplane/verification.ts +27 -22
- package/extensions/taskplane/waves.ts +59 -43
- package/extensions/taskplane/workspace.ts +14 -12
- package/extensions/taskplane/worktree.ts +218 -196
- package/package.json +14 -2
|
@@ -2,31 +2,89 @@
|
|
|
2
2
|
* Merge orchestration, merge agents, merge worktree
|
|
3
3
|
* @module orch/merge
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
type Dirent,
|
|
15
|
+
} from "fs";
|
|
6
16
|
import { readFile as fsReadFile } from "fs/promises";
|
|
7
17
|
import { execSync, spawnSync } from "child_process";
|
|
8
18
|
import { join, dirname, resolve, relative } from "path";
|
|
9
19
|
|
|
10
20
|
import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
|
|
11
21
|
import { resolveOperatorId } from "./naming.ts";
|
|
12
|
-
import {
|
|
13
|
-
|
|
22
|
+
import {
|
|
23
|
+
MERGE_POLL_INTERVAL_MS,
|
|
24
|
+
MERGE_RESULT_GRACE_MS,
|
|
25
|
+
MERGE_RESULT_READ_RETRIES,
|
|
26
|
+
MERGE_RESULT_READ_RETRY_DELAY_MS,
|
|
27
|
+
MERGE_SPAWN_RETRY_MAX,
|
|
28
|
+
MERGE_TIMEOUT_MAX_RETRIES,
|
|
29
|
+
MERGE_TIMEOUT_MS,
|
|
30
|
+
MERGE_HEALTH_POLL_INTERVAL_MS,
|
|
31
|
+
MERGE_HEALTH_WARNING_THRESHOLD_MS,
|
|
32
|
+
MERGE_HEALTH_STUCK_THRESHOLD_MS,
|
|
33
|
+
MergeError,
|
|
34
|
+
VALID_MERGE_STATUSES,
|
|
35
|
+
buildEngineEventBase,
|
|
36
|
+
} from "./types.ts";
|
|
37
|
+
import type {
|
|
38
|
+
AllocatedLane,
|
|
39
|
+
LaneExecutionResult,
|
|
40
|
+
MergeLaneResult,
|
|
41
|
+
MergeResult,
|
|
42
|
+
MergeResultStatus,
|
|
43
|
+
MergeWaveResult,
|
|
44
|
+
OrchestratorConfig,
|
|
45
|
+
RepoMergeOutcome,
|
|
46
|
+
TaskRunnerConfig,
|
|
47
|
+
TransactionRecord,
|
|
48
|
+
TransactionStatus,
|
|
49
|
+
VerificationBaselineResult,
|
|
50
|
+
WaveExecutionResult,
|
|
51
|
+
WorkspaceConfig,
|
|
52
|
+
MergeHealthStatus,
|
|
53
|
+
MergeHealthEventType,
|
|
54
|
+
MergeSessionSnapshot,
|
|
55
|
+
MergeSessionHealthState,
|
|
56
|
+
EngineEvent,
|
|
57
|
+
OrchBatchPhase,
|
|
58
|
+
RuntimeMergeSnapshot,
|
|
59
|
+
RuntimeAgentTelemetrySnapshot,
|
|
60
|
+
} from "./types.ts";
|
|
14
61
|
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
15
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
readManifest,
|
|
64
|
+
writeManifest,
|
|
65
|
+
buildRegistrySnapshot,
|
|
66
|
+
writeRegistrySnapshot,
|
|
67
|
+
readRegistrySnapshot,
|
|
68
|
+
writeMergeSnapshot,
|
|
69
|
+
} from "./process-registry.ts";
|
|
16
70
|
import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
|
|
17
71
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
18
72
|
import { ORCH_MESSAGES } from "./messages.ts";
|
|
19
73
|
import { emitEngineEvent } from "./persistence.ts";
|
|
20
74
|
import { loadOrchestratorConfig } from "./config.ts";
|
|
21
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
captureBaseline,
|
|
77
|
+
diffFingerprints,
|
|
78
|
+
runVerificationCommands,
|
|
79
|
+
parseTestOutput,
|
|
80
|
+
deduplicateFingerprints,
|
|
81
|
+
} from "./verification.ts";
|
|
22
82
|
import { spawnAgent } from "./agent-host.ts";
|
|
23
83
|
import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
|
|
24
84
|
import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
|
|
25
85
|
import type { RuntimeBackend } from "./execution.ts";
|
|
26
86
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
27
87
|
|
|
28
|
-
|
|
29
|
-
|
|
30
88
|
// ── Merge Implementation ─────────────────────────────────────────────
|
|
31
89
|
|
|
32
90
|
/**
|
|
@@ -47,10 +105,7 @@ import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./v
|
|
|
47
105
|
*/
|
|
48
106
|
export function parseMergeResult(resultPath: string): MergeResult {
|
|
49
107
|
if (!existsSync(resultPath)) {
|
|
50
|
-
throw new MergeError(
|
|
51
|
-
"MERGE_RESULT_INVALID",
|
|
52
|
-
`Merge result file not found: ${resultPath}`,
|
|
53
|
-
);
|
|
108
|
+
throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
|
|
54
109
|
}
|
|
55
110
|
|
|
56
111
|
const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
|
|
@@ -64,50 +119,55 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
64
119
|
};
|
|
65
120
|
|
|
66
121
|
const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
|
|
67
|
-
typeof obj.verification_passed === "boolean"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const normalizeVerification = (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
122
|
+
typeof obj.verification_passed === "boolean" ||
|
|
123
|
+
Array.isArray(obj.verification_commands) ||
|
|
124
|
+
typeof obj.verification_output === "string" ||
|
|
125
|
+
typeof obj.verification_exit_code === "number";
|
|
126
|
+
|
|
127
|
+
const normalizeVerification = (
|
|
128
|
+
obj: Record<string, unknown>,
|
|
129
|
+
): MergeResult["verification"] | null => {
|
|
130
|
+
const nested =
|
|
131
|
+
obj.verification && typeof obj.verification === "object"
|
|
132
|
+
? (obj.verification as Record<string, unknown>)
|
|
133
|
+
: null;
|
|
76
134
|
|
|
77
135
|
if (!nested && !hasFlatVerification(obj)) {
|
|
78
136
|
return null;
|
|
79
137
|
}
|
|
80
138
|
|
|
81
139
|
const passedFromBool =
|
|
82
|
-
(nested && typeof nested.passed === "boolean" ? nested.passed : undefined)
|
|
83
|
-
|
|
84
|
-
|
|
140
|
+
(nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ??
|
|
141
|
+
(nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ??
|
|
142
|
+
(typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
|
|
85
143
|
|
|
86
144
|
const exitCode =
|
|
87
|
-
(nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const passed =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
145
|
+
(nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ??
|
|
146
|
+
(nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ??
|
|
147
|
+
(typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
|
|
148
|
+
|
|
149
|
+
const passed =
|
|
150
|
+
typeof passedFromBool === "boolean"
|
|
151
|
+
? passedFromBool
|
|
152
|
+
: typeof exitCode === "number"
|
|
153
|
+
? exitCode === 0
|
|
154
|
+
: false;
|
|
155
|
+
|
|
156
|
+
const ran =
|
|
157
|
+
nested && typeof nested.ran === "boolean"
|
|
158
|
+
? nested.ran
|
|
159
|
+
: typeof passedFromBool === "boolean" ||
|
|
160
|
+
typeof exitCode === "number" ||
|
|
161
|
+
(nested && typeof nested.command === "string") ||
|
|
162
|
+
(nested && typeof nested.summary === "string") ||
|
|
163
|
+
typeof obj.verification_output === "string" ||
|
|
164
|
+
Array.isArray(obj.verification_commands);
|
|
105
165
|
|
|
106
166
|
const output = (
|
|
107
|
-
(nested && typeof nested.output === "string" ? nested.output : undefined)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
167
|
+
(nested && typeof nested.output === "string" ? nested.output : undefined) ??
|
|
168
|
+
(nested && typeof nested.summary === "string" ? nested.summary : undefined) ??
|
|
169
|
+
(nested && typeof nested.notes === "string" ? nested.notes : undefined) ??
|
|
170
|
+
(typeof obj.verification_output === "string" ? obj.verification_output : "")
|
|
111
171
|
).slice(0, 2000);
|
|
112
172
|
|
|
113
173
|
return { ran, passed, output };
|
|
@@ -159,13 +219,25 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
159
219
|
}
|
|
160
220
|
|
|
161
221
|
// Normalize status to uppercase (merge agents may write lowercase)
|
|
162
|
-
|
|
222
|
+
// TP-195: hoist normalized value to a local string so the
|
|
223
|
+
// `VALID_MERGE_STATUSES.has()` call typechecks. `parsed.status`
|
|
224
|
+
// is `unknown` after JSON parse; assigning `String(...)` to a
|
|
225
|
+
// property of an `any` doesn't propagate `string` type back
|
|
226
|
+
// through `parsed.status`. Runtime evaluation order is
|
|
227
|
+
// unchanged.
|
|
228
|
+
const normalizedStatus = String(parsed.status).toUpperCase();
|
|
229
|
+
parsed.status = normalizedStatus;
|
|
163
230
|
|
|
164
231
|
// Validate status value
|
|
165
|
-
if (!VALID_MERGE_STATUSES.has(
|
|
166
|
-
execLog(
|
|
167
|
-
|
|
168
|
-
|
|
232
|
+
if (!VALID_MERGE_STATUSES.has(normalizedStatus)) {
|
|
233
|
+
execLog(
|
|
234
|
+
"merge",
|
|
235
|
+
"parse",
|
|
236
|
+
`unknown merge status "${normalizedStatus}" — treating as BUILD_FAILURE`,
|
|
237
|
+
{
|
|
238
|
+
resultPath,
|
|
239
|
+
},
|
|
240
|
+
);
|
|
169
241
|
parsed.status = "BUILD_FAILURE";
|
|
170
242
|
}
|
|
171
243
|
|
|
@@ -173,19 +245,20 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
173
245
|
const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
|
|
174
246
|
const conflicts = Array.isArray(parsed.conflicts)
|
|
175
247
|
? parsed.conflicts
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
248
|
+
.filter(
|
|
249
|
+
(c): c is { file: string; type: string; resolved: boolean; resolution?: string } =>
|
|
250
|
+
typeof c === "object" &&
|
|
251
|
+
c !== null &&
|
|
252
|
+
typeof (c as { file?: unknown }).file === "string" &&
|
|
253
|
+
typeof (c as { type?: unknown }).type === "string" &&
|
|
254
|
+
typeof (c as { resolved?: unknown }).resolved === "boolean",
|
|
255
|
+
)
|
|
256
|
+
.map((c) => ({
|
|
257
|
+
file: c.file,
|
|
258
|
+
type: c.type,
|
|
259
|
+
resolved: c.resolved,
|
|
260
|
+
...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
|
|
261
|
+
}))
|
|
189
262
|
: [];
|
|
190
263
|
|
|
191
264
|
// Normalize optional fields with defaults
|
|
@@ -212,7 +285,7 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
212
285
|
throw new MergeError(
|
|
213
286
|
"MERGE_RESULT_INVALID",
|
|
214
287
|
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
215
|
-
|
|
288
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
216
289
|
);
|
|
217
290
|
}
|
|
218
291
|
|
|
@@ -232,10 +305,7 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
232
305
|
*/
|
|
233
306
|
export async function parseMergeResultAsync(resultPath: string): Promise<MergeResult> {
|
|
234
307
|
if (!existsSync(resultPath)) {
|
|
235
|
-
throw new MergeError(
|
|
236
|
-
"MERGE_RESULT_INVALID",
|
|
237
|
-
`Merge result file not found: ${resultPath}`,
|
|
238
|
-
);
|
|
308
|
+
throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
|
|
239
309
|
}
|
|
240
310
|
|
|
241
311
|
const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
|
|
@@ -249,50 +319,55 @@ export async function parseMergeResultAsync(resultPath: string): Promise<MergeRe
|
|
|
249
319
|
};
|
|
250
320
|
|
|
251
321
|
const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
|
|
252
|
-
typeof obj.verification_passed === "boolean"
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const normalizeVerification = (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
322
|
+
typeof obj.verification_passed === "boolean" ||
|
|
323
|
+
Array.isArray(obj.verification_commands) ||
|
|
324
|
+
typeof obj.verification_output === "string" ||
|
|
325
|
+
typeof obj.verification_exit_code === "number";
|
|
326
|
+
|
|
327
|
+
const normalizeVerification = (
|
|
328
|
+
obj: Record<string, unknown>,
|
|
329
|
+
): MergeResult["verification"] | null => {
|
|
330
|
+
const nested =
|
|
331
|
+
obj.verification && typeof obj.verification === "object"
|
|
332
|
+
? (obj.verification as Record<string, unknown>)
|
|
333
|
+
: null;
|
|
261
334
|
|
|
262
335
|
if (!nested && !hasFlatVerification(obj)) {
|
|
263
336
|
return null;
|
|
264
337
|
}
|
|
265
338
|
|
|
266
339
|
const passedFromBool =
|
|
267
|
-
(nested && typeof nested.passed === "boolean" ? nested.passed : undefined)
|
|
268
|
-
|
|
269
|
-
|
|
340
|
+
(nested && typeof nested.passed === "boolean" ? nested.passed : undefined) ??
|
|
341
|
+
(nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined) ??
|
|
342
|
+
(typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
|
|
270
343
|
|
|
271
344
|
const exitCode =
|
|
272
|
-
(nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const passed =
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
345
|
+
(nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined) ??
|
|
346
|
+
(nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined) ??
|
|
347
|
+
(typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
|
|
348
|
+
|
|
349
|
+
const passed =
|
|
350
|
+
typeof passedFromBool === "boolean"
|
|
351
|
+
? passedFromBool
|
|
352
|
+
: typeof exitCode === "number"
|
|
353
|
+
? exitCode === 0
|
|
354
|
+
: false;
|
|
355
|
+
|
|
356
|
+
const ran =
|
|
357
|
+
nested && typeof nested.ran === "boolean"
|
|
358
|
+
? nested.ran
|
|
359
|
+
: typeof passedFromBool === "boolean" ||
|
|
360
|
+
typeof exitCode === "number" ||
|
|
361
|
+
(nested && typeof nested.command === "string") ||
|
|
362
|
+
(nested && typeof nested.summary === "string") ||
|
|
363
|
+
typeof obj.verification_output === "string" ||
|
|
364
|
+
Array.isArray(obj.verification_commands);
|
|
290
365
|
|
|
291
366
|
const output = (
|
|
292
|
-
(nested && typeof nested.output === "string" ? nested.output : undefined)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
367
|
+
(nested && typeof nested.output === "string" ? nested.output : undefined) ??
|
|
368
|
+
(nested && typeof nested.summary === "string" ? nested.summary : undefined) ??
|
|
369
|
+
(nested && typeof nested.notes === "string" ? nested.notes : undefined) ??
|
|
370
|
+
(typeof obj.verification_output === "string" ? obj.verification_output : "")
|
|
296
371
|
).slice(0, 2000);
|
|
297
372
|
|
|
298
373
|
return { ran, passed, output };
|
|
@@ -342,12 +417,20 @@ export async function parseMergeResultAsync(resultPath: string): Promise<MergeRe
|
|
|
342
417
|
}
|
|
343
418
|
|
|
344
419
|
// Normalize status to uppercase
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
420
|
+
// TP-195: hoist normalized value to a local string (same rationale
|
|
421
|
+
// as the parallel block at line ~225 above).
|
|
422
|
+
const normalizedStatus = String(parsed.status).toUpperCase();
|
|
423
|
+
parsed.status = normalizedStatus;
|
|
424
|
+
|
|
425
|
+
if (!VALID_MERGE_STATUSES.has(normalizedStatus)) {
|
|
426
|
+
execLog(
|
|
427
|
+
"merge",
|
|
428
|
+
"parse",
|
|
429
|
+
`unknown merge status "${normalizedStatus}" — treating as BUILD_FAILURE`,
|
|
430
|
+
{
|
|
431
|
+
resultPath,
|
|
432
|
+
},
|
|
433
|
+
);
|
|
351
434
|
parsed.status = "BUILD_FAILURE";
|
|
352
435
|
}
|
|
353
436
|
|
|
@@ -355,19 +438,20 @@ export async function parseMergeResultAsync(resultPath: string): Promise<MergeRe
|
|
|
355
438
|
const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
|
|
356
439
|
const conflicts = Array.isArray(parsed.conflicts)
|
|
357
440
|
? parsed.conflicts
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
441
|
+
.filter(
|
|
442
|
+
(c): c is { file: string; type: string; resolved: boolean; resolution?: string } =>
|
|
443
|
+
typeof c === "object" &&
|
|
444
|
+
c !== null &&
|
|
445
|
+
typeof (c as { file?: unknown }).file === "string" &&
|
|
446
|
+
typeof (c as { type?: unknown }).type === "string" &&
|
|
447
|
+
typeof (c as { resolved?: unknown }).resolved === "boolean",
|
|
448
|
+
)
|
|
449
|
+
.map((c) => ({
|
|
450
|
+
file: c.file,
|
|
451
|
+
type: c.type,
|
|
452
|
+
resolved: c.resolved,
|
|
453
|
+
...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
|
|
454
|
+
}))
|
|
371
455
|
: [];
|
|
372
456
|
|
|
373
457
|
return {
|
|
@@ -392,7 +476,7 @@ export async function parseMergeResultAsync(resultPath: string): Promise<MergeRe
|
|
|
392
476
|
throw new MergeError(
|
|
393
477
|
"MERGE_RESULT_INVALID",
|
|
394
478
|
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
395
|
-
|
|
479
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
396
480
|
);
|
|
397
481
|
}
|
|
398
482
|
|
|
@@ -424,10 +508,9 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
424
508
|
const resolvedTmpPath = resolve(tmpWorktreePath);
|
|
425
509
|
|
|
426
510
|
try {
|
|
427
|
-
const addResult = spawnSync(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
);
|
|
511
|
+
const addResult = spawnSync("git", ["worktree", "add", resolvedTmpPath, targetBranch], {
|
|
512
|
+
cwd: repoRoot,
|
|
513
|
+
});
|
|
431
514
|
if (addResult.status !== 0) {
|
|
432
515
|
execLog("merge", `W${waveIndex}`, `failed to create temp worktree for skipped artifacts`, {
|
|
433
516
|
stderr: addResult.stderr?.toString().trim(),
|
|
@@ -459,7 +542,9 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
459
542
|
copyFileSync(srcPath, destPath);
|
|
460
543
|
spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
|
|
461
544
|
staged++;
|
|
462
|
-
} catch {
|
|
545
|
+
} catch {
|
|
546
|
+
/* best effort */
|
|
547
|
+
}
|
|
463
548
|
}
|
|
464
549
|
|
|
465
550
|
for (const dirName of ALLOWED_DIRS) {
|
|
@@ -469,9 +554,7 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
469
554
|
const entries = readdirSync(laneDir, { recursive: true, withFileTypes: true });
|
|
470
555
|
for (const entry of entries) {
|
|
471
556
|
if (!entry.isFile()) continue;
|
|
472
|
-
const entryPath = entry.parentPath
|
|
473
|
-
? join(entry.parentPath, entry.name)
|
|
474
|
-
: entry.name;
|
|
557
|
+
const entryPath = entry.parentPath ? join(entry.parentPath, entry.name) : entry.name;
|
|
475
558
|
const fileRel = relative(laneDir, entryPath).replace(/\\/g, "/");
|
|
476
559
|
if (fileRel.startsWith("..")) continue;
|
|
477
560
|
const relPath = `${relFolder}/${dirName}/${fileRel}`;
|
|
@@ -482,7 +565,9 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
482
565
|
spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
|
|
483
566
|
staged++;
|
|
484
567
|
}
|
|
485
|
-
} catch {
|
|
568
|
+
} catch {
|
|
569
|
+
/* best effort */
|
|
570
|
+
}
|
|
486
571
|
}
|
|
487
572
|
}
|
|
488
573
|
}
|
|
@@ -495,7 +580,7 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
495
580
|
);
|
|
496
581
|
if (commitResult.status === 0) {
|
|
497
582
|
execLog("merge", `W${waveIndex}`, `staged ${staged} artifact(s) from skipped-only lanes`, {
|
|
498
|
-
lanes: lanes.map(l => l.laneNumber).join(","),
|
|
583
|
+
lanes: lanes.map((l) => l.laneNumber).join(","),
|
|
499
584
|
});
|
|
500
585
|
} else {
|
|
501
586
|
execLog("merge", `W${waveIndex}`, `failed to commit skipped-task artifacts`, {
|
|
@@ -511,12 +596,16 @@ function stageSkippedArtifactsToTargetBranch(
|
|
|
511
596
|
// Clean up the temporary worktree
|
|
512
597
|
try {
|
|
513
598
|
spawnSync("git", ["worktree", "remove", "--force", resolvedTmpPath], { cwd: repoRoot });
|
|
514
|
-
} catch {
|
|
599
|
+
} catch {
|
|
600
|
+
/* best effort cleanup */
|
|
601
|
+
}
|
|
515
602
|
try {
|
|
516
603
|
if (existsSync(resolvedTmpPath)) {
|
|
517
604
|
rmSync(resolvedTmpPath, { recursive: true, force: true });
|
|
518
605
|
}
|
|
519
|
-
} catch {
|
|
606
|
+
} catch {
|
|
607
|
+
/* best effort cleanup */
|
|
608
|
+
}
|
|
520
609
|
}
|
|
521
610
|
}
|
|
522
611
|
|
|
@@ -584,10 +673,10 @@ export function buildMergeRequest(
|
|
|
584
673
|
verifyCommands: string[],
|
|
585
674
|
resultFilePath: string,
|
|
586
675
|
): string {
|
|
587
|
-
const taskIds = lane.tasks.map(t => t.taskId).join(", ");
|
|
676
|
+
const taskIds = lane.tasks.map((t) => t.taskId).join(", ");
|
|
588
677
|
// TP-169: Guard against null task stubs from reconstructAllocatedLanes
|
|
589
678
|
const fileScopes = lane.tasks
|
|
590
|
-
.flatMap(t => t.task?.fileScope || [])
|
|
679
|
+
.flatMap((t) => t.task?.fileScope || [])
|
|
591
680
|
.filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate
|
|
592
681
|
|
|
593
682
|
const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`;
|
|
@@ -605,15 +694,13 @@ export function buildMergeRequest(
|
|
|
605
694
|
`${mergeMessage}`,
|
|
606
695
|
"",
|
|
607
696
|
`## Tasks Completed`,
|
|
608
|
-
...lane.tasks.map(t => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`),
|
|
697
|
+
...lane.tasks.map((t) => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`),
|
|
609
698
|
"",
|
|
610
699
|
`## File Scope`,
|
|
611
|
-
...(fileScopes.length > 0
|
|
612
|
-
? fileScopes.map(f => `- ${f}`)
|
|
613
|
-
: ["- (no file scope declared)"]),
|
|
700
|
+
...(fileScopes.length > 0 ? fileScopes.map((f) => `- ${f}`) : ["- (no file scope declared)"]),
|
|
614
701
|
"",
|
|
615
702
|
`## Verification Commands`,
|
|
616
|
-
...verifyCommands.map(cmd => `\`\`\`bash\n${cmd}\n\`\`\``),
|
|
703
|
+
...verifyCommands.map((cmd) => `\`\`\`bash\n${cmd}\n\`\`\``),
|
|
617
704
|
"",
|
|
618
705
|
`## Result File`,
|
|
619
706
|
`result_file: ${resultFilePath.split("\\").join("/")}`,
|
|
@@ -624,12 +711,12 @@ export function buildMergeRequest(
|
|
|
624
711
|
"",
|
|
625
712
|
"```json",
|
|
626
713
|
"{",
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
714
|
+
' "status": "SUCCESS" | "CONFLICT_RESOLVED" | "CONFLICT_UNRESOLVED" | "BUILD_FAILURE",',
|
|
715
|
+
' "source_branch": "<source branch name>",',
|
|
716
|
+
' "target_branch": "<target branch name>",',
|
|
717
|
+
' "merge_commit": "<merge commit sha or empty string>",',
|
|
718
|
+
' "conflicts": [{ "file": "...", "type": "...", "resolved": true|false }],',
|
|
719
|
+
' "verification": { "ran": true|false, "passed": true|false, "output": "..." }',
|
|
633
720
|
"}",
|
|
634
721
|
"```",
|
|
635
722
|
"",
|
|
@@ -648,8 +735,6 @@ export function buildMergeRequest(
|
|
|
648
735
|
return lines.join("\n");
|
|
649
736
|
}
|
|
650
737
|
|
|
651
|
-
|
|
652
|
-
|
|
653
738
|
/**
|
|
654
739
|
* Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer).
|
|
655
740
|
*
|
|
@@ -674,6 +759,12 @@ export function buildMergeRequest(
|
|
|
674
759
|
*
|
|
675
760
|
* @since TP-108
|
|
676
761
|
*/
|
|
762
|
+
// TP-195: return type changed from `Promise<AgentHostResult>` to
|
|
763
|
+
// `Promise<void>` to match actual semantics. The function never returns a
|
|
764
|
+
// value — it spawns the merge agent, attaches `.then`/`.catch` handlers
|
|
765
|
+
// for fire-and-forget exit logging (line ~912 marker: "Fire-and-forget"),
|
|
766
|
+
// and exits. Both call sites (lines ~1929, ~1942) `await` the returned
|
|
767
|
+
// promise but do not consume its value.
|
|
677
768
|
export async function spawnMergeAgentV2(
|
|
678
769
|
sessionName: string,
|
|
679
770
|
repoRoot: string,
|
|
@@ -684,7 +775,7 @@ export async function spawnMergeAgentV2(
|
|
|
684
775
|
agentRoot?: string,
|
|
685
776
|
batchId?: string,
|
|
686
777
|
waveIndex?: number,
|
|
687
|
-
): Promise<
|
|
778
|
+
): Promise<void> {
|
|
688
779
|
execLog("merge", sessionName, "spawning merge agent via Runtime V2 (direct agent-host)", {
|
|
689
780
|
mergeWorkDir,
|
|
690
781
|
mergeRequestPath,
|
|
@@ -698,17 +789,28 @@ export async function spawnMergeAgentV2(
|
|
|
698
789
|
agentRoot ? join(agentRoot, "task-merger.md") : "",
|
|
699
790
|
join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
|
|
700
791
|
].filter(Boolean);
|
|
701
|
-
const systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
|
|
792
|
+
const systemPromptPath = systemPromptCandidates.find((p) => existsSync(p)) || "";
|
|
702
793
|
let systemPrompt: string | undefined;
|
|
703
794
|
if (systemPromptPath) {
|
|
704
|
-
try {
|
|
795
|
+
try {
|
|
796
|
+
systemPrompt = readFileSync(systemPromptPath, "utf-8");
|
|
797
|
+
} catch {
|
|
798
|
+
/* use default */
|
|
799
|
+
}
|
|
705
800
|
}
|
|
706
801
|
|
|
707
802
|
// Resolve event/exit paths
|
|
708
803
|
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
709
804
|
const bid = batchId || "unknown";
|
|
710
805
|
const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl");
|
|
711
|
-
const exitSummaryPath = join(
|
|
806
|
+
const exitSummaryPath = join(
|
|
807
|
+
sidecarRoot,
|
|
808
|
+
"runtime",
|
|
809
|
+
bid,
|
|
810
|
+
"agents",
|
|
811
|
+
sessionName,
|
|
812
|
+
"exit-summary.json",
|
|
813
|
+
);
|
|
712
814
|
|
|
713
815
|
// Mailbox directory
|
|
714
816
|
let mailboxDir: string | null = null;
|
|
@@ -752,16 +854,24 @@ export async function spawnMergeAgentV2(
|
|
|
752
854
|
// (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2).
|
|
753
855
|
const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/);
|
|
754
856
|
if (!mergeNumberMatch) {
|
|
755
|
-
execLog(
|
|
857
|
+
execLog(
|
|
858
|
+
"merge",
|
|
859
|
+
sessionName,
|
|
860
|
+
"warning: could not parse merge number from session name — defaulting to 1",
|
|
861
|
+
{ sessionName },
|
|
862
|
+
);
|
|
756
863
|
}
|
|
757
864
|
const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
|
|
758
865
|
const mergeStartedAt = Date.now();
|
|
759
866
|
|
|
760
867
|
// Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
|
|
761
|
-
const buildAgentSnap = (
|
|
868
|
+
const buildAgentSnap = (
|
|
869
|
+
tel: Partial<AgentHostResult>,
|
|
870
|
+
status: RuntimeAgentTelemetrySnapshot["status"],
|
|
871
|
+
): RuntimeAgentTelemetrySnapshot => ({
|
|
762
872
|
agentId: sessionName,
|
|
763
873
|
status,
|
|
764
|
-
elapsedMs: tel.durationMs ??
|
|
874
|
+
elapsedMs: tel.durationMs ?? Date.now() - mergeStartedAt,
|
|
765
875
|
toolCalls: tel.toolCalls ?? 0,
|
|
766
876
|
contextPct: tel.contextUsage?.percent ?? 0,
|
|
767
877
|
costUsd: tel.costUsd ?? 0,
|
|
@@ -786,7 +896,9 @@ export async function spawnMergeAgentV2(
|
|
|
786
896
|
updatedAt: Date.now(),
|
|
787
897
|
};
|
|
788
898
|
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
789
|
-
} catch {
|
|
899
|
+
} catch {
|
|
900
|
+
/* non-fatal */
|
|
901
|
+
}
|
|
790
902
|
};
|
|
791
903
|
|
|
792
904
|
const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry);
|
|
@@ -804,7 +916,9 @@ export async function spawnMergeAgentV2(
|
|
|
804
916
|
updatedAt: Date.now(),
|
|
805
917
|
};
|
|
806
918
|
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
|
|
807
|
-
} catch {
|
|
919
|
+
} catch {
|
|
920
|
+
/* non-fatal */
|
|
921
|
+
}
|
|
808
922
|
|
|
809
923
|
// Store the kill handle for external cleanup (pause/abort).
|
|
810
924
|
// The promise runs in background — caller uses waitForMergeResult()
|
|
@@ -813,65 +927,78 @@ export async function spawnMergeAgentV2(
|
|
|
813
927
|
|
|
814
928
|
// Fire-and-forget: the background promise handles exit logging and
|
|
815
929
|
// writes a terminal snapshot ("complete" or "failed") when the agent exits.
|
|
816
|
-
promise
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
930
|
+
promise
|
|
931
|
+
.then((result) => {
|
|
932
|
+
activeMergeAgents.delete(sessionName);
|
|
933
|
+
execLog("merge", sessionName, "merge agent exited (V2)", {
|
|
934
|
+
exitCode: result.exitCode,
|
|
935
|
+
durationMs: result.durationMs,
|
|
936
|
+
costUsd: result.costUsd,
|
|
937
|
+
killed: result.killed,
|
|
938
|
+
});
|
|
939
|
+
// Write terminal snapshot. Promise resolves for both successful and
|
|
940
|
+
// failed exits, so derive status from result fields rather than
|
|
941
|
+
// relying on .catch to handle failures.
|
|
942
|
+
// Determine terminal status. A clean post-success kill sets registry
|
|
943
|
+
// manifest to "exited" via killMergeAgentV2(name, true=cleanExit).
|
|
944
|
+
// Check the registry first so a successful-then-killed agent is shown
|
|
945
|
+
// as "complete" rather than "failed".
|
|
946
|
+
let terminalStatus: RuntimeMergeSnapshot["status"] = "complete";
|
|
947
|
+
try {
|
|
948
|
+
const manifest = readManifest(mergeStateRoot, bid, sessionName as any);
|
|
949
|
+
if (manifest?.status === "exited") {
|
|
950
|
+
terminalStatus = "complete";
|
|
951
|
+
} else if (result.exitCode !== 0 || !result.agentEnded) {
|
|
952
|
+
terminalStatus = "failed";
|
|
953
|
+
}
|
|
954
|
+
} catch {
|
|
955
|
+
if (result.exitCode !== 0 || !result.agentEnded) terminalStatus = "failed";
|
|
838
956
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
try {
|
|
859
|
-
const snap: RuntimeMergeSnapshot = {
|
|
860
|
-
batchId: bid,
|
|
861
|
-
mergeNumber,
|
|
957
|
+
try {
|
|
958
|
+
const snap: RuntimeMergeSnapshot = {
|
|
959
|
+
batchId: bid,
|
|
960
|
+
mergeNumber,
|
|
961
|
+
sessionName,
|
|
962
|
+
waveIndex: waveIndex ?? 0,
|
|
963
|
+
status: terminalStatus,
|
|
964
|
+
agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
|
|
965
|
+
updatedAt: Date.now(),
|
|
966
|
+
};
|
|
967
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
968
|
+
} catch {
|
|
969
|
+
/* non-fatal */
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
.catch((err) => {
|
|
973
|
+
activeMergeAgents.delete(sessionName);
|
|
974
|
+
execLog(
|
|
975
|
+
"merge",
|
|
862
976
|
sessionName,
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
977
|
+
`merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`,
|
|
978
|
+
);
|
|
979
|
+
// Write a failed terminal snapshot on unexpected rejection.
|
|
980
|
+
try {
|
|
981
|
+
const snap: RuntimeMergeSnapshot = {
|
|
982
|
+
batchId: bid,
|
|
983
|
+
mergeNumber,
|
|
984
|
+
sessionName,
|
|
985
|
+
waveIndex: waveIndex ?? 0,
|
|
986
|
+
status: "failed",
|
|
987
|
+
agent: buildAgentSnap({}, "crashed"),
|
|
988
|
+
updatedAt: Date.now(),
|
|
989
|
+
};
|
|
990
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
991
|
+
} catch {
|
|
992
|
+
/* non-fatal */
|
|
993
|
+
}
|
|
994
|
+
});
|
|
871
995
|
}
|
|
872
996
|
|
|
873
997
|
/** Active V2 merge agent handles for cleanup/abort. @since TP-108 */
|
|
874
|
-
const activeMergeAgents = new Map<
|
|
998
|
+
const activeMergeAgents = new Map<
|
|
999
|
+
string,
|
|
1000
|
+
{ promise: Promise<AgentHostResult>; kill: () => void; stateRoot?: string; batchId?: string }
|
|
1001
|
+
>();
|
|
875
1002
|
|
|
876
1003
|
/**
|
|
877
1004
|
* Kill a V2 merge agent if it's still running.
|
|
@@ -893,7 +1020,9 @@ export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): bool
|
|
|
893
1020
|
const snapshot = buildRegistrySnapshot(handle.stateRoot, handle.batchId);
|
|
894
1021
|
writeRegistrySnapshot(handle.stateRoot, snapshot);
|
|
895
1022
|
}
|
|
896
|
-
} catch {
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* best effort */
|
|
1025
|
+
}
|
|
897
1026
|
}
|
|
898
1027
|
activeMergeAgents.delete(sessionName);
|
|
899
1028
|
return true;
|
|
@@ -937,7 +1066,11 @@ export function reloadMergeTimeoutMs(configRoot: string, pointerConfigRoot?: str
|
|
|
937
1066
|
} catch (err: unknown) {
|
|
938
1067
|
// Config re-read is best-effort — fall back to default on failure
|
|
939
1068
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
940
|
-
execLog(
|
|
1069
|
+
execLog(
|
|
1070
|
+
"merge",
|
|
1071
|
+
"config-reload",
|
|
1072
|
+
`failed to re-read merge timeout from config: ${errMsg} — using default`,
|
|
1073
|
+
);
|
|
941
1074
|
return MERGE_TIMEOUT_MS;
|
|
942
1075
|
}
|
|
943
1076
|
}
|
|
@@ -989,11 +1122,16 @@ export async function waitForMergeResult(
|
|
|
989
1122
|
try {
|
|
990
1123
|
const lateResult = await parseMergeResultAsync(resultPath);
|
|
991
1124
|
if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) {
|
|
992
|
-
execLog(
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1125
|
+
execLog(
|
|
1126
|
+
"merge",
|
|
1127
|
+
sessionName,
|
|
1128
|
+
"merge agent slow but succeeded — accepting result at timeout",
|
|
1129
|
+
{
|
|
1130
|
+
status: lateResult.status,
|
|
1131
|
+
elapsed,
|
|
1132
|
+
timeoutMs,
|
|
1133
|
+
},
|
|
1134
|
+
);
|
|
997
1135
|
// Clean up agent (may still be running post-write)
|
|
998
1136
|
killMergeAgentV2(sessionName, true);
|
|
999
1137
|
return lateResult;
|
|
@@ -1012,8 +1150,8 @@ export async function waitForMergeResult(
|
|
|
1012
1150
|
throw new MergeError(
|
|
1013
1151
|
"MERGE_TIMEOUT",
|
|
1014
1152
|
`Merge agent '${sessionName}' did not produce a result within ` +
|
|
1015
|
-
|
|
1016
|
-
|
|
1153
|
+
`${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` +
|
|
1154
|
+
`Check the merge request and agent logs.`,
|
|
1017
1155
|
);
|
|
1018
1156
|
}
|
|
1019
1157
|
|
|
@@ -1032,7 +1170,11 @@ export async function waitForMergeResult(
|
|
|
1032
1170
|
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
1033
1171
|
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
1034
1172
|
if (existsSync(resultPath)) {
|
|
1035
|
-
try {
|
|
1173
|
+
try {
|
|
1174
|
+
return await parseMergeResultAsync(resultPath);
|
|
1175
|
+
} catch {
|
|
1176
|
+
/* give up */
|
|
1177
|
+
}
|
|
1036
1178
|
}
|
|
1037
1179
|
}
|
|
1038
1180
|
}
|
|
@@ -1051,14 +1193,18 @@ export async function waitForMergeResult(
|
|
|
1051
1193
|
} else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
|
|
1052
1194
|
// Grace period expired — one final check
|
|
1053
1195
|
if (existsSync(resultPath)) {
|
|
1054
|
-
try {
|
|
1196
|
+
try {
|
|
1197
|
+
return await parseMergeResultAsync(resultPath);
|
|
1198
|
+
} catch {
|
|
1199
|
+
/* fall through */
|
|
1200
|
+
}
|
|
1055
1201
|
}
|
|
1056
1202
|
|
|
1057
1203
|
throw new MergeError(
|
|
1058
1204
|
"MERGE_SESSION_DIED",
|
|
1059
1205
|
`Merge agent '${sessionName}' exited without writing ` +
|
|
1060
|
-
|
|
1061
|
-
|
|
1206
|
+
`a result file to '${resultPath}'. The merge may have crashed. ` +
|
|
1207
|
+
`Check agent logs for diagnostics.`,
|
|
1062
1208
|
);
|
|
1063
1209
|
}
|
|
1064
1210
|
}
|
|
@@ -1081,25 +1227,28 @@ export async function waitForMergeResult(
|
|
|
1081
1227
|
* @param repoRoot - Main repository root for git operations
|
|
1082
1228
|
* @param context - Logging context (e.g., "W1" for wave 1)
|
|
1083
1229
|
*/
|
|
1084
|
-
function forceRemoveMergeWorktree(
|
|
1085
|
-
mergeWorkDir: string,
|
|
1086
|
-
repoRoot: string,
|
|
1087
|
-
context: string,
|
|
1088
|
-
): void {
|
|
1230
|
+
function forceRemoveMergeWorktree(mergeWorkDir: string, repoRoot: string, context: string): void {
|
|
1089
1231
|
if (!existsSync(mergeWorkDir)) return;
|
|
1090
1232
|
|
|
1091
1233
|
// Try git worktree remove --force first
|
|
1092
|
-
const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], {
|
|
1234
|
+
const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], {
|
|
1235
|
+
cwd: repoRoot,
|
|
1236
|
+
});
|
|
1093
1237
|
if (removeResult.status === 0) {
|
|
1094
1238
|
return;
|
|
1095
1239
|
}
|
|
1096
1240
|
|
|
1097
1241
|
// Fallback: force-remove the directory and prune git worktree state
|
|
1098
1242
|
const stderr = removeResult.stderr?.toString().trim() || "";
|
|
1099
|
-
execLog(
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1243
|
+
execLog(
|
|
1244
|
+
"merge",
|
|
1245
|
+
context,
|
|
1246
|
+
`git worktree remove failed for merge worktree, applying force cleanup`,
|
|
1247
|
+
{
|
|
1248
|
+
error: stderr.slice(0, 200),
|
|
1249
|
+
path: mergeWorkDir,
|
|
1250
|
+
},
|
|
1251
|
+
);
|
|
1103
1252
|
|
|
1104
1253
|
try {
|
|
1105
1254
|
rmSync(mergeWorkDir, { recursive: true, force: true });
|
|
@@ -1107,14 +1256,18 @@ function forceRemoveMergeWorktree(
|
|
|
1107
1256
|
} catch (rmErr: unknown) {
|
|
1108
1257
|
// Node's rmSync may fail on Windows reserved-name files — try OS-level removal
|
|
1109
1258
|
const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
|
|
1110
|
-
execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, {
|
|
1259
|
+
execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, {
|
|
1260
|
+
error: rmMsg,
|
|
1261
|
+
});
|
|
1111
1262
|
try {
|
|
1112
1263
|
if (process.platform === "win32") {
|
|
1113
1264
|
execSync(`rd /s /q "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1114
1265
|
} else {
|
|
1115
1266
|
execSync(`rm -rf "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1116
1267
|
}
|
|
1117
|
-
execLog("merge", context, `OS-level removal of merge worktree succeeded`, {
|
|
1268
|
+
execLog("merge", context, `OS-level removal of merge worktree succeeded`, {
|
|
1269
|
+
path: mergeWorkDir,
|
|
1270
|
+
});
|
|
1118
1271
|
} catch (osErr: unknown) {
|
|
1119
1272
|
const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
|
|
1120
1273
|
execLog("merge", context, `OS-level removal also failed — manual cleanup needed`, {
|
|
@@ -1149,17 +1302,11 @@ function forceRemoveMergeWorktree(
|
|
|
1149
1302
|
*/
|
|
1150
1303
|
function persistTransactionRecord(record: TransactionRecord, stateRoot: string): string | null {
|
|
1151
1304
|
try {
|
|
1152
|
-
const repoSlug = record.repoId
|
|
1153
|
-
? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
1154
|
-
: "default";
|
|
1305
|
+
const repoSlug = record.repoId ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_") : "default";
|
|
1155
1306
|
const verifyDir = join(stateRoot, ".pi", "verification", record.opId);
|
|
1156
1307
|
mkdirSync(verifyDir, { recursive: true });
|
|
1157
1308
|
const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`;
|
|
1158
|
-
writeFileSync(
|
|
1159
|
-
join(verifyDir, fileName),
|
|
1160
|
-
JSON.stringify(record, null, 2),
|
|
1161
|
-
"utf-8",
|
|
1162
|
-
);
|
|
1309
|
+
writeFileSync(join(verifyDir, fileName), JSON.stringify(record, null, 2), "utf-8");
|
|
1163
1310
|
execLog("merge", `W${record.waveIndex}`, `transaction record persisted`, {
|
|
1164
1311
|
file: fileName,
|
|
1165
1312
|
status: record.status,
|
|
@@ -1229,11 +1376,7 @@ function runPostMergeVerification(
|
|
|
1229
1376
|
// when mergeWaveByRepo() calls mergeWave() once per repo group.
|
|
1230
1377
|
const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
|
|
1231
1378
|
const postFileName = `post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`;
|
|
1232
|
-
writeFileSync(
|
|
1233
|
-
join(verifyDir, postFileName),
|
|
1234
|
-
JSON.stringify(postMerge, null, 2),
|
|
1235
|
-
"utf-8",
|
|
1236
|
-
);
|
|
1379
|
+
writeFileSync(join(verifyDir, postFileName), JSON.stringify(postMerge, null, 2), "utf-8");
|
|
1237
1380
|
} catch {
|
|
1238
1381
|
// Best effort — persistence failure doesn't block verification
|
|
1239
1382
|
}
|
|
@@ -1264,7 +1407,7 @@ function runPostMergeVerification(
|
|
|
1264
1407
|
// Only when flakyReruns > 0 (0 = disabled — any new failure immediately blocks)
|
|
1265
1408
|
if (flakyReruns > 0) {
|
|
1266
1409
|
// Identify which commandIds produced new failures
|
|
1267
|
-
const failedCommandIds = new Set(diff.newFailures.map(fp => fp.commandId));
|
|
1410
|
+
const failedCommandIds = new Set(diff.newFailures.map((fp) => fp.commandId));
|
|
1268
1411
|
const rerunCommands: Record<string, string> = {};
|
|
1269
1412
|
for (const cmdId of failedCommandIds) {
|
|
1270
1413
|
if (testingCommands[cmdId]) {
|
|
@@ -1275,10 +1418,15 @@ function runPostMergeVerification(
|
|
|
1275
1418
|
// Re-run up to flakyReruns times; break early if failures clear
|
|
1276
1419
|
let clearedOnRerun = false;
|
|
1277
1420
|
for (let attempt = 0; attempt < flakyReruns; attempt++) {
|
|
1278
|
-
execLog(
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1421
|
+
execLog(
|
|
1422
|
+
"merge",
|
|
1423
|
+
sessionName,
|
|
1424
|
+
`new failures detected — running flaky re-run ${attempt + 1}/${flakyReruns}`,
|
|
1425
|
+
{
|
|
1426
|
+
failedCommands: [...failedCommandIds].join(", "),
|
|
1427
|
+
rerunCount: Object.keys(rerunCommands).length,
|
|
1428
|
+
},
|
|
1429
|
+
);
|
|
1282
1430
|
|
|
1283
1431
|
const rerunResults = runVerificationCommands(rerunCommands, mergeWorkDir);
|
|
1284
1432
|
|
|
@@ -1292,12 +1440,18 @@ function runPostMergeVerification(
|
|
|
1292
1440
|
|
|
1293
1441
|
// Re-diff: compare baseline against re-run results for the failed commands only
|
|
1294
1442
|
// Filter baseline fingerprints to only the commands we re-ran
|
|
1295
|
-
const baselineForRerun = baseline.fingerprints.filter(fp =>
|
|
1443
|
+
const baselineForRerun = baseline.fingerprints.filter((fp) =>
|
|
1444
|
+
failedCommandIds.has(fp.commandId),
|
|
1445
|
+
);
|
|
1296
1446
|
const rerunDiff = diffFingerprints(baselineForRerun, dedupedRerun);
|
|
1297
1447
|
|
|
1298
1448
|
if (rerunDiff.newFailures.length === 0) {
|
|
1299
1449
|
// Failures disappeared on re-run — flaky suspected
|
|
1300
|
-
execLog(
|
|
1450
|
+
execLog(
|
|
1451
|
+
"merge",
|
|
1452
|
+
sessionName,
|
|
1453
|
+
`flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`,
|
|
1454
|
+
);
|
|
1301
1455
|
clearedOnRerun = true;
|
|
1302
1456
|
break;
|
|
1303
1457
|
}
|
|
@@ -1306,11 +1460,10 @@ function runPostMergeVerification(
|
|
|
1306
1460
|
if (attempt === flakyReruns - 1) {
|
|
1307
1461
|
const summary = rerunDiff.newFailures
|
|
1308
1462
|
.slice(0, 5)
|
|
1309
|
-
.map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1463
|
+
.map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1310
1464
|
.join("; ");
|
|
1311
|
-
const truncated =
|
|
1312
|
-
? ` ... and ${rerunDiff.newFailures.length - 5} more`
|
|
1313
|
-
: "";
|
|
1465
|
+
const truncated =
|
|
1466
|
+
rerunDiff.newFailures.length > 5 ? ` ... and ${rerunDiff.newFailures.length - 5} more` : "";
|
|
1314
1467
|
|
|
1315
1468
|
return {
|
|
1316
1469
|
performed: true,
|
|
@@ -1340,11 +1493,10 @@ function runPostMergeVerification(
|
|
|
1340
1493
|
// flakyReruns === 0 or fallthrough: new failures block immediately
|
|
1341
1494
|
const summary = diff.newFailures
|
|
1342
1495
|
.slice(0, 5)
|
|
1343
|
-
.map(fp => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1496
|
+
.map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1344
1497
|
.join("; ");
|
|
1345
|
-
const truncated =
|
|
1346
|
-
? ` ... and ${diff.newFailures.length - 5} more`
|
|
1347
|
-
: "";
|
|
1498
|
+
const truncated =
|
|
1499
|
+
diff.newFailures.length > 5 ? ` ... and ${diff.newFailures.length - 5} more` : "";
|
|
1348
1500
|
|
|
1349
1501
|
return {
|
|
1350
1502
|
performed: true,
|
|
@@ -1427,14 +1579,12 @@ export async function mergeWave(
|
|
|
1427
1579
|
// TP-078: When forceMixedOutcome is true, lanes with both succeeded and
|
|
1428
1580
|
// failed/stalled tasks are also considered mergeable. This allows the
|
|
1429
1581
|
// orch_force_merge tool to merge succeeded commits from mixed-outcome lanes.
|
|
1430
|
-
const mergeableLanes = completedLanes.filter(lane => {
|
|
1582
|
+
const mergeableLanes = completedLanes.filter((lane) => {
|
|
1431
1583
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1432
1584
|
if (!outcome) return false;
|
|
1433
1585
|
|
|
1434
|
-
const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
|
|
1435
|
-
const hasHardFailure = outcome.tasks.some(
|
|
1436
|
-
t => t.status === "failed" || t.status === "stalled",
|
|
1437
|
-
);
|
|
1586
|
+
const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
|
|
1587
|
+
const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
|
|
1438
1588
|
|
|
1439
1589
|
if (forceMixedOutcome) {
|
|
1440
1590
|
// In force mode, merge any lane with at least one succeeded task
|
|
@@ -1449,11 +1599,11 @@ export async function mergeWave(
|
|
|
1449
1599
|
// partial progress (STATUS.md updates) that should be staged on the target
|
|
1450
1600
|
// branch so it survives integration. Stage artifacts directly without
|
|
1451
1601
|
// creating a full merge worktree.
|
|
1452
|
-
const skippedOnlyLanes = completedLanes.filter(lane => {
|
|
1602
|
+
const skippedOnlyLanes = completedLanes.filter((lane) => {
|
|
1453
1603
|
if (!lane.worktreePath) return false;
|
|
1454
1604
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1455
1605
|
if (!outcome) return false;
|
|
1456
|
-
return outcome.tasks.some(t => t.status === "skipped");
|
|
1606
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
1457
1607
|
});
|
|
1458
1608
|
if (skippedOnlyLanes.length > 0) {
|
|
1459
1609
|
stageSkippedArtifactsToTargetBranch(skippedOnlyLanes, waveIndex, repoRoot, targetBranch);
|
|
@@ -1477,21 +1627,22 @@ export async function mergeWave(
|
|
|
1477
1627
|
// These lanes won't have their branches merged, but their task artifacts
|
|
1478
1628
|
// (STATUS.md, .reviews) should still be staged so partial progress is preserved
|
|
1479
1629
|
// through integration. Only lanes with worktree paths can contribute artifacts.
|
|
1480
|
-
const mergeableLaneNumbers = new Set(mergeableLanes.map(l => l.laneNumber));
|
|
1481
|
-
const skippedArtifactLanes = completedLanes.filter(lane => {
|
|
1630
|
+
const mergeableLaneNumbers = new Set(mergeableLanes.map((l) => l.laneNumber));
|
|
1631
|
+
const skippedArtifactLanes = completedLanes.filter((lane) => {
|
|
1482
1632
|
if (mergeableLaneNumbers.has(lane.laneNumber)) return false;
|
|
1483
1633
|
if (!lane.worktreePath) return false;
|
|
1484
1634
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1485
1635
|
if (!outcome) return false;
|
|
1486
|
-
return outcome.tasks.some(t => t.status === "skipped");
|
|
1636
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
1487
1637
|
});
|
|
1488
1638
|
|
|
1489
1639
|
execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, {
|
|
1490
1640
|
order: config.merge.order,
|
|
1491
|
-
lanes: orderedLanes.map(l => l.laneNumber).join(","),
|
|
1492
|
-
skippedArtifactLanes:
|
|
1493
|
-
|
|
1494
|
-
|
|
1641
|
+
lanes: orderedLanes.map((l) => l.laneNumber).join(","),
|
|
1642
|
+
skippedArtifactLanes:
|
|
1643
|
+
skippedArtifactLanes.length > 0
|
|
1644
|
+
? skippedArtifactLanes.map((l) => l.laneNumber).join(",")
|
|
1645
|
+
: undefined,
|
|
1495
1646
|
});
|
|
1496
1647
|
|
|
1497
1648
|
// ── Create isolated merge worktree ──────────────────────────────
|
|
@@ -1513,7 +1664,9 @@ export async function mergeWave(
|
|
|
1513
1664
|
}
|
|
1514
1665
|
try {
|
|
1515
1666
|
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1516
|
-
} catch {
|
|
1667
|
+
} catch {
|
|
1668
|
+
/* branch may not exist */
|
|
1669
|
+
}
|
|
1517
1670
|
|
|
1518
1671
|
// Create temp branch at target branch HEAD, then worktree
|
|
1519
1672
|
const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot });
|
|
@@ -1521,20 +1674,28 @@ export async function mergeWave(
|
|
|
1521
1674
|
const err = branchResult.stderr?.toString().trim() || "unknown error";
|
|
1522
1675
|
execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`);
|
|
1523
1676
|
return {
|
|
1524
|
-
waveIndex,
|
|
1525
|
-
|
|
1677
|
+
waveIndex,
|
|
1678
|
+
status: "failed",
|
|
1679
|
+
laneResults: [],
|
|
1680
|
+
failedLane: null,
|
|
1681
|
+
failureReason: `Failed to create merge temp branch: ${err}`,
|
|
1526
1682
|
totalDurationMs: Date.now() - startTime,
|
|
1527
1683
|
};
|
|
1528
1684
|
}
|
|
1529
1685
|
|
|
1530
|
-
const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], {
|
|
1686
|
+
const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], {
|
|
1687
|
+
cwd: repoRoot,
|
|
1688
|
+
});
|
|
1531
1689
|
if (wtResult.status !== 0) {
|
|
1532
1690
|
const err = wtResult.stderr?.toString().trim() || "unknown error";
|
|
1533
1691
|
execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`);
|
|
1534
1692
|
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1535
1693
|
return {
|
|
1536
|
-
waveIndex,
|
|
1537
|
-
|
|
1694
|
+
waveIndex,
|
|
1695
|
+
status: "failed",
|
|
1696
|
+
laneResults: [],
|
|
1697
|
+
failedLane: null,
|
|
1698
|
+
failureReason: `Failed to create merge worktree: ${err}`,
|
|
1538
1699
|
totalDurationMs: Date.now() - startTime,
|
|
1539
1700
|
};
|
|
1540
1701
|
}
|
|
@@ -1559,18 +1720,33 @@ export async function mergeWave(
|
|
|
1559
1720
|
// Verification is enabled but no testing commands configured — treat as
|
|
1560
1721
|
// baseline-unavailable. Strict/permissive handling below.
|
|
1561
1722
|
if (verificationMode === "strict") {
|
|
1562
|
-
execLog(
|
|
1723
|
+
execLog(
|
|
1724
|
+
"merge",
|
|
1725
|
+
`W${waveIndex}`,
|
|
1726
|
+
"verification enabled but no testing commands configured — strict mode: failing merge",
|
|
1727
|
+
);
|
|
1563
1728
|
// Clean up worktree and temp branch before returning failure
|
|
1564
1729
|
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1565
|
-
try {
|
|
1730
|
+
try {
|
|
1731
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1732
|
+
} catch {
|
|
1733
|
+
/* best effort */
|
|
1734
|
+
}
|
|
1566
1735
|
return {
|
|
1567
|
-
waveIndex,
|
|
1736
|
+
waveIndex,
|
|
1737
|
+
status: "failed",
|
|
1738
|
+
laneResults: [],
|
|
1568
1739
|
failedLane: null,
|
|
1569
|
-
failureReason:
|
|
1740
|
+
failureReason:
|
|
1741
|
+
"Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands",
|
|
1570
1742
|
totalDurationMs: Date.now() - startTime,
|
|
1571
1743
|
};
|
|
1572
1744
|
} else {
|
|
1573
|
-
execLog(
|
|
1745
|
+
execLog(
|
|
1746
|
+
"merge",
|
|
1747
|
+
`W${waveIndex}`,
|
|
1748
|
+
"verification enabled but no testing commands configured — permissive mode: continuing without verification",
|
|
1749
|
+
);
|
|
1574
1750
|
}
|
|
1575
1751
|
}
|
|
1576
1752
|
|
|
@@ -1591,11 +1767,7 @@ export async function mergeWave(
|
|
|
1591
1767
|
// when mergeWaveByRepo() calls mergeWave() once per repo group.
|
|
1592
1768
|
const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
|
|
1593
1769
|
const baselineFileName = `baseline-b${batchId}-w${waveIndex}${repoSuffix}.json`;
|
|
1594
|
-
writeFileSync(
|
|
1595
|
-
join(verifyDir, baselineFileName),
|
|
1596
|
-
JSON.stringify(baseline, null, 2),
|
|
1597
|
-
"utf-8",
|
|
1598
|
-
);
|
|
1770
|
+
writeFileSync(join(verifyDir, baselineFileName), JSON.stringify(baseline, null, 2), "utf-8");
|
|
1599
1771
|
|
|
1600
1772
|
execLog("merge", `W${waveIndex}`, "verification baseline captured", {
|
|
1601
1773
|
fingerprints: baseline.fingerprints.length,
|
|
@@ -1610,17 +1782,28 @@ export async function mergeWave(
|
|
|
1610
1782
|
});
|
|
1611
1783
|
// Clean up worktree and temp branch before returning failure
|
|
1612
1784
|
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1613
|
-
try {
|
|
1785
|
+
try {
|
|
1786
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1787
|
+
} catch {
|
|
1788
|
+
/* best effort */
|
|
1789
|
+
}
|
|
1614
1790
|
return {
|
|
1615
|
-
waveIndex,
|
|
1791
|
+
waveIndex,
|
|
1792
|
+
status: "failed",
|
|
1793
|
+
laneResults: [],
|
|
1616
1794
|
failedLane: null,
|
|
1617
1795
|
failureReason: `Verification baseline capture failed (strict mode): ${errMsg}`,
|
|
1618
1796
|
totalDurationMs: Date.now() - startTime,
|
|
1619
1797
|
};
|
|
1620
1798
|
}
|
|
1621
|
-
execLog(
|
|
1622
|
-
|
|
1623
|
-
|
|
1799
|
+
execLog(
|
|
1800
|
+
"merge",
|
|
1801
|
+
`W${waveIndex}`,
|
|
1802
|
+
`baseline capture failed — permissive mode: continuing without baseline verification`,
|
|
1803
|
+
{
|
|
1804
|
+
error: errMsg,
|
|
1805
|
+
},
|
|
1806
|
+
);
|
|
1624
1807
|
// Permissive: baseline capture failure is non-fatal — merge proceeds without
|
|
1625
1808
|
// orchestrator-side verification. Merge-agent verification (merge.verify)
|
|
1626
1809
|
// still applies independently.
|
|
@@ -1659,7 +1842,10 @@ export async function mergeWave(
|
|
|
1659
1842
|
// This is the rollback target if verification detects new failures.
|
|
1660
1843
|
let baseHEAD = "";
|
|
1661
1844
|
{
|
|
1662
|
-
const headResult = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
1845
|
+
const headResult = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
1846
|
+
cwd: mergeWorkDir,
|
|
1847
|
+
encoding: "utf-8",
|
|
1848
|
+
});
|
|
1663
1849
|
if (headResult.status === 0) {
|
|
1664
1850
|
baseHEAD = headResult.stdout.trim();
|
|
1665
1851
|
}
|
|
@@ -1668,7 +1854,10 @@ export async function mergeWave(
|
|
|
1668
1854
|
// ── TP-033: Capture laneHEAD (source branch tip being merged in) ──
|
|
1669
1855
|
let laneHEAD = "";
|
|
1670
1856
|
{
|
|
1671
|
-
const laneRef = spawnSync("git", ["rev-parse", lane.branch], {
|
|
1857
|
+
const laneRef = spawnSync("git", ["rev-parse", lane.branch], {
|
|
1858
|
+
cwd: repoRoot,
|
|
1859
|
+
encoding: "utf-8",
|
|
1860
|
+
});
|
|
1672
1861
|
if (laneRef.status === 0) {
|
|
1673
1862
|
laneHEAD = laneRef.stdout.trim();
|
|
1674
1863
|
}
|
|
@@ -1730,28 +1919,62 @@ export async function mergeWave(
|
|
|
1730
1919
|
// Apply 2× backoff: double the timeout for each retry attempt
|
|
1731
1920
|
currentTimeoutMs = freshTimeoutMs * Math.pow(2, attempt);
|
|
1732
1921
|
|
|
1733
|
-
execLog(
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
attempt
|
|
1737
|
-
|
|
1922
|
+
execLog(
|
|
1923
|
+
"merge",
|
|
1924
|
+
sessionName,
|
|
1925
|
+
`retry ${attempt}/${MERGE_TIMEOUT_MAX_RETRIES} after timeout — respawning merge agent`,
|
|
1926
|
+
{
|
|
1927
|
+
newTimeoutMs: currentTimeoutMs,
|
|
1928
|
+
newTimeoutMin: Math.round(currentTimeoutMs / 60_000),
|
|
1929
|
+
attempt,
|
|
1930
|
+
},
|
|
1931
|
+
);
|
|
1738
1932
|
|
|
1739
1933
|
// Clean up stale result file from prior attempt
|
|
1740
1934
|
if (existsSync(resultFilePath)) {
|
|
1741
|
-
try {
|
|
1935
|
+
try {
|
|
1936
|
+
unlinkSync(resultFilePath);
|
|
1937
|
+
} catch {
|
|
1938
|
+
/* best effort */
|
|
1939
|
+
}
|
|
1742
1940
|
}
|
|
1743
1941
|
|
|
1744
1942
|
// Re-spawn merge agent for the retry.
|
|
1745
1943
|
// Kill previous V2 agent handle to prevent orphan/duplicate.
|
|
1746
1944
|
killMergeAgentV2(sessionName);
|
|
1747
|
-
await spawnMergeAgentV2(
|
|
1945
|
+
await spawnMergeAgentV2(
|
|
1946
|
+
sessionName,
|
|
1947
|
+
repoRoot,
|
|
1948
|
+
mergeWorkDir,
|
|
1949
|
+
requestFilePath,
|
|
1950
|
+
config,
|
|
1951
|
+
stateRoot,
|
|
1952
|
+
agentRoot,
|
|
1953
|
+
batchId,
|
|
1954
|
+
waveIndex,
|
|
1955
|
+
);
|
|
1748
1956
|
} else {
|
|
1749
1957
|
// First attempt: spawn merge agent (Runtime V2)
|
|
1750
|
-
await spawnMergeAgentV2(
|
|
1958
|
+
await spawnMergeAgentV2(
|
|
1959
|
+
sessionName,
|
|
1960
|
+
repoRoot,
|
|
1961
|
+
mergeWorkDir,
|
|
1962
|
+
requestFilePath,
|
|
1963
|
+
config,
|
|
1964
|
+
stateRoot,
|
|
1965
|
+
agentRoot,
|
|
1966
|
+
batchId,
|
|
1967
|
+
waveIndex,
|
|
1968
|
+
);
|
|
1751
1969
|
}
|
|
1752
1970
|
|
|
1753
1971
|
try {
|
|
1754
|
-
mergeResult = await waitForMergeResult(
|
|
1972
|
+
mergeResult = await waitForMergeResult(
|
|
1973
|
+
resultFilePath,
|
|
1974
|
+
sessionName,
|
|
1975
|
+
currentTimeoutMs,
|
|
1976
|
+
runtimeBackend,
|
|
1977
|
+
);
|
|
1755
1978
|
// TP-056: Deregister session from health monitor on completion
|
|
1756
1979
|
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1757
1980
|
lastTimeoutError = null;
|
|
@@ -1820,11 +2043,12 @@ export async function mergeWave(
|
|
|
1820
2043
|
case "CONFLICT_UNRESOLVED":
|
|
1821
2044
|
execLog("merge", sessionName, "merge failed — unresolved conflicts", {
|
|
1822
2045
|
conflictCount: mergeResult.conflicts.length,
|
|
1823
|
-
files: mergeResult.conflicts.map(c => c.file).join(", "),
|
|
2046
|
+
files: mergeResult.conflicts.map((c) => c.file).join(", "),
|
|
1824
2047
|
});
|
|
1825
2048
|
failedLane = lane.laneNumber;
|
|
1826
|
-
failureReason =
|
|
1827
|
-
|
|
2049
|
+
failureReason =
|
|
2050
|
+
`Unresolved merge conflicts in lane ${lane.laneNumber}: ` +
|
|
2051
|
+
mergeResult.conflicts.map((c) => c.file).join(", ");
|
|
1828
2052
|
break;
|
|
1829
2053
|
|
|
1830
2054
|
case "BUILD_FAILURE":
|
|
@@ -1838,7 +2062,8 @@ export async function mergeWave(
|
|
|
1838
2062
|
baselineActive: !!baseline,
|
|
1839
2063
|
});
|
|
1840
2064
|
failedLane = lane.laneNumber;
|
|
1841
|
-
failureReason =
|
|
2065
|
+
failureReason =
|
|
2066
|
+
`Post-merge verification failed in lane ${lane.laneNumber}: ` +
|
|
1842
2067
|
mergeResult.verification.output.slice(0, 500);
|
|
1843
2068
|
break;
|
|
1844
2069
|
}
|
|
@@ -1846,7 +2071,10 @@ export async function mergeWave(
|
|
|
1846
2071
|
// ── TP-033: Capture mergedHEAD after successful merge commit ──
|
|
1847
2072
|
let mergedHEAD: string | null = null;
|
|
1848
2073
|
if (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") {
|
|
1849
|
-
const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
2074
|
+
const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
2075
|
+
cwd: mergeWorkDir,
|
|
2076
|
+
encoding: "utf-8",
|
|
2077
|
+
});
|
|
1850
2078
|
if (postMergeRef.status === 0) {
|
|
1851
2079
|
mergedHEAD = postMergeRef.stdout.trim();
|
|
1852
2080
|
}
|
|
@@ -1916,7 +2144,8 @@ export async function mergeWave(
|
|
|
1916
2144
|
// ref advancement MUST NOT proceed for ANY lane, because the temp
|
|
1917
2145
|
// branch HEAD includes the unverified commit.
|
|
1918
2146
|
const resetErr = resetResult.stderr?.toString().trim() || "unknown error";
|
|
1919
|
-
laneResult.error =
|
|
2147
|
+
laneResult.error =
|
|
2148
|
+
`verification_new_failure: rollback reset failed (${resetErr}) — ` +
|
|
1920
2149
|
`temp branch may contain failing merge commit, advancement blocked`;
|
|
1921
2150
|
blockAdvancement = true;
|
|
1922
2151
|
txnStatus = "rollback_failed";
|
|
@@ -1931,15 +2160,21 @@ export async function mergeWave(
|
|
|
1931
2160
|
];
|
|
1932
2161
|
rollbackFailed = true;
|
|
1933
2162
|
|
|
1934
|
-
execLog(
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2163
|
+
execLog(
|
|
2164
|
+
"merge",
|
|
2165
|
+
sessionName,
|
|
2166
|
+
`CRITICAL: rollback reset failed: ${resetErr} — safe-stop triggered`,
|
|
2167
|
+
{
|
|
2168
|
+
preLaneHead: preLaneHead.slice(0, 8),
|
|
2169
|
+
recoveryCommands: txnRecoveryCommands,
|
|
2170
|
+
},
|
|
2171
|
+
);
|
|
1938
2172
|
}
|
|
1939
2173
|
} else {
|
|
1940
2174
|
// TP-032 R006-2: No pre-lane HEAD captured — cannot roll back.
|
|
1941
2175
|
// Block advancement since the bad commit cannot be removed.
|
|
1942
|
-
laneResult.error =
|
|
2176
|
+
laneResult.error =
|
|
2177
|
+
`verification_new_failure: no pre-lane HEAD available for rollback — ` +
|
|
1943
2178
|
`advancement blocked`;
|
|
1944
2179
|
blockAdvancement = true;
|
|
1945
2180
|
txnStatus = "rollback_failed";
|
|
@@ -1957,18 +2192,28 @@ export async function mergeWave(
|
|
|
1957
2192
|
];
|
|
1958
2193
|
rollbackFailed = true;
|
|
1959
2194
|
|
|
1960
|
-
execLog(
|
|
2195
|
+
execLog(
|
|
2196
|
+
"merge",
|
|
2197
|
+
sessionName,
|
|
2198
|
+
"CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered",
|
|
2199
|
+
);
|
|
1961
2200
|
}
|
|
1962
2201
|
|
|
1963
2202
|
failedLane = lane.laneNumber;
|
|
1964
|
-
failureReason =
|
|
2203
|
+
failureReason =
|
|
2204
|
+
`Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` +
|
|
1965
2205
|
`in lane ${lane.laneNumber} (${verificationResult.preExistingCount} pre-existing). ` +
|
|
1966
2206
|
verificationResult.newFailureSummary.slice(0, 300);
|
|
1967
2207
|
} else if (verificationResult.classification === "flaky_suspected") {
|
|
1968
|
-
execLog(
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2208
|
+
execLog(
|
|
2209
|
+
"merge",
|
|
2210
|
+
sessionName,
|
|
2211
|
+
"flaky test suspected — failures disappeared on re-run (warning only)",
|
|
2212
|
+
{
|
|
2213
|
+
newFailures: verificationResult.newFailureCount,
|
|
2214
|
+
flakyRerun: true,
|
|
2215
|
+
},
|
|
2216
|
+
);
|
|
1972
2217
|
// Warning only — does not block merge advancement
|
|
1973
2218
|
} else {
|
|
1974
2219
|
execLog("merge", sessionName, "orchestrator-side verification passed", {
|
|
@@ -2001,7 +2246,6 @@ export async function mergeWave(
|
|
|
2001
2246
|
|
|
2002
2247
|
// Stop merging if this lane failed
|
|
2003
2248
|
if (failedLane !== null) break;
|
|
2004
|
-
|
|
2005
2249
|
} catch (err: unknown) {
|
|
2006
2250
|
// Clean up request file on error
|
|
2007
2251
|
try {
|
|
@@ -2080,7 +2324,7 @@ export async function mergeWave(
|
|
|
2080
2324
|
if (!existsSync(rootDir)) return [];
|
|
2081
2325
|
const files: string[] = [];
|
|
2082
2326
|
const walk = (dir: string): void => {
|
|
2083
|
-
let entries;
|
|
2327
|
+
let entries: Dirent[];
|
|
2084
2328
|
try {
|
|
2085
2329
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
2086
2330
|
} catch {
|
|
@@ -2106,7 +2350,7 @@ export async function mergeWave(
|
|
|
2106
2350
|
// because their code was not merged; staging .DONE would create false
|
|
2107
2351
|
// completion markers on the orch branch.
|
|
2108
2352
|
const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"];
|
|
2109
|
-
const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map(l => l.laneNumber));
|
|
2353
|
+
const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map((l) => l.laneNumber));
|
|
2110
2354
|
|
|
2111
2355
|
// Include both merged lanes and skipped-artifact lanes in staging.
|
|
2112
2356
|
const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes];
|
|
@@ -2117,9 +2361,14 @@ export async function mergeWave(
|
|
|
2117
2361
|
|
|
2118
2362
|
for (const allocTask of lane.tasks) {
|
|
2119
2363
|
if (!allocTask.task?.taskFolder?.trim()) {
|
|
2120
|
-
execLog(
|
|
2121
|
-
|
|
2122
|
-
|
|
2364
|
+
execLog(
|
|
2365
|
+
"merge",
|
|
2366
|
+
`W${waveIndex}`,
|
|
2367
|
+
`skipping task with missing taskFolder (possibly dynamically expanded)`,
|
|
2368
|
+
{
|
|
2369
|
+
taskId: allocTask.taskId,
|
|
2370
|
+
},
|
|
2371
|
+
);
|
|
2123
2372
|
continue;
|
|
2124
2373
|
}
|
|
2125
2374
|
const absFolder = resolve(allocTask.task.taskFolder);
|
|
@@ -2190,7 +2439,10 @@ export async function mergeWave(
|
|
|
2190
2439
|
const resolvedSrc = resolve(repoRootSrc);
|
|
2191
2440
|
const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/");
|
|
2192
2441
|
if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) {
|
|
2193
|
-
execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, {
|
|
2442
|
+
execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, {
|
|
2443
|
+
path: relPath,
|
|
2444
|
+
src: repoRootSrc,
|
|
2445
|
+
});
|
|
2194
2446
|
continue;
|
|
2195
2447
|
}
|
|
2196
2448
|
srcPath = repoRootSrc;
|
|
@@ -2211,14 +2463,26 @@ export async function mergeWave(
|
|
|
2211
2463
|
}
|
|
2212
2464
|
|
|
2213
2465
|
if (staged > 0) {
|
|
2214
|
-
spawnSync(
|
|
2466
|
+
spawnSync(
|
|
2467
|
+
"git",
|
|
2468
|
+
[
|
|
2469
|
+
"commit",
|
|
2470
|
+
"-m",
|
|
2471
|
+
`checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`,
|
|
2472
|
+
],
|
|
2473
|
+
{ cwd: mergeWorkDir },
|
|
2474
|
+
);
|
|
2215
2475
|
execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
|
|
2216
2476
|
skipped,
|
|
2217
2477
|
preserved,
|
|
2218
2478
|
allowedCandidates: allowedRelPaths.size,
|
|
2219
2479
|
});
|
|
2220
2480
|
} else {
|
|
2221
|
-
execLog(
|
|
2481
|
+
execLog(
|
|
2482
|
+
"merge",
|
|
2483
|
+
`W${waveIndex}`,
|
|
2484
|
+
`no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`,
|
|
2485
|
+
);
|
|
2222
2486
|
}
|
|
2223
2487
|
|
|
2224
2488
|
// Keep both .DONE and STATUS.md in develop's working tree:
|
|
@@ -2235,13 +2499,19 @@ export async function mergeWave(
|
|
|
2235
2499
|
// that would be included in branch advancement — so we block entirely.
|
|
2236
2500
|
// Also exclude verification_new_failure lanes (with successful rollback) from
|
|
2237
2501
|
// success accounting: they have laneResult.error set, so !r.error filters them.
|
|
2238
|
-
const anySuccess =
|
|
2239
|
-
|
|
2240
|
-
|
|
2502
|
+
const anySuccess =
|
|
2503
|
+
!blockAdvancement &&
|
|
2504
|
+
laneResults.some(
|
|
2505
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2506
|
+
);
|
|
2241
2507
|
|
|
2242
2508
|
if (blockAdvancement) {
|
|
2243
|
-
execLog(
|
|
2244
|
-
"
|
|
2509
|
+
execLog(
|
|
2510
|
+
"merge",
|
|
2511
|
+
`W${waveIndex}`,
|
|
2512
|
+
"branch advancement BLOCKED due to verification rollback failure — " +
|
|
2513
|
+
"temp branch may contain unverified merge commit",
|
|
2514
|
+
);
|
|
2245
2515
|
}
|
|
2246
2516
|
|
|
2247
2517
|
if (anySuccess) {
|
|
@@ -2296,7 +2566,9 @@ export async function mergeWave(
|
|
|
2296
2566
|
} else {
|
|
2297
2567
|
// Not checked out — safe to use update-ref without touching the worktree.
|
|
2298
2568
|
// Use compare-and-swap (3-arg form) to guard against concurrent branch movement.
|
|
2299
|
-
const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], {
|
|
2569
|
+
const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], {
|
|
2570
|
+
cwd: repoRoot,
|
|
2571
|
+
});
|
|
2300
2572
|
const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : "";
|
|
2301
2573
|
|
|
2302
2574
|
const updateRefArgs = oldRef
|
|
@@ -2328,10 +2600,15 @@ export async function mergeWave(
|
|
|
2328
2600
|
// branch for manual recovery. The operator can use the recovery commands in
|
|
2329
2601
|
// the transaction record to restore consistency.
|
|
2330
2602
|
if (rollbackFailed) {
|
|
2331
|
-
execLog(
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2603
|
+
execLog(
|
|
2604
|
+
"merge",
|
|
2605
|
+
`W${waveIndex}`,
|
|
2606
|
+
"SAFE-STOP: preserving merge worktree and temp branch for recovery",
|
|
2607
|
+
{
|
|
2608
|
+
mergeWorkDir,
|
|
2609
|
+
tempBranch,
|
|
2610
|
+
},
|
|
2611
|
+
);
|
|
2335
2612
|
} else {
|
|
2336
2613
|
// TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted
|
|
2337
2614
|
// merge worktrees don't persist between attempts.
|
|
@@ -2340,7 +2617,9 @@ export async function mergeWave(
|
|
|
2340
2617
|
// Small delay to ensure worktree lock is released
|
|
2341
2618
|
await sleepAsync(500);
|
|
2342
2619
|
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
2343
|
-
} catch {
|
|
2620
|
+
} catch {
|
|
2621
|
+
/* best effort */
|
|
2622
|
+
}
|
|
2344
2623
|
}
|
|
2345
2624
|
|
|
2346
2625
|
// Determine overall status
|
|
@@ -2356,7 +2635,9 @@ export async function mergeWave(
|
|
|
2356
2635
|
const totalDurationMs = Date.now() - startTime;
|
|
2357
2636
|
|
|
2358
2637
|
execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
|
|
2359
|
-
mergedLanes: laneResults.filter(
|
|
2638
|
+
mergedLanes: laneResults.filter(
|
|
2639
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2640
|
+
).length,
|
|
2360
2641
|
failedLane: failedLane ?? 0,
|
|
2361
2642
|
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
2362
2643
|
});
|
|
@@ -2386,7 +2667,6 @@ export async function mergeWave(
|
|
|
2386
2667
|
return result;
|
|
2387
2668
|
}
|
|
2388
2669
|
|
|
2389
|
-
|
|
2390
2670
|
// ── Repo-Scoped Merge ────────────────────────────────────────────────
|
|
2391
2671
|
|
|
2392
2672
|
/**
|
|
@@ -2412,7 +2692,7 @@ export function groupLanesByRepo(
|
|
|
2412
2692
|
}
|
|
2413
2693
|
|
|
2414
2694
|
const sortedKeys = [...groupMap.keys()].sort();
|
|
2415
|
-
return sortedKeys.map(key => ({
|
|
2695
|
+
return sortedKeys.map((key) => ({
|
|
2416
2696
|
repoId: key || undefined,
|
|
2417
2697
|
lanes: groupMap.get(key)!,
|
|
2418
2698
|
}));
|
|
@@ -2477,13 +2757,11 @@ export async function mergeWaveByRepo(
|
|
|
2477
2757
|
|
|
2478
2758
|
// Filter to mergeable lanes (same criteria as mergeWave).
|
|
2479
2759
|
// TP-078: When forceMixedOutcome is true, lanes with mixed outcomes are also included.
|
|
2480
|
-
const mergeableLanes = completedLanes.filter(lane => {
|
|
2760
|
+
const mergeableLanes = completedLanes.filter((lane) => {
|
|
2481
2761
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2482
2762
|
if (!outcome) return false;
|
|
2483
|
-
const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
|
|
2484
|
-
const hasHardFailure = outcome.tasks.some(
|
|
2485
|
-
t => t.status === "failed" || t.status === "stalled",
|
|
2486
|
-
);
|
|
2763
|
+
const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
|
|
2764
|
+
const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
|
|
2487
2765
|
if (forceMixedOutcome) return hasSucceeded;
|
|
2488
2766
|
return hasSucceeded && !hasHardFailure;
|
|
2489
2767
|
});
|
|
@@ -2491,11 +2769,11 @@ export async function mergeWaveByRepo(
|
|
|
2491
2769
|
if (mergeableLanes.length === 0) {
|
|
2492
2770
|
// TP-171: Even when no lanes are mergeable, skipped-task lanes may have
|
|
2493
2771
|
// partial progress that should be staged on the target branch.
|
|
2494
|
-
const skippedOnlyLanes = completedLanes.filter(lane => {
|
|
2772
|
+
const skippedOnlyLanes = completedLanes.filter((lane) => {
|
|
2495
2773
|
if (!lane.worktreePath) return false;
|
|
2496
2774
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2497
2775
|
if (!outcome) return false;
|
|
2498
|
-
return outcome.tasks.some(t => t.status === "skipped");
|
|
2776
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
2499
2777
|
});
|
|
2500
2778
|
if (skippedOnlyLanes.length > 0) {
|
|
2501
2779
|
// In workspace mode, group skipped lanes by repo and stage per-repo.
|
|
@@ -2522,7 +2800,7 @@ export async function mergeWaveByRepo(
|
|
|
2522
2800
|
const repoGroups = groupLanesByRepo(mergeableLanes);
|
|
2523
2801
|
|
|
2524
2802
|
execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, {
|
|
2525
|
-
repos: repoGroups.map(g => g.repoId ?? "(default)").join(", "),
|
|
2803
|
+
repos: repoGroups.map((g) => g.repoId ?? "(default)").join(", "),
|
|
2526
2804
|
totalLanes: mergeableLanes.length,
|
|
2527
2805
|
});
|
|
2528
2806
|
|
|
@@ -2577,21 +2855,21 @@ export async function mergeWaveByRepo(
|
|
|
2577
2855
|
repoRoot: groupRepoRoot,
|
|
2578
2856
|
baseBranch: groupBaseBranch,
|
|
2579
2857
|
laneCount: group.lanes.length,
|
|
2580
|
-
lanes: group.lanes.map(l => l.laneNumber).join(","),
|
|
2858
|
+
lanes: group.lanes.map((l) => l.laneNumber).join(","),
|
|
2581
2859
|
});
|
|
2582
2860
|
|
|
2583
2861
|
// TP-171: Build allGroupLanes from all completed lanes for this repo
|
|
2584
2862
|
// (not just mergeable) so mergeWave() can compute skippedArtifactLanes.
|
|
2585
2863
|
const groupRepoId = group.repoId;
|
|
2586
|
-
const allGroupLanes = completedLanes.filter(l => (l.repoId ?? undefined) === groupRepoId);
|
|
2587
|
-
const allGroupLaneNumbers = new Set(allGroupLanes.map(l => l.laneNumber));
|
|
2864
|
+
const allGroupLanes = completedLanes.filter((l) => (l.repoId ?? undefined) === groupRepoId);
|
|
2865
|
+
const allGroupLaneNumbers = new Set(allGroupLanes.map((l) => l.laneNumber));
|
|
2588
2866
|
|
|
2589
2867
|
// Build a filtered WaveExecutionResult containing all lanes for this repo
|
|
2590
2868
|
// (including skipped-only lanes that aren't in the mergeable group).
|
|
2591
2869
|
const filteredWaveResult: WaveExecutionResult = {
|
|
2592
2870
|
...waveResult,
|
|
2593
|
-
laneResults: waveResult.laneResults.filter(lr => allGroupLaneNumbers.has(lr.laneNumber)),
|
|
2594
|
-
allocatedLanes: waveResult.allocatedLanes.filter(l => allGroupLaneNumbers.has(l.laneNumber)),
|
|
2871
|
+
laneResults: waveResult.laneResults.filter((lr) => allGroupLaneNumbers.has(lr.laneNumber)),
|
|
2872
|
+
allocatedLanes: waveResult.allocatedLanes.filter((l) => allGroupLaneNumbers.has(l.laneNumber)),
|
|
2595
2873
|
};
|
|
2596
2874
|
|
|
2597
2875
|
const groupResult = await mergeWave(
|
|
@@ -2658,9 +2936,14 @@ export async function mergeWaveByRepo(
|
|
|
2658
2936
|
const processedIndex = repoGroups.indexOf(group);
|
|
2659
2937
|
const remainingGroups = repoGroups.slice(processedIndex + 1);
|
|
2660
2938
|
if (remainingGroups.length > 0) {
|
|
2661
|
-
execLog(
|
|
2662
|
-
|
|
2663
|
-
|
|
2939
|
+
execLog(
|
|
2940
|
+
"merge",
|
|
2941
|
+
`W${waveIndex}`,
|
|
2942
|
+
`safe-stop: skipping ${remainingGroups.length} remaining repo group(s) after rollback failure`,
|
|
2943
|
+
{
|
|
2944
|
+
skippedRepos: remainingGroups.map((g) => g.repoId ?? "(default)").join(", "),
|
|
2945
|
+
},
|
|
2946
|
+
);
|
|
2664
2947
|
}
|
|
2665
2948
|
break;
|
|
2666
2949
|
}
|
|
@@ -2668,14 +2951,14 @@ export async function mergeWaveByRepo(
|
|
|
2668
2951
|
|
|
2669
2952
|
// TP-171: Stage artifacts for repos that have only skipped lanes but were
|
|
2670
2953
|
// not included in the mergeable repoGroups.
|
|
2671
|
-
const processedRepoIds = new Set(repoGroups.map(g => g.repoId));
|
|
2672
|
-
const skippedOnlyRepoLanes = completedLanes.filter(lane => {
|
|
2954
|
+
const processedRepoIds = new Set(repoGroups.map((g) => g.repoId));
|
|
2955
|
+
const skippedOnlyRepoLanes = completedLanes.filter((lane) => {
|
|
2673
2956
|
if (!lane.worktreePath) return false;
|
|
2674
2957
|
const laneRepoId = lane.repoId ?? undefined;
|
|
2675
2958
|
if (processedRepoIds.has(laneRepoId)) return false; // already handled by mergeWave
|
|
2676
2959
|
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2677
2960
|
if (!outcome) return false;
|
|
2678
|
-
return outcome.tasks.some(t => t.status === "skipped");
|
|
2961
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
2679
2962
|
});
|
|
2680
2963
|
// TP-171 R004: Gate artifact staging behind safe-stop — do not advance
|
|
2681
2964
|
// any branch refs when a rollback failure has been detected.
|
|
@@ -2694,7 +2977,7 @@ export async function mergeWaveByRepo(
|
|
|
2694
2977
|
// both lane-level failures AND repo setup failures with failedLane=null)
|
|
2695
2978
|
// TP-032 R006-3: Exclude verification_new_failure lanes from success determination
|
|
2696
2979
|
const anyLaneSucceeded = allLaneResults.some(
|
|
2697
|
-
r => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2980
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2698
2981
|
);
|
|
2699
2982
|
|
|
2700
2983
|
let status: MergeWaveResult["status"];
|
|
@@ -2710,8 +2993,10 @@ export async function mergeWaveByRepo(
|
|
|
2710
2993
|
|
|
2711
2994
|
execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
|
|
2712
2995
|
repoCount: repoOutcomes.length,
|
|
2713
|
-
repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
|
|
2714
|
-
mergedLanes: allLaneResults.filter(
|
|
2996
|
+
repoStatuses: repoOutcomes.map((r) => `${r.repoId ?? "default"}:${r.status}`).join(", "),
|
|
2997
|
+
mergedLanes: allLaneResults.filter(
|
|
2998
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2999
|
+
).length,
|
|
2715
3000
|
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
2716
3001
|
});
|
|
2717
3002
|
|
|
@@ -2740,8 +3025,6 @@ export async function mergeWaveByRepo(
|
|
|
2740
3025
|
return aggregateResult;
|
|
2741
3026
|
}
|
|
2742
3027
|
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
3028
|
// ── Auto-Integration ─────────────────────────────────────────────────
|
|
2746
3029
|
|
|
2747
3030
|
/**
|
|
@@ -2839,7 +3122,9 @@ export function attemptAutoIntegration(
|
|
|
2839
3122
|
}
|
|
2840
3123
|
}
|
|
2841
3124
|
|
|
2842
|
-
execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, {
|
|
3125
|
+
execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, {
|
|
3126
|
+
orchHead,
|
|
3127
|
+
});
|
|
2843
3128
|
onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info");
|
|
2844
3129
|
return true;
|
|
2845
3130
|
}
|
|
@@ -3042,12 +3327,7 @@ export class MergeHealthMonitor {
|
|
|
3042
3327
|
const resultPath = this._resultPaths.get(sessionName) ?? "";
|
|
3043
3328
|
const hasResultFile = resultPath ? existsSync(resultPath) : false;
|
|
3044
3329
|
|
|
3045
|
-
const newStatus = classifyMergeHealth(
|
|
3046
|
-
sessionAlive,
|
|
3047
|
-
hasResultFile,
|
|
3048
|
-
state,
|
|
3049
|
-
now,
|
|
3050
|
-
);
|
|
3330
|
+
const newStatus = classifyMergeHealth(sessionAlive, hasResultFile, state, now);
|
|
3051
3331
|
|
|
3052
3332
|
state.status = newStatus;
|
|
3053
3333
|
|
|
@@ -3132,4 +3412,3 @@ export class MergeHealthMonitor {
|
|
|
3132
3412
|
return new Map(this.sessions);
|
|
3133
3413
|
}
|
|
3134
3414
|
}
|
|
3135
|
-
|