@pi-agents/orchid 0.1.0-beta.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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
|
@@ -0,0 +1,3414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge orchestration, merge agents, merge worktree
|
|
3
|
+
* @module orch/merge
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
type Dirent,
|
|
15
|
+
} from "fs";
|
|
16
|
+
import { readFile as fsReadFile } from "fs/promises";
|
|
17
|
+
import { execSync, spawnSync } from "child_process";
|
|
18
|
+
import { join, dirname, resolve, relative } from "path";
|
|
19
|
+
|
|
20
|
+
import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
|
|
21
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
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";
|
|
61
|
+
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
62
|
+
import {
|
|
63
|
+
readManifest,
|
|
64
|
+
writeManifest,
|
|
65
|
+
buildRegistrySnapshot,
|
|
66
|
+
writeRegistrySnapshot,
|
|
67
|
+
readRegistrySnapshot,
|
|
68
|
+
writeMergeSnapshot,
|
|
69
|
+
} from "./process-registry.ts";
|
|
70
|
+
import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
|
|
71
|
+
import { getCurrentBranch, runGit } from "./git.ts";
|
|
72
|
+
import { ORCH_MESSAGES } from "./messages.ts";
|
|
73
|
+
import { emitEngineEvent } from "./persistence.ts";
|
|
74
|
+
import { loadOrchestratorConfig } from "./config.ts";
|
|
75
|
+
import {
|
|
76
|
+
captureBaseline,
|
|
77
|
+
diffFingerprints,
|
|
78
|
+
runVerificationCommands,
|
|
79
|
+
parseTestOutput,
|
|
80
|
+
deduplicateFingerprints,
|
|
81
|
+
} from "./verification.ts";
|
|
82
|
+
import { spawnAgent } from "./agent-host.ts";
|
|
83
|
+
import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
|
|
84
|
+
import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
|
|
85
|
+
import type { RuntimeBackend } from "./execution.ts";
|
|
86
|
+
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
87
|
+
|
|
88
|
+
// ── Merge Implementation ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse and validate a merge result JSON file.
|
|
92
|
+
*
|
|
93
|
+
* Strict validation:
|
|
94
|
+
* - Must be valid JSON
|
|
95
|
+
* - Must have required fields: status, source_branch, verification
|
|
96
|
+
* - status must be a known MergeResultStatus
|
|
97
|
+
* - Unknown status values are mapped to BUILD_FAILURE (fail-safe)
|
|
98
|
+
*
|
|
99
|
+
* Retry-read strategy: if initial parse fails, waits and retries up to
|
|
100
|
+
* MERGE_RESULT_READ_RETRIES times to handle partially-written files.
|
|
101
|
+
*
|
|
102
|
+
* @param resultPath - Absolute path to the merge result JSON file
|
|
103
|
+
* @returns Validated MergeResult
|
|
104
|
+
* @throws MergeError with appropriate code on validation failure
|
|
105
|
+
*/
|
|
106
|
+
export function parseMergeResult(resultPath: string): MergeResult {
|
|
107
|
+
if (!existsSync(resultPath)) {
|
|
108
|
+
throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
|
|
112
|
+
for (const key of keys) {
|
|
113
|
+
const value = obj[key];
|
|
114
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
|
|
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;
|
|
134
|
+
|
|
135
|
+
if (!nested && !hasFlatVerification(obj)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const passedFromBool =
|
|
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);
|
|
143
|
+
|
|
144
|
+
const exitCode =
|
|
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);
|
|
165
|
+
|
|
166
|
+
const output = (
|
|
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 : "")
|
|
171
|
+
).slice(0, 2000);
|
|
172
|
+
|
|
173
|
+
return { ran, passed, output };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Retry-read loop for partially-written files
|
|
177
|
+
let lastParseError = "";
|
|
178
|
+
for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = readFileSync(resultPath, "utf-8").trim();
|
|
181
|
+
if (!raw) {
|
|
182
|
+
lastParseError = "File is empty";
|
|
183
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
184
|
+
sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
throw new MergeError(
|
|
188
|
+
"MERGE_RESULT_INVALID",
|
|
189
|
+
`Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
194
|
+
|
|
195
|
+
// Validate required fields
|
|
196
|
+
if (typeof parsed.status !== "string") {
|
|
197
|
+
throw new MergeError(
|
|
198
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
199
|
+
`Merge result missing required field "status": ${resultPath}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Accept known source-field variants written by different merge agents.
|
|
204
|
+
// Canonical field remains source_branch.
|
|
205
|
+
const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
|
|
206
|
+
if (!sourceBranch) {
|
|
207
|
+
throw new MergeError(
|
|
208
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
209
|
+
`Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const verification = normalizeVerification(parsed);
|
|
214
|
+
if (!verification) {
|
|
215
|
+
throw new MergeError(
|
|
216
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
217
|
+
`Merge result missing required field "verification": ${resultPath}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Normalize status to uppercase (merge agents may write lowercase)
|
|
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;
|
|
230
|
+
|
|
231
|
+
// Validate status value
|
|
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
|
+
);
|
|
241
|
+
parsed.status = "BUILD_FAILURE";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
|
|
245
|
+
const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
|
|
246
|
+
const conflicts = Array.isArray(parsed.conflicts)
|
|
247
|
+
? parsed.conflicts
|
|
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
|
+
}))
|
|
262
|
+
: [];
|
|
263
|
+
|
|
264
|
+
// Normalize optional fields with defaults
|
|
265
|
+
return {
|
|
266
|
+
status: parsed.status as MergeResultStatus,
|
|
267
|
+
source_branch: sourceBranch,
|
|
268
|
+
target_branch: targetBranch,
|
|
269
|
+
merge_commit: mergeCommit,
|
|
270
|
+
conflicts,
|
|
271
|
+
verification,
|
|
272
|
+
};
|
|
273
|
+
} catch (err: unknown) {
|
|
274
|
+
if (err instanceof MergeError) throw err;
|
|
275
|
+
|
|
276
|
+
// JSON parse error — possibly partially written
|
|
277
|
+
lastParseError = err instanceof Error ? err.message : String(err);
|
|
278
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
279
|
+
sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
throw new MergeError(
|
|
286
|
+
"MERGE_RESULT_INVALID",
|
|
287
|
+
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
288
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Async version of parseMergeResult — reads and validates a merge result
|
|
294
|
+
* JSON file without blocking the event loop.
|
|
295
|
+
*
|
|
296
|
+
* Uses `fs/promises.readFile` instead of `readFileSync` and `sleepAsync`
|
|
297
|
+
* instead of `sleepSync` for retry delays. Validation semantics and error
|
|
298
|
+
* codes are identical to the sync version.
|
|
299
|
+
*
|
|
300
|
+
* @param resultPath - Path to the merge result JSON file
|
|
301
|
+
* @returns Promise resolving to a validated MergeResult
|
|
302
|
+
* @throws MergeError on missing/invalid/unparseable result
|
|
303
|
+
*
|
|
304
|
+
* @since TP-070
|
|
305
|
+
*/
|
|
306
|
+
export async function parseMergeResultAsync(resultPath: string): Promise<MergeResult> {
|
|
307
|
+
if (!existsSync(resultPath)) {
|
|
308
|
+
throw new MergeError("MERGE_RESULT_INVALID", `Merge result file not found: ${resultPath}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
|
|
312
|
+
for (const key of keys) {
|
|
313
|
+
const value = obj[key];
|
|
314
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
|
|
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;
|
|
334
|
+
|
|
335
|
+
if (!nested && !hasFlatVerification(obj)) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const passedFromBool =
|
|
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);
|
|
343
|
+
|
|
344
|
+
const exitCode =
|
|
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);
|
|
365
|
+
|
|
366
|
+
const output = (
|
|
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 : "")
|
|
371
|
+
).slice(0, 2000);
|
|
372
|
+
|
|
373
|
+
return { ran, passed, output };
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Retry-read loop for partially-written files — async version
|
|
377
|
+
let lastParseError = "";
|
|
378
|
+
for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
|
|
379
|
+
try {
|
|
380
|
+
const raw = (await fsReadFile(resultPath, "utf-8")).trim();
|
|
381
|
+
if (!raw) {
|
|
382
|
+
lastParseError = "File is empty";
|
|
383
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
384
|
+
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
throw new MergeError(
|
|
388
|
+
"MERGE_RESULT_INVALID",
|
|
389
|
+
`Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
394
|
+
|
|
395
|
+
// Validate required fields
|
|
396
|
+
if (typeof parsed.status !== "string") {
|
|
397
|
+
throw new MergeError(
|
|
398
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
399
|
+
`Merge result missing required field "status": ${resultPath}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
|
|
404
|
+
if (!sourceBranch) {
|
|
405
|
+
throw new MergeError(
|
|
406
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
407
|
+
`Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const verification = normalizeVerification(parsed);
|
|
412
|
+
if (!verification) {
|
|
413
|
+
throw new MergeError(
|
|
414
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
415
|
+
`Merge result missing required field "verification": ${resultPath}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Normalize status to uppercase
|
|
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
|
+
);
|
|
434
|
+
parsed.status = "BUILD_FAILURE";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
|
|
438
|
+
const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
|
|
439
|
+
const conflicts = Array.isArray(parsed.conflicts)
|
|
440
|
+
? parsed.conflicts
|
|
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
|
+
}))
|
|
455
|
+
: [];
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
status: parsed.status as MergeResultStatus,
|
|
459
|
+
source_branch: sourceBranch,
|
|
460
|
+
target_branch: targetBranch,
|
|
461
|
+
merge_commit: mergeCommit,
|
|
462
|
+
conflicts,
|
|
463
|
+
verification,
|
|
464
|
+
};
|
|
465
|
+
} catch (err: unknown) {
|
|
466
|
+
if (err instanceof MergeError) throw err;
|
|
467
|
+
|
|
468
|
+
lastParseError = err instanceof Error ? err.message : String(err);
|
|
469
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
470
|
+
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
throw new MergeError(
|
|
477
|
+
"MERGE_RESULT_INVALID",
|
|
478
|
+
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
479
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* TP-171: Stage task artifacts from skipped-only lanes onto the target branch
|
|
485
|
+
* using an isolated temporary worktree.
|
|
486
|
+
*
|
|
487
|
+
* When no mergeable lanes exist (e.g., all tasks skipped), there is no merge worktree.
|
|
488
|
+
* This function creates a lightweight temporary worktree on `targetBranch`, copies
|
|
489
|
+
* STATUS.md, .DONE, REVIEW_VERDICT.json, and .reviews/** from skipped-lane worktrees
|
|
490
|
+
* into it, commits, advances `targetBranch`, then cleans up the temporary worktree.
|
|
491
|
+
* This ensures partial worker progress (STATUS.md updates) survives integration.
|
|
492
|
+
*/
|
|
493
|
+
function stageSkippedArtifactsToTargetBranch(
|
|
494
|
+
lanes: AllocatedLane[],
|
|
495
|
+
waveIndex: number,
|
|
496
|
+
repoRoot: string,
|
|
497
|
+
targetBranch: string,
|
|
498
|
+
): void {
|
|
499
|
+
// TP-171: Do NOT include .DONE — skipped tasks' code was not merged,
|
|
500
|
+
// so staging .DONE would create false completion markers.
|
|
501
|
+
const ALLOWED_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"];
|
|
502
|
+
const ALLOWED_DIRS = [".reviews"];
|
|
503
|
+
const resolvedRepoRoot = resolve(repoRoot);
|
|
504
|
+
|
|
505
|
+
// Create a temporary worktree on the target branch for isolated artifact staging.
|
|
506
|
+
// This avoids writing to whatever branch repoRoot has checked out.
|
|
507
|
+
const tmpWorktreePath = join(repoRoot, "..", `skip-artifacts-w${waveIndex}-${Date.now()}`);
|
|
508
|
+
const resolvedTmpPath = resolve(tmpWorktreePath);
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const addResult = spawnSync("git", ["worktree", "add", resolvedTmpPath, targetBranch], {
|
|
512
|
+
cwd: repoRoot,
|
|
513
|
+
});
|
|
514
|
+
if (addResult.status !== 0) {
|
|
515
|
+
execLog("merge", `W${waveIndex}`, `failed to create temp worktree for skipped artifacts`, {
|
|
516
|
+
stderr: addResult.stderr?.toString().trim(),
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let staged = 0;
|
|
522
|
+
|
|
523
|
+
for (const lane of lanes) {
|
|
524
|
+
if (!lane.worktreePath || !existsSync(lane.worktreePath)) continue;
|
|
525
|
+
for (const allocTask of lane.tasks) {
|
|
526
|
+
if (!allocTask.task?.taskFolder?.trim()) continue;
|
|
527
|
+
// Resolve taskFolder against the lane worktree first (workspace mode:
|
|
528
|
+
// taskFolder may be relative to the packet repo, not the execution repo).
|
|
529
|
+
// Fall back to repoRoot if worktreePath is unavailable.
|
|
530
|
+
const resolveBase = lane.worktreePath ? resolve(lane.worktreePath) : resolvedRepoRoot;
|
|
531
|
+
const absFolder = resolve(resolveBase, allocTask.task.taskFolder);
|
|
532
|
+
const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
|
|
533
|
+
if (relFolder.startsWith("..") || relFolder.startsWith("/")) continue;
|
|
534
|
+
|
|
535
|
+
for (const name of ALLOWED_NAMES) {
|
|
536
|
+
const relPath = `${relFolder}/${name}`;
|
|
537
|
+
const srcPath = join(lane.worktreePath, relPath);
|
|
538
|
+
if (!existsSync(srcPath)) continue;
|
|
539
|
+
try {
|
|
540
|
+
const destPath = join(resolvedTmpPath, relPath);
|
|
541
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
542
|
+
copyFileSync(srcPath, destPath);
|
|
543
|
+
spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
|
|
544
|
+
staged++;
|
|
545
|
+
} catch {
|
|
546
|
+
/* best effort */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const dirName of ALLOWED_DIRS) {
|
|
551
|
+
const laneDir = join(lane.worktreePath, relFolder, dirName);
|
|
552
|
+
if (!existsSync(laneDir)) continue;
|
|
553
|
+
try {
|
|
554
|
+
const entries = readdirSync(laneDir, { recursive: true, withFileTypes: true });
|
|
555
|
+
for (const entry of entries) {
|
|
556
|
+
if (!entry.isFile()) continue;
|
|
557
|
+
const entryPath = entry.parentPath ? join(entry.parentPath, entry.name) : entry.name;
|
|
558
|
+
const fileRel = relative(laneDir, entryPath).replace(/\\/g, "/");
|
|
559
|
+
if (fileRel.startsWith("..")) continue;
|
|
560
|
+
const relPath = `${relFolder}/${dirName}/${fileRel}`;
|
|
561
|
+
const srcPath = join(laneDir, fileRel);
|
|
562
|
+
const destPath = join(resolvedTmpPath, relPath);
|
|
563
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
564
|
+
copyFileSync(srcPath, destPath);
|
|
565
|
+
spawnSync("git", ["add", "--", relPath], { cwd: resolvedTmpPath });
|
|
566
|
+
staged++;
|
|
567
|
+
}
|
|
568
|
+
} catch {
|
|
569
|
+
/* best effort */
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (staged > 0) {
|
|
576
|
+
const commitResult = spawnSync(
|
|
577
|
+
"git",
|
|
578
|
+
["commit", "-m", `checkpoint: wave ${waveIndex} skipped-task artifacts (STATUS.md, .reviews)`],
|
|
579
|
+
{ cwd: resolvedTmpPath },
|
|
580
|
+
);
|
|
581
|
+
if (commitResult.status === 0) {
|
|
582
|
+
execLog("merge", `W${waveIndex}`, `staged ${staged} artifact(s) from skipped-only lanes`, {
|
|
583
|
+
lanes: lanes.map((l) => l.laneNumber).join(","),
|
|
584
|
+
});
|
|
585
|
+
} else {
|
|
586
|
+
execLog("merge", `W${waveIndex}`, `failed to commit skipped-task artifacts`, {
|
|
587
|
+
stderr: commitResult.stderr?.toString().trim(),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} catch (err: any) {
|
|
592
|
+
execLog("merge", `W${waveIndex}`, `unexpected error staging skipped artifacts`, {
|
|
593
|
+
error: err?.message,
|
|
594
|
+
});
|
|
595
|
+
} finally {
|
|
596
|
+
// Clean up the temporary worktree
|
|
597
|
+
try {
|
|
598
|
+
spawnSync("git", ["worktree", "remove", "--force", resolvedTmpPath], { cwd: repoRoot });
|
|
599
|
+
} catch {
|
|
600
|
+
/* best effort cleanup */
|
|
601
|
+
}
|
|
602
|
+
try {
|
|
603
|
+
if (existsSync(resolvedTmpPath)) {
|
|
604
|
+
rmSync(resolvedTmpPath, { recursive: true, force: true });
|
|
605
|
+
}
|
|
606
|
+
} catch {
|
|
607
|
+
/* best effort cleanup */
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Determine merge order for completed lanes.
|
|
614
|
+
*
|
|
615
|
+
* Default heuristic: fewest-files-first.
|
|
616
|
+
* - Lanes with fewer files in their file scope merge first
|
|
617
|
+
* - Smaller changes are less likely to conflict, establishing a clean base
|
|
618
|
+
* - Tie-breaker: branch name alphabetically (deterministic)
|
|
619
|
+
*
|
|
620
|
+
* Alternative: sequential (lane number order).
|
|
621
|
+
*
|
|
622
|
+
* @param lanes - Completed lanes to order
|
|
623
|
+
* @param order - Ordering strategy from config
|
|
624
|
+
* @returns Lanes sorted in merge order
|
|
625
|
+
*/
|
|
626
|
+
export function determineMergeOrder(
|
|
627
|
+
lanes: AllocatedLane[],
|
|
628
|
+
order: "fewest-files-first" | "sequential",
|
|
629
|
+
): AllocatedLane[] {
|
|
630
|
+
const sorted = [...lanes];
|
|
631
|
+
|
|
632
|
+
if (order === "sequential") {
|
|
633
|
+
sorted.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
634
|
+
return sorted;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// fewest-files-first: count total file scope across all tasks in the lane
|
|
638
|
+
sorted.sort((a, b) => {
|
|
639
|
+
const aFiles = a.tasks.reduce((sum, t) => sum + (t.task?.fileScope?.length || 0), 0);
|
|
640
|
+
const bFiles = b.tasks.reduce((sum, t) => sum + (t.task?.fileScope?.length || 0), 0);
|
|
641
|
+
|
|
642
|
+
if (aFiles !== bFiles) return aFiles - bFiles;
|
|
643
|
+
|
|
644
|
+
// Tie-breaker: branch name alphabetically
|
|
645
|
+
return a.branch.localeCompare(b.branch);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return sorted;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Build merge request content for the merge agent.
|
|
653
|
+
*
|
|
654
|
+
* The merge request is a structured text document that tells the merge agent:
|
|
655
|
+
* - Which branch to merge (source)
|
|
656
|
+
* - Which branch to merge into (target)
|
|
657
|
+
* - What tasks were completed in this lane
|
|
658
|
+
* - File scope of those tasks
|
|
659
|
+
* - Verification commands to run
|
|
660
|
+
* - Where to write the result file
|
|
661
|
+
*
|
|
662
|
+
* @param lane - The lane to merge
|
|
663
|
+
* @param targetBranch - Target branch (typically "develop")
|
|
664
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
665
|
+
* @param verifyCommands - Verification commands from config
|
|
666
|
+
* @param resultFilePath - Path where the merge agent should write results
|
|
667
|
+
* @returns Formatted merge request text
|
|
668
|
+
*/
|
|
669
|
+
export function buildMergeRequest(
|
|
670
|
+
lane: AllocatedLane,
|
|
671
|
+
targetBranch: string,
|
|
672
|
+
waveIndex: number,
|
|
673
|
+
verifyCommands: string[],
|
|
674
|
+
resultFilePath: string,
|
|
675
|
+
): string {
|
|
676
|
+
const taskIds = lane.tasks.map((t) => t.taskId).join(", ");
|
|
677
|
+
// TP-169: Guard against null task stubs from reconstructAllocatedLanes
|
|
678
|
+
const fileScopes = lane.tasks
|
|
679
|
+
.flatMap((t) => t.task?.fileScope || [])
|
|
680
|
+
.filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate
|
|
681
|
+
|
|
682
|
+
const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`;
|
|
683
|
+
|
|
684
|
+
const lines: string[] = [
|
|
685
|
+
"# Merge Request",
|
|
686
|
+
"",
|
|
687
|
+
`## Source Branch`,
|
|
688
|
+
`${lane.branch}`,
|
|
689
|
+
"",
|
|
690
|
+
`## Target Branch`,
|
|
691
|
+
`${targetBranch}`,
|
|
692
|
+
"",
|
|
693
|
+
`## Merge Message`,
|
|
694
|
+
`${mergeMessage}`,
|
|
695
|
+
"",
|
|
696
|
+
`## Tasks Completed`,
|
|
697
|
+
...lane.tasks.map((t) => `- ${t.taskId}: ${t.task?.taskName ?? "(unknown)"}`),
|
|
698
|
+
"",
|
|
699
|
+
`## File Scope`,
|
|
700
|
+
...(fileScopes.length > 0 ? fileScopes.map((f) => `- ${f}`) : ["- (no file scope declared)"]),
|
|
701
|
+
"",
|
|
702
|
+
`## Verification Commands`,
|
|
703
|
+
...verifyCommands.map((cmd) => `\`\`\`bash\n${cmd}\n\`\`\``),
|
|
704
|
+
"",
|
|
705
|
+
`## Result File`,
|
|
706
|
+
`result_file: ${resultFilePath.split("\\").join("/")}`,
|
|
707
|
+
`Write your JSON result to EXACTLY this path (do NOT modify or convert it): ${resultFilePath.split("\\").join("/")}`,
|
|
708
|
+
"",
|
|
709
|
+
"## Result JSON Schema (required)",
|
|
710
|
+
"Use EXACT snake_case keys shown below. Do not use camelCase or shortened keys.",
|
|
711
|
+
"",
|
|
712
|
+
"```json",
|
|
713
|
+
"{",
|
|
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": "..." }',
|
|
720
|
+
"}",
|
|
721
|
+
"```",
|
|
722
|
+
"",
|
|
723
|
+
"Do NOT use keys like source/sourceBranch/target/mergeCommit.",
|
|
724
|
+
"Write valid JSON only (no markdown around the final file).",
|
|
725
|
+
"",
|
|
726
|
+
"## Important",
|
|
727
|
+
"- You are working in an ISOLATED MERGE WORKTREE (not the user's main repo)",
|
|
728
|
+
"- The correct branch is ALREADY checked out — do NOT checkout any other branch",
|
|
729
|
+
"- Simply merge the source branch into the current HEAD",
|
|
730
|
+
"- Run ALL verification commands after a successful merge",
|
|
731
|
+
"- If verification fails, revert the merge commit before writing the result",
|
|
732
|
+
"- Write the result file LAST, after all git operations are complete",
|
|
733
|
+
];
|
|
734
|
+
|
|
735
|
+
return lines.join("\n");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer).
|
|
740
|
+
*
|
|
741
|
+
* Per Runtime V2 spec (02-runtime-process-model.md §8.3):
|
|
742
|
+
* "engine spawns merge host directly" — the merge agent runs as a direct
|
|
743
|
+
* child process via agent-host, with process registry tracking, normalized
|
|
744
|
+
* events, and deterministic exit classification.
|
|
745
|
+
*
|
|
746
|
+
* The merge agent receives the merge request as its prompt and writes
|
|
747
|
+
* a result JSON file. The caller polls for that result file (same contract
|
|
748
|
+
* as the legacy session-backed path via waitForMergeResult).
|
|
749
|
+
*
|
|
750
|
+
* @param sessionName - Stable agent ID (e.g., "orch-merge-1")
|
|
751
|
+
* @param repoRoot - Main repository root (merge happens here)
|
|
752
|
+
* @param mergeWorkDir - Working directory for the merge
|
|
753
|
+
* @param mergeRequestPath - Path to the merge request file
|
|
754
|
+
* @param config - Orchestrator config
|
|
755
|
+
* @param stateRoot - Root for state files / registry
|
|
756
|
+
* @param agentRoot - Root for agent prompts
|
|
757
|
+
* @param batchId - Current batch ID
|
|
758
|
+
* @returns Promise that resolves when the agent exits
|
|
759
|
+
*
|
|
760
|
+
* @since TP-108
|
|
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.
|
|
768
|
+
export async function spawnMergeAgentV2(
|
|
769
|
+
sessionName: string,
|
|
770
|
+
repoRoot: string,
|
|
771
|
+
mergeWorkDir: string,
|
|
772
|
+
mergeRequestPath: string,
|
|
773
|
+
config: OrchestratorConfig,
|
|
774
|
+
stateRoot?: string,
|
|
775
|
+
agentRoot?: string,
|
|
776
|
+
batchId?: string,
|
|
777
|
+
waveIndex?: number,
|
|
778
|
+
): Promise<void> {
|
|
779
|
+
execLog("merge", sessionName, "spawning merge agent via Runtime V2 (direct agent-host)", {
|
|
780
|
+
mergeWorkDir,
|
|
781
|
+
mergeRequestPath,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Read the merge request as the agent prompt
|
|
785
|
+
const prompt = readFileSync(mergeRequestPath, "utf-8");
|
|
786
|
+
|
|
787
|
+
// Resolve merger system prompt
|
|
788
|
+
const systemPromptCandidates = [
|
|
789
|
+
agentRoot ? join(agentRoot, "task-merger.md") : "",
|
|
790
|
+
join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
|
|
791
|
+
].filter(Boolean);
|
|
792
|
+
const systemPromptPath = systemPromptCandidates.find((p) => existsSync(p)) || "";
|
|
793
|
+
let systemPrompt: string | undefined;
|
|
794
|
+
if (systemPromptPath) {
|
|
795
|
+
try {
|
|
796
|
+
systemPrompt = readFileSync(systemPromptPath, "utf-8");
|
|
797
|
+
} catch {
|
|
798
|
+
/* use default */
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Resolve event/exit paths
|
|
803
|
+
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
804
|
+
const bid = batchId || "unknown";
|
|
805
|
+
const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl");
|
|
806
|
+
const exitSummaryPath = join(
|
|
807
|
+
sidecarRoot,
|
|
808
|
+
"runtime",
|
|
809
|
+
bid,
|
|
810
|
+
"agents",
|
|
811
|
+
sessionName,
|
|
812
|
+
"exit-summary.json",
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
// Mailbox directory
|
|
816
|
+
let mailboxDir: string | null = null;
|
|
817
|
+
if (batchId) {
|
|
818
|
+
mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
|
|
819
|
+
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// TP-180: Forward user-installed extensions to merge agent
|
|
823
|
+
const mergeStateRoot = stateRoot ?? repoRoot;
|
|
824
|
+
const allMergePackages = loadPiSettingsPackages(mergeStateRoot);
|
|
825
|
+
const mergeExclusions = config.merge.exclude_extensions ?? [];
|
|
826
|
+
const mergePackages = filterExcludedExtensions(allMergePackages, mergeExclusions);
|
|
827
|
+
|
|
828
|
+
const opts: AgentHostOptions = {
|
|
829
|
+
agentId: sessionName,
|
|
830
|
+
role: "merger",
|
|
831
|
+
batchId: bid,
|
|
832
|
+
laneNumber: null,
|
|
833
|
+
taskId: null,
|
|
834
|
+
repoId: "default",
|
|
835
|
+
cwd: mergeWorkDir,
|
|
836
|
+
prompt,
|
|
837
|
+
systemPrompt,
|
|
838
|
+
model: config.merge.model || undefined,
|
|
839
|
+
tools: config.merge.tools || undefined,
|
|
840
|
+
thinking: config.merge.thinking || undefined,
|
|
841
|
+
mailboxDir,
|
|
842
|
+
eventsPath,
|
|
843
|
+
exitSummaryPath,
|
|
844
|
+
timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
|
|
845
|
+
stateRoot: mergeStateRoot,
|
|
846
|
+
packet: null,
|
|
847
|
+
...(mergePackages.length > 0 ? { extensions: mergePackages } : {}),
|
|
848
|
+
env: {
|
|
849
|
+
ORCH_BATCH_ID: bid,
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Derive the 1-indexed merge number from the session name
|
|
854
|
+
// (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2).
|
|
855
|
+
const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/);
|
|
856
|
+
if (!mergeNumberMatch) {
|
|
857
|
+
execLog(
|
|
858
|
+
"merge",
|
|
859
|
+
sessionName,
|
|
860
|
+
"warning: could not parse merge number from session name — defaulting to 1",
|
|
861
|
+
{ sessionName },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
|
|
865
|
+
const mergeStartedAt = Date.now();
|
|
866
|
+
|
|
867
|
+
// Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
|
|
868
|
+
const buildAgentSnap = (
|
|
869
|
+
tel: Partial<AgentHostResult>,
|
|
870
|
+
status: RuntimeAgentTelemetrySnapshot["status"],
|
|
871
|
+
): RuntimeAgentTelemetrySnapshot => ({
|
|
872
|
+
agentId: sessionName,
|
|
873
|
+
status,
|
|
874
|
+
elapsedMs: tel.durationMs ?? Date.now() - mergeStartedAt,
|
|
875
|
+
toolCalls: tel.toolCalls ?? 0,
|
|
876
|
+
contextPct: tel.contextUsage?.percent ?? 0,
|
|
877
|
+
costUsd: tel.costUsd ?? 0,
|
|
878
|
+
lastTool: tel.lastTool ?? "",
|
|
879
|
+
inputTokens: tel.inputTokens ?? 0,
|
|
880
|
+
outputTokens: tel.outputTokens ?? 0,
|
|
881
|
+
cacheReadTokens: tel.cacheReadTokens ?? 0,
|
|
882
|
+
cacheWriteTokens: tel.cacheWriteTokens ?? 0,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// Telemetry callback: write a merge snapshot on every telemetry update.
|
|
886
|
+
// Non-fatal — snapshot writes must never interfere with merge execution.
|
|
887
|
+
const onMergeTelemetry: AgentTelemetryCallback = (tel) => {
|
|
888
|
+
try {
|
|
889
|
+
const snap: RuntimeMergeSnapshot = {
|
|
890
|
+
batchId: bid,
|
|
891
|
+
mergeNumber,
|
|
892
|
+
sessionName,
|
|
893
|
+
waveIndex: waveIndex ?? 0,
|
|
894
|
+
status: "running",
|
|
895
|
+
agent: buildAgentSnap(tel, "running"),
|
|
896
|
+
updatedAt: Date.now(),
|
|
897
|
+
};
|
|
898
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, snap);
|
|
899
|
+
} catch {
|
|
900
|
+
/* non-fatal */
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry);
|
|
905
|
+
|
|
906
|
+
// Write an initial "running" snapshot immediately so the dashboard row
|
|
907
|
+
// appears even when the first telemetry event is delayed.
|
|
908
|
+
try {
|
|
909
|
+
const initialSnap: RuntimeMergeSnapshot = {
|
|
910
|
+
batchId: bid,
|
|
911
|
+
mergeNumber,
|
|
912
|
+
sessionName,
|
|
913
|
+
waveIndex: waveIndex ?? 0,
|
|
914
|
+
status: "running",
|
|
915
|
+
agent: buildAgentSnap({}, "running"),
|
|
916
|
+
updatedAt: Date.now(),
|
|
917
|
+
};
|
|
918
|
+
writeMergeSnapshot(mergeStateRoot, bid, waveIndex ?? 0, mergeNumber, initialSnap);
|
|
919
|
+
} catch {
|
|
920
|
+
/* non-fatal */
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Store the kill handle for external cleanup (pause/abort).
|
|
924
|
+
// The promise runs in background — caller uses waitForMergeResult()
|
|
925
|
+
// to poll for the result file, same contract as the legacy session path.
|
|
926
|
+
activeMergeAgents.set(sessionName, { promise, kill, stateRoot: mergeStateRoot, batchId: bid });
|
|
927
|
+
|
|
928
|
+
// Fire-and-forget: the background promise handles exit logging and
|
|
929
|
+
// writes a terminal snapshot ("complete" or "failed") when the agent exits.
|
|
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";
|
|
956
|
+
}
|
|
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, waveIndex ?? 0, mergeNumber, snap);
|
|
968
|
+
} catch {
|
|
969
|
+
/* non-fatal */
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
.catch((err) => {
|
|
973
|
+
activeMergeAgents.delete(sessionName);
|
|
974
|
+
execLog(
|
|
975
|
+
"merge",
|
|
976
|
+
sessionName,
|
|
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, waveIndex ?? 0, mergeNumber, snap);
|
|
991
|
+
} catch {
|
|
992
|
+
/* non-fatal */
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Active V2 merge agent handles for cleanup/abort. @since TP-108 */
|
|
998
|
+
const activeMergeAgents = new Map<
|
|
999
|
+
string,
|
|
1000
|
+
{ promise: Promise<AgentHostResult>; kill: () => void; stateRoot?: string; batchId?: string }
|
|
1001
|
+
>();
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Kill a V2 merge agent if it's still running.
|
|
1005
|
+
* Used by pause/abort/cleanup flows.
|
|
1006
|
+
* @since TP-108
|
|
1007
|
+
*/
|
|
1008
|
+
export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): boolean {
|
|
1009
|
+
const handle = activeMergeAgents.get(sessionName);
|
|
1010
|
+
if (handle) {
|
|
1011
|
+
handle.kill();
|
|
1012
|
+
// TP-115: On clean post-result cleanup, update manifest to "exited"
|
|
1013
|
+
// so dashboard shows correct status instead of "killed".
|
|
1014
|
+
if (cleanExit && handle.stateRoot && handle.batchId) {
|
|
1015
|
+
try {
|
|
1016
|
+
const manifest = readManifest(handle.stateRoot, handle.batchId, sessionName as any);
|
|
1017
|
+
if (manifest) {
|
|
1018
|
+
manifest.status = "exited";
|
|
1019
|
+
writeManifest(handle.stateRoot, manifest);
|
|
1020
|
+
const snapshot = buildRegistrySnapshot(handle.stateRoot, handle.batchId);
|
|
1021
|
+
writeRegistrySnapshot(handle.stateRoot, snapshot);
|
|
1022
|
+
}
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* best effort */
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
activeMergeAgents.delete(sessionName);
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Kill ALL active V2 merge agents. Used by abort flow to ensure
|
|
1035
|
+
* no merge agents survive even when the legacy session list is empty.
|
|
1036
|
+
* @returns Number of agents killed
|
|
1037
|
+
* @since TP-108
|
|
1038
|
+
*/
|
|
1039
|
+
export function killAllMergeAgentsV2(): number {
|
|
1040
|
+
let killed = 0;
|
|
1041
|
+
for (const [name, handle] of activeMergeAgents) {
|
|
1042
|
+
handle.kill();
|
|
1043
|
+
execLog("merge", name, "V2 merge agent killed by bulk abort");
|
|
1044
|
+
killed++;
|
|
1045
|
+
}
|
|
1046
|
+
activeMergeAgents.clear();
|
|
1047
|
+
return killed;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Re-read merge timeout from config on disk.
|
|
1052
|
+
*
|
|
1053
|
+
* TP-038: Allows the operator to increase `merge.timeoutMinutes` without
|
|
1054
|
+
* restarting the pi session. Called before each retry attempt so the
|
|
1055
|
+
* retry loop picks up any config changes made while the batch was running.
|
|
1056
|
+
*
|
|
1057
|
+
* @param configRoot - The directory containing `.pi/orchid-config.json`
|
|
1058
|
+
* @param pointerConfigRoot - Optional pointer config root (workspace mode)
|
|
1059
|
+
* @returns Fresh timeout in milliseconds
|
|
1060
|
+
*/
|
|
1061
|
+
export function reloadMergeTimeoutMs(configRoot: string, pointerConfigRoot?: string): number {
|
|
1062
|
+
try {
|
|
1063
|
+
const freshConfig = loadOrchestratorConfig(configRoot, pointerConfigRoot);
|
|
1064
|
+
const minutes = freshConfig.merge.timeout_minutes ?? 90;
|
|
1065
|
+
return minutes * 60 * 1000;
|
|
1066
|
+
} catch (err: unknown) {
|
|
1067
|
+
// Config re-read is best-effort — fall back to default on failure
|
|
1068
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1069
|
+
execLog(
|
|
1070
|
+
"merge",
|
|
1071
|
+
"config-reload",
|
|
1072
|
+
`failed to re-read merge timeout from config: ${errMsg} — using default`,
|
|
1073
|
+
);
|
|
1074
|
+
return MERGE_TIMEOUT_MS;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Merge result statuses that indicate the merge agent completed successfully. */
|
|
1079
|
+
const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED"]);
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Wait for merge agent to produce a result file.
|
|
1083
|
+
*
|
|
1084
|
+
* Polling loop with timeout and session liveness detection:
|
|
1085
|
+
* 1. Check if result file exists → parse and return
|
|
1086
|
+
* 2. Check if the merge agent session is still alive
|
|
1087
|
+
* 3. If session died without result → grace period → check again → fail
|
|
1088
|
+
* 4. If timeout exceeded → check result before killing:
|
|
1089
|
+
* a. If result exists with SUCCESS/CONFLICT_RESOLVED: accept it
|
|
1090
|
+
* (merge agent slow but succeeded)
|
|
1091
|
+
* b. If result missing or non-success: kill session → fail
|
|
1092
|
+
*
|
|
1093
|
+
* @param resultPath - Path to the expected result JSON file
|
|
1094
|
+
* @param sessionName - Merge session name for liveness checking
|
|
1095
|
+
* @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
|
|
1096
|
+
* @returns Validated MergeResult
|
|
1097
|
+
* @throws MergeError on timeout, session death, or invalid result
|
|
1098
|
+
*/
|
|
1099
|
+
export async function waitForMergeResult(
|
|
1100
|
+
resultPath: string,
|
|
1101
|
+
sessionName: string,
|
|
1102
|
+
timeoutMs: number = MERGE_TIMEOUT_MS,
|
|
1103
|
+
runtimeBackend?: RuntimeBackend,
|
|
1104
|
+
): Promise<MergeResult> {
|
|
1105
|
+
const startTime = Date.now();
|
|
1106
|
+
let sessionDiedAt: number | null = null;
|
|
1107
|
+
const isV2 = runtimeBackend === "v2";
|
|
1108
|
+
|
|
1109
|
+
execLog("merge", sessionName, "waiting for merge result", {
|
|
1110
|
+
resultPath,
|
|
1111
|
+
timeoutMs,
|
|
1112
|
+
backend: isV2 ? "v2" : "legacy",
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
while (true) {
|
|
1116
|
+
const elapsed = Date.now() - startTime;
|
|
1117
|
+
|
|
1118
|
+
// Check timeout
|
|
1119
|
+
if (elapsed >= timeoutMs) {
|
|
1120
|
+
// TP-038: Check result file BEFORE killing the session.
|
|
1121
|
+
if (existsSync(resultPath)) {
|
|
1122
|
+
try {
|
|
1123
|
+
const lateResult = await parseMergeResultAsync(resultPath);
|
|
1124
|
+
if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) {
|
|
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
|
+
);
|
|
1135
|
+
// Clean up agent (may still be running post-write)
|
|
1136
|
+
killMergeAgentV2(sessionName, true);
|
|
1137
|
+
return lateResult;
|
|
1138
|
+
}
|
|
1139
|
+
execLog("merge", sessionName, "merge result exists at timeout but non-success — killing", {
|
|
1140
|
+
status: lateResult.status,
|
|
1141
|
+
});
|
|
1142
|
+
} catch {
|
|
1143
|
+
// Result file unreadable — fall through to kill
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
|
|
1148
|
+
killMergeAgentV2(sessionName);
|
|
1149
|
+
|
|
1150
|
+
throw new MergeError(
|
|
1151
|
+
"MERGE_TIMEOUT",
|
|
1152
|
+
`Merge agent '${sessionName}' did not produce a result within ` +
|
|
1153
|
+
`${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` +
|
|
1154
|
+
`Check the merge request and agent logs.`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Check if result file exists
|
|
1159
|
+
if (existsSync(resultPath)) {
|
|
1160
|
+
try {
|
|
1161
|
+
const result = await parseMergeResultAsync(resultPath);
|
|
1162
|
+
execLog("merge", sessionName, "merge result received", {
|
|
1163
|
+
status: result.status,
|
|
1164
|
+
elapsed,
|
|
1165
|
+
});
|
|
1166
|
+
// Clean up agent if still alive
|
|
1167
|
+
killMergeAgentV2(sessionName, true);
|
|
1168
|
+
return result;
|
|
1169
|
+
} catch (err: unknown) {
|
|
1170
|
+
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
1171
|
+
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
1172
|
+
if (existsSync(resultPath)) {
|
|
1173
|
+
try {
|
|
1174
|
+
return await parseMergeResultAsync(resultPath);
|
|
1175
|
+
} catch {
|
|
1176
|
+
/* give up */
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Check agent liveness — backend-aware
|
|
1184
|
+
// Runtime V2: check active merge agent handle map (process-owned).
|
|
1185
|
+
const agentAlive = activeMergeAgents.has(sessionName);
|
|
1186
|
+
|
|
1187
|
+
if (!agentAlive) {
|
|
1188
|
+
if (sessionDiedAt === null) {
|
|
1189
|
+
sessionDiedAt = Date.now();
|
|
1190
|
+
execLog("merge", sessionName, "agent exited — starting grace period", {
|
|
1191
|
+
graceMs: MERGE_RESULT_GRACE_MS,
|
|
1192
|
+
});
|
|
1193
|
+
} else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
|
|
1194
|
+
// Grace period expired — one final check
|
|
1195
|
+
if (existsSync(resultPath)) {
|
|
1196
|
+
try {
|
|
1197
|
+
return await parseMergeResultAsync(resultPath);
|
|
1198
|
+
} catch {
|
|
1199
|
+
/* fall through */
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
throw new MergeError(
|
|
1204
|
+
"MERGE_SESSION_DIED",
|
|
1205
|
+
`Merge agent '${sessionName}' exited without writing ` +
|
|
1206
|
+
`a result file to '${resultPath}'. The merge may have crashed. ` +
|
|
1207
|
+
`Check agent logs for diagnostics.`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
await sleepAsync(MERGE_POLL_INTERVAL_MS);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Force-remove a merge worktree directory and prune stale git references.
|
|
1218
|
+
*
|
|
1219
|
+
* TP-029: Applies the same forceCleanupWorktree pattern used for lane
|
|
1220
|
+
* worktrees. Tries `git worktree remove --force` first, then falls back
|
|
1221
|
+
* to `rm -rf` + `git worktree prune` if the initial removal fails.
|
|
1222
|
+
*
|
|
1223
|
+
* Used in both stale-prep cleanup (before creating a fresh merge worktree)
|
|
1224
|
+
* and end-of-wave cleanup (after merge completes).
|
|
1225
|
+
*
|
|
1226
|
+
* @param mergeWorkDir - Absolute path to the merge worktree directory
|
|
1227
|
+
* @param repoRoot - Main repository root for git operations
|
|
1228
|
+
* @param context - Logging context (e.g., "W1" for wave 1)
|
|
1229
|
+
*/
|
|
1230
|
+
function forceRemoveMergeWorktree(mergeWorkDir: string, repoRoot: string, context: string): void {
|
|
1231
|
+
if (!existsSync(mergeWorkDir)) return;
|
|
1232
|
+
|
|
1233
|
+
// Try git worktree remove --force first
|
|
1234
|
+
const removeResult = spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], {
|
|
1235
|
+
cwd: repoRoot,
|
|
1236
|
+
});
|
|
1237
|
+
if (removeResult.status === 0) {
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Fallback: force-remove the directory and prune git worktree state
|
|
1242
|
+
const stderr = removeResult.stderr?.toString().trim() || "";
|
|
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
|
+
);
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
rmSync(mergeWorkDir, { recursive: true, force: true });
|
|
1255
|
+
execLog("merge", context, `force-removed merge worktree directory`, { path: mergeWorkDir });
|
|
1256
|
+
} catch (rmErr: unknown) {
|
|
1257
|
+
// Node's rmSync may fail on Windows reserved-name files — try OS-level removal
|
|
1258
|
+
const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
|
|
1259
|
+
execLog("merge", context, `rmSync failed for merge worktree, trying OS-level removal`, {
|
|
1260
|
+
error: rmMsg,
|
|
1261
|
+
});
|
|
1262
|
+
try {
|
|
1263
|
+
if (process.platform === "win32") {
|
|
1264
|
+
execSync(`rd /s /q "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1265
|
+
} else {
|
|
1266
|
+
execSync(`rm -rf "${mergeWorkDir}"`, { stdio: "pipe", timeout: 30_000 });
|
|
1267
|
+
}
|
|
1268
|
+
execLog("merge", context, `OS-level removal of merge worktree succeeded`, {
|
|
1269
|
+
path: mergeWorkDir,
|
|
1270
|
+
});
|
|
1271
|
+
} catch (osErr: unknown) {
|
|
1272
|
+
const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
|
|
1273
|
+
execLog("merge", context, `OS-level removal also failed — manual cleanup needed`, {
|
|
1274
|
+
path: mergeWorkDir,
|
|
1275
|
+
error: osMsg,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Prune stale worktree references
|
|
1281
|
+
runGit(["worktree", "prune"], repoRoot);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ── Transaction Record Persistence (TP-033) ─────────────────────────
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Persist a transaction record to disk as JSON.
|
|
1288
|
+
*
|
|
1289
|
+
* Written to: `.pi/verification/{opId}/txn-b{batchId}-repo-{repoId}-wave-{n}-lane-{k}.json`
|
|
1290
|
+
*
|
|
1291
|
+
* When repoId is null/undefined (repo mode), uses "default" as the repo slug.
|
|
1292
|
+
* Non-alphanumeric characters in repoId are sanitized to underscores.
|
|
1293
|
+
*
|
|
1294
|
+
* @param record - The transaction record to persist
|
|
1295
|
+
* @param stateRoot - Root directory for .pi state files
|
|
1296
|
+
*/
|
|
1297
|
+
/**
|
|
1298
|
+
* Persist a transaction record to disk. Returns null on success, or an error
|
|
1299
|
+
* message string on failure. Persistence is best-effort — callers should
|
|
1300
|
+
* accumulate errors and surface them in MergeWaveResult.persistenceErrors
|
|
1301
|
+
* so operators know recovery guidance may reference missing files.
|
|
1302
|
+
*/
|
|
1303
|
+
function persistTransactionRecord(record: TransactionRecord, stateRoot: string): string | null {
|
|
1304
|
+
try {
|
|
1305
|
+
const repoSlug = record.repoId ? record.repoId.replace(/[^a-zA-Z0-9_-]/g, "_") : "default";
|
|
1306
|
+
const verifyDir = join(stateRoot, ".pi", "verification", record.opId);
|
|
1307
|
+
mkdirSync(verifyDir, { recursive: true });
|
|
1308
|
+
const fileName = `txn-b${record.batchId}-repo-${repoSlug}-wave-${record.waveIndex}-lane-${record.laneNumber}.json`;
|
|
1309
|
+
writeFileSync(join(verifyDir, fileName), JSON.stringify(record, null, 2), "utf-8");
|
|
1310
|
+
execLog("merge", `W${record.waveIndex}`, `transaction record persisted`, {
|
|
1311
|
+
file: fileName,
|
|
1312
|
+
status: record.status,
|
|
1313
|
+
});
|
|
1314
|
+
return null;
|
|
1315
|
+
} catch (err: unknown) {
|
|
1316
|
+
// Transaction record persistence is best-effort — don't fail the merge
|
|
1317
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1318
|
+
execLog("merge", `W${record.waveIndex}`, `failed to persist transaction record: ${errMsg}`);
|
|
1319
|
+
return `lane ${record.laneNumber} (repo: ${record.repoId ?? "default"}): ${errMsg}`;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ── Orchestrator-Side Verification (TP-032) ──────────────────────────
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Run post-merge verification and compare against baseline.
|
|
1327
|
+
*
|
|
1328
|
+
* Captures fingerprints from the merge worktree after a successful merge,
|
|
1329
|
+
* diffs against the pre-merge baseline, and classifies the result:
|
|
1330
|
+
* - "pass": no new failures (only pre-existing or fixed)
|
|
1331
|
+
* - "verification_new_failure": genuinely new failures detected
|
|
1332
|
+
* - "flaky_suspected": new failures disappeared on re-run (warning only)
|
|
1333
|
+
*
|
|
1334
|
+
* Flaky handling: when new failures are detected and flakyReruns > 0,
|
|
1335
|
+
* only the commands that produced new failures are re-run up to
|
|
1336
|
+
* flakyReruns times. If the failures disappear on any re-run attempt,
|
|
1337
|
+
* the result is reclassified as "flaky_suspected". When flakyReruns is
|
|
1338
|
+
* 0, no re-runs are attempted and new failures immediately block.
|
|
1339
|
+
*
|
|
1340
|
+
* @param testingCommands - Named verification commands (from testing.commands config)
|
|
1341
|
+
* @param mergeWorkDir - Merge worktree path (post-merge state)
|
|
1342
|
+
* @param baseline - Pre-merge baseline to compare against
|
|
1343
|
+
* @param laneNumber - Lane number (for logging/persistence)
|
|
1344
|
+
* @param waveIndex - Wave index (for persistence naming)
|
|
1345
|
+
* @param batchId - Batch ID (for persistence naming)
|
|
1346
|
+
* @param opId - Operator ID (for persistence naming)
|
|
1347
|
+
* @param sessionName - Session name for structured logging
|
|
1348
|
+
* @param stateRoot - State root for persistence (workspace root or repo root)
|
|
1349
|
+
* @param repoId - Repository ID for workspace-mode artifact naming (optional)
|
|
1350
|
+
* @param flakyReruns - Number of flaky re-runs (0 = disabled, default 1)
|
|
1351
|
+
* @returns VerificationBaselineResult with classification and details
|
|
1352
|
+
*/
|
|
1353
|
+
function runPostMergeVerification(
|
|
1354
|
+
testingCommands: Record<string, string>,
|
|
1355
|
+
mergeWorkDir: string,
|
|
1356
|
+
baseline: VerificationBaseline,
|
|
1357
|
+
laneNumber: number,
|
|
1358
|
+
waveIndex: number,
|
|
1359
|
+
batchId: string,
|
|
1360
|
+
opId: string,
|
|
1361
|
+
sessionName: string,
|
|
1362
|
+
stateRoot: string,
|
|
1363
|
+
repoId?: string,
|
|
1364
|
+
flakyReruns: number = 1,
|
|
1365
|
+
): VerificationBaselineResult {
|
|
1366
|
+
execLog("merge", sessionName, "capturing post-merge verification fingerprints");
|
|
1367
|
+
|
|
1368
|
+
// Capture post-merge fingerprints
|
|
1369
|
+
const postMerge = captureBaseline(testingCommands, mergeWorkDir);
|
|
1370
|
+
|
|
1371
|
+
// Persist post-merge snapshot for debugging
|
|
1372
|
+
try {
|
|
1373
|
+
const verifyDir = join(stateRoot, ".pi", "verification", opId);
|
|
1374
|
+
mkdirSync(verifyDir, { recursive: true });
|
|
1375
|
+
// TP-032 R006-1: Include repoId in filename to prevent overwrites
|
|
1376
|
+
// when mergeWaveByRepo() calls mergeWave() once per repo group.
|
|
1377
|
+
const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
|
|
1378
|
+
const postFileName = `post-b${batchId}-w${waveIndex}${repoSuffix}-lane${laneNumber}.json`;
|
|
1379
|
+
writeFileSync(join(verifyDir, postFileName), JSON.stringify(postMerge, null, 2), "utf-8");
|
|
1380
|
+
} catch {
|
|
1381
|
+
// Best effort — persistence failure doesn't block verification
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Diff fingerprints
|
|
1385
|
+
const diff = diffFingerprints(baseline.fingerprints, postMerge.fingerprints);
|
|
1386
|
+
|
|
1387
|
+
execLog("merge", sessionName, "verification diff computed", {
|
|
1388
|
+
newFailures: diff.newFailures.length,
|
|
1389
|
+
preExisting: diff.preExisting.length,
|
|
1390
|
+
fixed: diff.fixed.length,
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
// No new failures — pass
|
|
1394
|
+
if (diff.newFailures.length === 0) {
|
|
1395
|
+
return {
|
|
1396
|
+
performed: true,
|
|
1397
|
+
newFailureCount: 0,
|
|
1398
|
+
preExistingCount: diff.preExisting.length,
|
|
1399
|
+
fixedCount: diff.fixed.length,
|
|
1400
|
+
classification: "pass",
|
|
1401
|
+
newFailureSummary: "",
|
|
1402
|
+
flakyRerunPerformed: false,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// ── Flaky re-run: re-run only the commands that produced new failures ──
|
|
1407
|
+
// Only when flakyReruns > 0 (0 = disabled — any new failure immediately blocks)
|
|
1408
|
+
if (flakyReruns > 0) {
|
|
1409
|
+
// Identify which commandIds produced new failures
|
|
1410
|
+
const failedCommandIds = new Set(diff.newFailures.map((fp) => fp.commandId));
|
|
1411
|
+
const rerunCommands: Record<string, string> = {};
|
|
1412
|
+
for (const cmdId of failedCommandIds) {
|
|
1413
|
+
if (testingCommands[cmdId]) {
|
|
1414
|
+
rerunCommands[cmdId] = testingCommands[cmdId];
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Re-run up to flakyReruns times; break early if failures clear
|
|
1419
|
+
let clearedOnRerun = false;
|
|
1420
|
+
for (let attempt = 0; attempt < flakyReruns; attempt++) {
|
|
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
|
+
);
|
|
1430
|
+
|
|
1431
|
+
const rerunResults = runVerificationCommands(rerunCommands, mergeWorkDir);
|
|
1432
|
+
|
|
1433
|
+
// Parse re-run fingerprints
|
|
1434
|
+
const rerunFingerprints: TestFingerprint[] = [];
|
|
1435
|
+
for (const result of rerunResults) {
|
|
1436
|
+
const fps = parseTestOutput(result);
|
|
1437
|
+
rerunFingerprints.push(...fps);
|
|
1438
|
+
}
|
|
1439
|
+
const dedupedRerun = deduplicateFingerprints(rerunFingerprints);
|
|
1440
|
+
|
|
1441
|
+
// Re-diff: compare baseline against re-run results for the failed commands only
|
|
1442
|
+
// Filter baseline fingerprints to only the commands we re-ran
|
|
1443
|
+
const baselineForRerun = baseline.fingerprints.filter((fp) =>
|
|
1444
|
+
failedCommandIds.has(fp.commandId),
|
|
1445
|
+
);
|
|
1446
|
+
const rerunDiff = diffFingerprints(baselineForRerun, dedupedRerun);
|
|
1447
|
+
|
|
1448
|
+
if (rerunDiff.newFailures.length === 0) {
|
|
1449
|
+
// Failures disappeared on re-run — flaky suspected
|
|
1450
|
+
execLog(
|
|
1451
|
+
"merge",
|
|
1452
|
+
sessionName,
|
|
1453
|
+
`flaky re-run ${attempt + 1} cleared all new failures — classifying as flaky_suspected`,
|
|
1454
|
+
);
|
|
1455
|
+
clearedOnRerun = true;
|
|
1456
|
+
break;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// If this is the last attempt and failures persist, return failure
|
|
1460
|
+
if (attempt === flakyReruns - 1) {
|
|
1461
|
+
const summary = rerunDiff.newFailures
|
|
1462
|
+
.slice(0, 5)
|
|
1463
|
+
.map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1464
|
+
.join("; ");
|
|
1465
|
+
const truncated =
|
|
1466
|
+
rerunDiff.newFailures.length > 5 ? ` ... and ${rerunDiff.newFailures.length - 5} more` : "";
|
|
1467
|
+
|
|
1468
|
+
return {
|
|
1469
|
+
performed: true,
|
|
1470
|
+
newFailureCount: rerunDiff.newFailures.length,
|
|
1471
|
+
preExistingCount: diff.preExisting.length,
|
|
1472
|
+
fixedCount: diff.fixed.length,
|
|
1473
|
+
classification: "verification_new_failure",
|
|
1474
|
+
newFailureSummary: summary + truncated,
|
|
1475
|
+
flakyRerunPerformed: true,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (clearedOnRerun) {
|
|
1481
|
+
return {
|
|
1482
|
+
performed: true,
|
|
1483
|
+
newFailureCount: 0,
|
|
1484
|
+
preExistingCount: diff.preExisting.length,
|
|
1485
|
+
fixedCount: diff.fixed.length,
|
|
1486
|
+
classification: "flaky_suspected",
|
|
1487
|
+
newFailureSummary: `Flaky: ${diff.newFailures.length} failure(s) disappeared on re-run`,
|
|
1488
|
+
flakyRerunPerformed: true,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// flakyReruns === 0 or fallthrough: new failures block immediately
|
|
1494
|
+
const summary = diff.newFailures
|
|
1495
|
+
.slice(0, 5)
|
|
1496
|
+
.map((fp) => `${fp.commandId}:${fp.file}:${fp.case} (${fp.kind})`)
|
|
1497
|
+
.join("; ");
|
|
1498
|
+
const truncated =
|
|
1499
|
+
diff.newFailures.length > 5 ? ` ... and ${diff.newFailures.length - 5} more` : "";
|
|
1500
|
+
|
|
1501
|
+
return {
|
|
1502
|
+
performed: true,
|
|
1503
|
+
newFailureCount: diff.newFailures.length,
|
|
1504
|
+
preExistingCount: diff.preExisting.length,
|
|
1505
|
+
fixedCount: diff.fixed.length,
|
|
1506
|
+
classification: "verification_new_failure",
|
|
1507
|
+
newFailureSummary: summary + truncated,
|
|
1508
|
+
flakyRerunPerformed: flakyReruns > 0,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Merge a completed wave's lane branches into the base branch.
|
|
1514
|
+
*
|
|
1515
|
+
* Orchestration flow:
|
|
1516
|
+
* 1. Filter to only succeeded lanes (failed lanes are not merged)
|
|
1517
|
+
* 2. Determine merge order (fewest-files-first or sequential)
|
|
1518
|
+
* 3. For each lane, sequentially:
|
|
1519
|
+
* a. Build merge request content
|
|
1520
|
+
* b. Write merge request to temp file
|
|
1521
|
+
* c. Spawn merge agent session (in main repo)
|
|
1522
|
+
* d. Wait for merge result
|
|
1523
|
+
* e. Handle result (continue, log, or pause)
|
|
1524
|
+
* 4. Return MergeWaveResult
|
|
1525
|
+
*
|
|
1526
|
+
* Sequential execution is mandatory — the base branch is a shared
|
|
1527
|
+
* resource, and each merge must see the prior merge's result.
|
|
1528
|
+
*
|
|
1529
|
+
* On CONFLICT_UNRESOLVED or BUILD_FAILURE: stops merging remaining lanes
|
|
1530
|
+
* and returns with failure status.
|
|
1531
|
+
*
|
|
1532
|
+
* Temp file cleanup: merge request files are cleaned up after each lane,
|
|
1533
|
+
* regardless of outcome. Result files are left for debugging.
|
|
1534
|
+
*
|
|
1535
|
+
* @param completedLanes - Lanes that completed execution (from wave result)
|
|
1536
|
+
* @param waveResult - The wave execution result (for lane status filtering)
|
|
1537
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
1538
|
+
* @param config - Orchestrator configuration
|
|
1539
|
+
* @param repoRoot - Main repository root
|
|
1540
|
+
* @param batchId - Batch ID for session naming
|
|
1541
|
+
* @param baseBranch - Branch to merge into (captured at batch start)
|
|
1542
|
+
* @returns MergeWaveResult with per-lane outcomes
|
|
1543
|
+
*/
|
|
1544
|
+
export async function mergeWave(
|
|
1545
|
+
completedLanes: AllocatedLane[],
|
|
1546
|
+
waveResult: WaveExecutionResult,
|
|
1547
|
+
waveIndex: number,
|
|
1548
|
+
config: OrchestratorConfig,
|
|
1549
|
+
repoRoot: string,
|
|
1550
|
+
batchId: string,
|
|
1551
|
+
baseBranch: string,
|
|
1552
|
+
stateRoot?: string,
|
|
1553
|
+
agentRoot?: string,
|
|
1554
|
+
testingCommands?: Record<string, string>,
|
|
1555
|
+
repoId?: string,
|
|
1556
|
+
healthMonitor?: MergeHealthMonitor | null,
|
|
1557
|
+
forceMixedOutcome?: boolean,
|
|
1558
|
+
runtimeBackend?: RuntimeBackend,
|
|
1559
|
+
): Promise<MergeWaveResult> {
|
|
1560
|
+
const startTime = Date.now();
|
|
1561
|
+
const sessionPrefix = config.orchestrator.sessionPrefix;
|
|
1562
|
+
const opId = resolveOperatorId(config);
|
|
1563
|
+
const targetBranch = baseBranch;
|
|
1564
|
+
const laneResults: MergeLaneResult[] = [];
|
|
1565
|
+
|
|
1566
|
+
// Build lane outcome lookup for merge eligibility checks.
|
|
1567
|
+
const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
|
|
1568
|
+
for (const laneOutcome of waveResult.laneResults) {
|
|
1569
|
+
laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// A lane is mergeable if:
|
|
1573
|
+
// - It has at least one succeeded task, AND
|
|
1574
|
+
// - It has no hard failures (failed/stalled).
|
|
1575
|
+
//
|
|
1576
|
+
// This allows succeeded+skipped lanes (e.g., stop-wave skip of remaining tasks)
|
|
1577
|
+
// to merge their committed work, while excluding mixed succeeded+failed lanes.
|
|
1578
|
+
//
|
|
1579
|
+
// TP-078: When forceMixedOutcome is true, lanes with both succeeded and
|
|
1580
|
+
// failed/stalled tasks are also considered mergeable. This allows the
|
|
1581
|
+
// orch_force_merge tool to merge succeeded commits from mixed-outcome lanes.
|
|
1582
|
+
const mergeableLanes = completedLanes.filter((lane) => {
|
|
1583
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1584
|
+
if (!outcome) return false;
|
|
1585
|
+
|
|
1586
|
+
const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
|
|
1587
|
+
const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
|
|
1588
|
+
|
|
1589
|
+
if (forceMixedOutcome) {
|
|
1590
|
+
// In force mode, merge any lane with at least one succeeded task
|
|
1591
|
+
return hasSucceeded;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
return hasSucceeded && !hasHardFailure;
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
if (mergeableLanes.length === 0) {
|
|
1598
|
+
// TP-171: Even when no lanes are mergeable, skipped-task lanes may have
|
|
1599
|
+
// partial progress (STATUS.md updates) that should be staged on the target
|
|
1600
|
+
// branch so it survives integration. Stage artifacts directly without
|
|
1601
|
+
// creating a full merge worktree.
|
|
1602
|
+
const skippedOnlyLanes = completedLanes.filter((lane) => {
|
|
1603
|
+
if (!lane.worktreePath) return false;
|
|
1604
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1605
|
+
if (!outcome) return false;
|
|
1606
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
1607
|
+
});
|
|
1608
|
+
if (skippedOnlyLanes.length > 0) {
|
|
1609
|
+
stageSkippedArtifactsToTargetBranch(skippedOnlyLanes, waveIndex, repoRoot, targetBranch);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
|
|
1613
|
+
return {
|
|
1614
|
+
waveIndex,
|
|
1615
|
+
status: "succeeded", // vacuous success — nothing to merge
|
|
1616
|
+
laneResults: [],
|
|
1617
|
+
failedLane: null,
|
|
1618
|
+
failureReason: null,
|
|
1619
|
+
totalDurationMs: Date.now() - startTime,
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// Determine merge order
|
|
1624
|
+
const orderedLanes = determineMergeOrder(mergeableLanes, config.merge.order);
|
|
1625
|
+
|
|
1626
|
+
// TP-171: Identify lanes with skipped tasks that are NOT in the mergeable set.
|
|
1627
|
+
// These lanes won't have their branches merged, but their task artifacts
|
|
1628
|
+
// (STATUS.md, .reviews) should still be staged so partial progress is preserved
|
|
1629
|
+
// through integration. Only lanes with worktree paths can contribute artifacts.
|
|
1630
|
+
const mergeableLaneNumbers = new Set(mergeableLanes.map((l) => l.laneNumber));
|
|
1631
|
+
const skippedArtifactLanes = completedLanes.filter((lane) => {
|
|
1632
|
+
if (mergeableLaneNumbers.has(lane.laneNumber)) return false;
|
|
1633
|
+
if (!lane.worktreePath) return false;
|
|
1634
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
1635
|
+
if (!outcome) return false;
|
|
1636
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, {
|
|
1640
|
+
order: config.merge.order,
|
|
1641
|
+
lanes: orderedLanes.map((l) => l.laneNumber).join(","),
|
|
1642
|
+
skippedArtifactLanes:
|
|
1643
|
+
skippedArtifactLanes.length > 0
|
|
1644
|
+
? skippedArtifactLanes.map((l) => l.laneNumber).join(",")
|
|
1645
|
+
: undefined,
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
// ── Create isolated merge worktree ──────────────────────────────
|
|
1649
|
+
// Merging in a dedicated worktree prevents dirty-worktree failures
|
|
1650
|
+
// caused by user edits or orchestrator-generated files in the main repo.
|
|
1651
|
+
// The merge worktree lives inside the batch container alongside lane worktrees:
|
|
1652
|
+
// {basePath}/{opId}-{batchId}/merge
|
|
1653
|
+
const tempBranch = `_merge-temp-${opId}-${batchId}`;
|
|
1654
|
+
const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config);
|
|
1655
|
+
|
|
1656
|
+
// Clean up stale merge worktree/branch from prior failed attempt.
|
|
1657
|
+
// TP-029: Apply forceRemoveMergeWorktree fallback so stale merge worktrees
|
|
1658
|
+
// from prior failed attempts don't block new merge creation.
|
|
1659
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1660
|
+
if (existsSync(mergeWorkDir)) {
|
|
1661
|
+
// Force cleanup didn't fully remove — wait and retry once
|
|
1662
|
+
await sleepAsync(500);
|
|
1663
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1664
|
+
}
|
|
1665
|
+
try {
|
|
1666
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1667
|
+
} catch {
|
|
1668
|
+
/* branch may not exist */
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// Create temp branch at target branch HEAD, then worktree
|
|
1672
|
+
const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot });
|
|
1673
|
+
if (branchResult.status !== 0) {
|
|
1674
|
+
const err = branchResult.stderr?.toString().trim() || "unknown error";
|
|
1675
|
+
execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`);
|
|
1676
|
+
return {
|
|
1677
|
+
waveIndex,
|
|
1678
|
+
status: "failed",
|
|
1679
|
+
laneResults: [],
|
|
1680
|
+
failedLane: null,
|
|
1681
|
+
failureReason: `Failed to create merge temp branch: ${err}`,
|
|
1682
|
+
totalDurationMs: Date.now() - startTime,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], {
|
|
1687
|
+
cwd: repoRoot,
|
|
1688
|
+
});
|
|
1689
|
+
if (wtResult.status !== 0) {
|
|
1690
|
+
const err = wtResult.stderr?.toString().trim() || "unknown error";
|
|
1691
|
+
execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`);
|
|
1692
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1693
|
+
return {
|
|
1694
|
+
waveIndex,
|
|
1695
|
+
status: "failed",
|
|
1696
|
+
laneResults: [],
|
|
1697
|
+
failedLane: null,
|
|
1698
|
+
failureReason: `Failed to create merge worktree: ${err}`,
|
|
1699
|
+
totalDurationMs: Date.now() - startTime,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
execLog("merge", `W${waveIndex}`, `merge worktree created`, {
|
|
1704
|
+
worktree: mergeWorkDir,
|
|
1705
|
+
tempBranch,
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
// ── Orchestrator-side baseline capture (TP-032) ────────────────
|
|
1709
|
+
// Capture verification fingerprints on the pre-merge state of the merge
|
|
1710
|
+
// worktree. This baseline is compared against post-merge fingerprints
|
|
1711
|
+
// for each lane to detect genuinely new failures vs pre-existing ones.
|
|
1712
|
+
// Only runs when verification.enabled === true AND testing.commands present.
|
|
1713
|
+
let baseline: VerificationBaseline | null = null;
|
|
1714
|
+
const hasTestingCommands = testingCommands && Object.keys(testingCommands).length > 0;
|
|
1715
|
+
const verificationEnabled = config.verification.enabled;
|
|
1716
|
+
const verificationMode = config.verification.mode;
|
|
1717
|
+
const flakyReruns = config.verification.flaky_reruns;
|
|
1718
|
+
|
|
1719
|
+
if (verificationEnabled && !hasTestingCommands) {
|
|
1720
|
+
// Verification is enabled but no testing commands configured — treat as
|
|
1721
|
+
// baseline-unavailable. Strict/permissive handling below.
|
|
1722
|
+
if (verificationMode === "strict") {
|
|
1723
|
+
execLog(
|
|
1724
|
+
"merge",
|
|
1725
|
+
`W${waveIndex}`,
|
|
1726
|
+
"verification enabled but no testing commands configured — strict mode: failing merge",
|
|
1727
|
+
);
|
|
1728
|
+
// Clean up worktree and temp branch before returning failure
|
|
1729
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1730
|
+
try {
|
|
1731
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1732
|
+
} catch {
|
|
1733
|
+
/* best effort */
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
waveIndex,
|
|
1737
|
+
status: "failed",
|
|
1738
|
+
laneResults: [],
|
|
1739
|
+
failedLane: null,
|
|
1740
|
+
failureReason:
|
|
1741
|
+
"Verification enabled (strict mode) but no testing commands configured in taskRunner.testing.commands",
|
|
1742
|
+
totalDurationMs: Date.now() - startTime,
|
|
1743
|
+
};
|
|
1744
|
+
} else {
|
|
1745
|
+
execLog(
|
|
1746
|
+
"merge",
|
|
1747
|
+
`W${waveIndex}`,
|
|
1748
|
+
"verification enabled but no testing commands configured — permissive mode: continuing without verification",
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
if (verificationEnabled && hasTestingCommands) {
|
|
1754
|
+
execLog("merge", `W${waveIndex}`, "capturing verification baseline on pre-merge state", {
|
|
1755
|
+
commandCount: Object.keys(testingCommands).length,
|
|
1756
|
+
commands: Object.keys(testingCommands).join(", "),
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
try {
|
|
1760
|
+
baseline = captureBaseline(testingCommands, mergeWorkDir);
|
|
1761
|
+
|
|
1762
|
+
// Persist baseline for debugging/auditability
|
|
1763
|
+
const piDir = stateRoot ?? repoRoot;
|
|
1764
|
+
const verifyDir = join(piDir, ".pi", "verification", opId);
|
|
1765
|
+
mkdirSync(verifyDir, { recursive: true });
|
|
1766
|
+
// TP-032 R006-1: Include repoId in filename to prevent overwrites
|
|
1767
|
+
// when mergeWaveByRepo() calls mergeWave() once per repo group.
|
|
1768
|
+
const repoSuffix = repoId ? `-repo-${repoId.replace(/[^a-zA-Z0-9_-]/g, "_")}` : "";
|
|
1769
|
+
const baselineFileName = `baseline-b${batchId}-w${waveIndex}${repoSuffix}.json`;
|
|
1770
|
+
writeFileSync(join(verifyDir, baselineFileName), JSON.stringify(baseline, null, 2), "utf-8");
|
|
1771
|
+
|
|
1772
|
+
execLog("merge", `W${waveIndex}`, "verification baseline captured", {
|
|
1773
|
+
fingerprints: baseline.fingerprints.length,
|
|
1774
|
+
preExistingFailures: baseline.fingerprints.length,
|
|
1775
|
+
storedAt: join(verifyDir, baselineFileName),
|
|
1776
|
+
});
|
|
1777
|
+
} catch (err: unknown) {
|
|
1778
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1779
|
+
if (verificationMode === "strict") {
|
|
1780
|
+
execLog("merge", `W${waveIndex}`, `baseline capture failed — strict mode: failing merge`, {
|
|
1781
|
+
error: errMsg,
|
|
1782
|
+
});
|
|
1783
|
+
// Clean up worktree and temp branch before returning failure
|
|
1784
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
1785
|
+
try {
|
|
1786
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
1787
|
+
} catch {
|
|
1788
|
+
/* best effort */
|
|
1789
|
+
}
|
|
1790
|
+
return {
|
|
1791
|
+
waveIndex,
|
|
1792
|
+
status: "failed",
|
|
1793
|
+
laneResults: [],
|
|
1794
|
+
failedLane: null,
|
|
1795
|
+
failureReason: `Verification baseline capture failed (strict mode): ${errMsg}`,
|
|
1796
|
+
totalDurationMs: Date.now() - startTime,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
execLog(
|
|
1800
|
+
"merge",
|
|
1801
|
+
`W${waveIndex}`,
|
|
1802
|
+
`baseline capture failed — permissive mode: continuing without baseline verification`,
|
|
1803
|
+
{
|
|
1804
|
+
error: errMsg,
|
|
1805
|
+
},
|
|
1806
|
+
);
|
|
1807
|
+
// Permissive: baseline capture failure is non-fatal — merge proceeds without
|
|
1808
|
+
// orchestrator-side verification. Merge-agent verification (merge.verify)
|
|
1809
|
+
// still applies independently.
|
|
1810
|
+
baseline = null;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// Sequential merge loop
|
|
1815
|
+
let failedLane: number | null = null;
|
|
1816
|
+
let failureReason: string | null = null;
|
|
1817
|
+
// TP-032 R006-2: When verification rollback fails, the temp branch still contains
|
|
1818
|
+
// the bad merge commit. Branch advancement MUST be blocked entirely — not just for
|
|
1819
|
+
// the verification-blocked lane, but for all lanes, because the temp branch HEAD
|
|
1820
|
+
// includes the unverified commit and any prior successful merges built on top of it.
|
|
1821
|
+
let blockAdvancement = false;
|
|
1822
|
+
|
|
1823
|
+
// TP-033: Collect transaction records for all lane merges in this wave
|
|
1824
|
+
const transactionRecords: TransactionRecord[] = [];
|
|
1825
|
+
// TP-033 R004-2: Track persistence errors for operator visibility
|
|
1826
|
+
const persistenceErrors: string[] = [];
|
|
1827
|
+
// TP-033: Track whether any rollback failure triggered safe-stop
|
|
1828
|
+
let rollbackFailed = false;
|
|
1829
|
+
|
|
1830
|
+
for (const lane of orderedLanes) {
|
|
1831
|
+
const laneStart = Date.now();
|
|
1832
|
+
const txnStartedAt = new Date().toISOString();
|
|
1833
|
+
const sessionName = `${sessionPrefix}-${opId}-merge-${lane.laneNumber}`;
|
|
1834
|
+
const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
|
|
1835
|
+
const piDir = stateRoot ?? repoRoot;
|
|
1836
|
+
const resultFilePath = join(piDir, ".pi", resultFileName);
|
|
1837
|
+
const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
|
|
1838
|
+
const requestFilePath = join(piDir, ".pi", requestFileName);
|
|
1839
|
+
|
|
1840
|
+
// ── TP-033: Capture baseHEAD (temp branch HEAD before lane merge) ──
|
|
1841
|
+
// Always captured for transaction record — not conditional on baseline.
|
|
1842
|
+
// This is the rollback target if verification detects new failures.
|
|
1843
|
+
let baseHEAD = "";
|
|
1844
|
+
{
|
|
1845
|
+
const headResult = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
1846
|
+
cwd: mergeWorkDir,
|
|
1847
|
+
encoding: "utf-8",
|
|
1848
|
+
});
|
|
1849
|
+
if (headResult.status === 0) {
|
|
1850
|
+
baseHEAD = headResult.stdout.trim();
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// ── TP-033: Capture laneHEAD (source branch tip being merged in) ──
|
|
1855
|
+
let laneHEAD = "";
|
|
1856
|
+
{
|
|
1857
|
+
const laneRef = spawnSync("git", ["rev-parse", lane.branch], {
|
|
1858
|
+
cwd: repoRoot,
|
|
1859
|
+
encoding: "utf-8",
|
|
1860
|
+
});
|
|
1861
|
+
if (laneRef.status === 0) {
|
|
1862
|
+
laneHEAD = laneRef.stdout.trim();
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// TP-032 compat: preLaneHead is baseHEAD (renamed for clarity in txn model)
|
|
1867
|
+
const preLaneHead = baseHEAD;
|
|
1868
|
+
|
|
1869
|
+
execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
|
|
1870
|
+
sourceBranch: lane.branch,
|
|
1871
|
+
targetBranch,
|
|
1872
|
+
baseHEAD: baseHEAD.slice(0, 8),
|
|
1873
|
+
laneHEAD: laneHEAD.slice(0, 8),
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
try {
|
|
1877
|
+
// Clean up any stale result file from prior attempt
|
|
1878
|
+
if (existsSync(resultFilePath)) {
|
|
1879
|
+
try {
|
|
1880
|
+
unlinkSync(resultFilePath);
|
|
1881
|
+
} catch {
|
|
1882
|
+
// Best effort
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// Build merge request content
|
|
1887
|
+
// TP-032 R006-3: Preserve merge.verify commands independently of baseline
|
|
1888
|
+
// fingerprinting. The orchestrator-side baseline comparison (testing.commands)
|
|
1889
|
+
// is additive — it does NOT replace the merge agent's own verification
|
|
1890
|
+
// (merge.verify). Agents may run build checks or other non-fingerprintable
|
|
1891
|
+
// commands via merge.verify that must not be silently suppressed.
|
|
1892
|
+
const mergeRequestContent = buildMergeRequest(
|
|
1893
|
+
lane,
|
|
1894
|
+
targetBranch,
|
|
1895
|
+
waveIndex,
|
|
1896
|
+
config.merge.verify,
|
|
1897
|
+
resultFilePath,
|
|
1898
|
+
);
|
|
1899
|
+
|
|
1900
|
+
// Write merge request to temp file
|
|
1901
|
+
writeFileSync(requestFilePath, mergeRequestContent, "utf-8");
|
|
1902
|
+
|
|
1903
|
+
// ── TP-038: Spawn + wait with retry-on-timeout ──────────────
|
|
1904
|
+
// On MERGE_TIMEOUT, retry with 2× the previous timeout (up to
|
|
1905
|
+
// MERGE_TIMEOUT_MAX_RETRIES). Before each retry, re-read config
|
|
1906
|
+
// from disk so operators can increase merge.timeoutMinutes without
|
|
1907
|
+
// restarting the session.
|
|
1908
|
+
let mergeResult: MergeResult;
|
|
1909
|
+
{
|
|
1910
|
+
const configRoot = stateRoot ?? repoRoot;
|
|
1911
|
+
let currentTimeoutMs = (config.merge.timeout_minutes ?? 10) * 60 * 1000;
|
|
1912
|
+
let lastTimeoutError: MergeError | null = null;
|
|
1913
|
+
|
|
1914
|
+
for (let attempt = 0; attempt <= MERGE_TIMEOUT_MAX_RETRIES; attempt++) {
|
|
1915
|
+
// On retry: clean up stale result, re-read config, apply backoff
|
|
1916
|
+
if (attempt > 0) {
|
|
1917
|
+
// Re-read config from disk (TP-038: allows operator to adjust timeout)
|
|
1918
|
+
const freshTimeoutMs = reloadMergeTimeoutMs(configRoot);
|
|
1919
|
+
// Apply 2× backoff: double the timeout for each retry attempt
|
|
1920
|
+
currentTimeoutMs = freshTimeoutMs * Math.pow(2, attempt);
|
|
1921
|
+
|
|
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
|
+
);
|
|
1932
|
+
|
|
1933
|
+
// Clean up stale result file from prior attempt
|
|
1934
|
+
if (existsSync(resultFilePath)) {
|
|
1935
|
+
try {
|
|
1936
|
+
unlinkSync(resultFilePath);
|
|
1937
|
+
} catch {
|
|
1938
|
+
/* best effort */
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Re-spawn merge agent for the retry.
|
|
1943
|
+
// Kill previous V2 agent handle to prevent orphan/duplicate.
|
|
1944
|
+
killMergeAgentV2(sessionName);
|
|
1945
|
+
await spawnMergeAgentV2(
|
|
1946
|
+
sessionName,
|
|
1947
|
+
repoRoot,
|
|
1948
|
+
mergeWorkDir,
|
|
1949
|
+
requestFilePath,
|
|
1950
|
+
config,
|
|
1951
|
+
stateRoot,
|
|
1952
|
+
agentRoot,
|
|
1953
|
+
batchId,
|
|
1954
|
+
waveIndex,
|
|
1955
|
+
);
|
|
1956
|
+
} else {
|
|
1957
|
+
// First attempt: spawn merge agent (Runtime V2)
|
|
1958
|
+
await spawnMergeAgentV2(
|
|
1959
|
+
sessionName,
|
|
1960
|
+
repoRoot,
|
|
1961
|
+
mergeWorkDir,
|
|
1962
|
+
requestFilePath,
|
|
1963
|
+
config,
|
|
1964
|
+
stateRoot,
|
|
1965
|
+
agentRoot,
|
|
1966
|
+
batchId,
|
|
1967
|
+
waveIndex,
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
mergeResult = await waitForMergeResult(
|
|
1973
|
+
resultFilePath,
|
|
1974
|
+
sessionName,
|
|
1975
|
+
currentTimeoutMs,
|
|
1976
|
+
runtimeBackend,
|
|
1977
|
+
);
|
|
1978
|
+
// TP-056: Deregister session from health monitor on completion
|
|
1979
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1980
|
+
lastTimeoutError = null;
|
|
1981
|
+
break; // Success — exit retry loop
|
|
1982
|
+
} catch (waitErr: unknown) {
|
|
1983
|
+
if (
|
|
1984
|
+
waitErr instanceof MergeError &&
|
|
1985
|
+
waitErr.code === "MERGE_TIMEOUT" &&
|
|
1986
|
+
attempt < MERGE_TIMEOUT_MAX_RETRIES
|
|
1987
|
+
) {
|
|
1988
|
+
// Timeout — will retry on next loop iteration
|
|
1989
|
+
lastTimeoutError = waitErr;
|
|
1990
|
+
// TP-056: Deregister before retry (will re-register on respawn)
|
|
1991
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
// Non-timeout error or final retry exhausted — propagate
|
|
1995
|
+
// TP-056: Deregister session from health monitor on error
|
|
1996
|
+
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1997
|
+
throw waitErr;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// TypeScript: mergeResult is guaranteed to be assigned here
|
|
2002
|
+
// (either break from loop or throw propagated the error)
|
|
2003
|
+
mergeResult = mergeResult!;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// Clean up request file (leave result file for debugging)
|
|
2007
|
+
try {
|
|
2008
|
+
unlinkSync(requestFilePath);
|
|
2009
|
+
} catch {
|
|
2010
|
+
// Best effort
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// Record lane result (verificationBaseline populated below if applicable)
|
|
2014
|
+
const laneResult: MergeLaneResult = {
|
|
2015
|
+
laneNumber: lane.laneNumber,
|
|
2016
|
+
laneId: lane.laneId,
|
|
2017
|
+
sourceBranch: lane.branch,
|
|
2018
|
+
targetBranch,
|
|
2019
|
+
result: mergeResult,
|
|
2020
|
+
error: null,
|
|
2021
|
+
durationMs: Date.now() - laneStart,
|
|
2022
|
+
repoId: lane.repoId,
|
|
2023
|
+
};
|
|
2024
|
+
laneResults.push(laneResult);
|
|
2025
|
+
|
|
2026
|
+
// Handle merge outcome
|
|
2027
|
+
switch (mergeResult.status) {
|
|
2028
|
+
case "SUCCESS":
|
|
2029
|
+
execLog("merge", sessionName, "merge succeeded", {
|
|
2030
|
+
mergeCommit: mergeResult.merge_commit.slice(0, 8),
|
|
2031
|
+
duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
|
|
2032
|
+
});
|
|
2033
|
+
break;
|
|
2034
|
+
|
|
2035
|
+
case "CONFLICT_RESOLVED":
|
|
2036
|
+
execLog("merge", sessionName, "merge succeeded with resolved conflicts", {
|
|
2037
|
+
mergeCommit: mergeResult.merge_commit.slice(0, 8),
|
|
2038
|
+
conflictCount: mergeResult.conflicts.length,
|
|
2039
|
+
duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
|
|
2040
|
+
});
|
|
2041
|
+
break;
|
|
2042
|
+
|
|
2043
|
+
case "CONFLICT_UNRESOLVED":
|
|
2044
|
+
execLog("merge", sessionName, "merge failed — unresolved conflicts", {
|
|
2045
|
+
conflictCount: mergeResult.conflicts.length,
|
|
2046
|
+
files: mergeResult.conflicts.map((c) => c.file).join(", "),
|
|
2047
|
+
});
|
|
2048
|
+
failedLane = lane.laneNumber;
|
|
2049
|
+
failureReason =
|
|
2050
|
+
`Unresolved merge conflicts in lane ${lane.laneNumber}: ` +
|
|
2051
|
+
mergeResult.conflicts.map((c) => c.file).join(", ");
|
|
2052
|
+
break;
|
|
2053
|
+
|
|
2054
|
+
case "BUILD_FAILURE":
|
|
2055
|
+
// TP-032: When baseline is active, BUILD_FAILURE from the merge agent
|
|
2056
|
+
// should not normally occur (we suppress verify commands). But if it does
|
|
2057
|
+
// (e.g., agent detected build failure independently), log and proceed as
|
|
2058
|
+
// a regular failure — the orchestrator-side verification below will not
|
|
2059
|
+
// run because the agent already reverted the merge commit.
|
|
2060
|
+
execLog("merge", sessionName, "merge failed — verification failed", {
|
|
2061
|
+
output: mergeResult.verification.output.slice(0, 200),
|
|
2062
|
+
baselineActive: !!baseline,
|
|
2063
|
+
});
|
|
2064
|
+
failedLane = lane.laneNumber;
|
|
2065
|
+
failureReason =
|
|
2066
|
+
`Post-merge verification failed in lane ${lane.laneNumber}: ` +
|
|
2067
|
+
mergeResult.verification.output.slice(0, 500);
|
|
2068
|
+
break;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// ── TP-033: Capture mergedHEAD after successful merge commit ──
|
|
2072
|
+
let mergedHEAD: string | null = null;
|
|
2073
|
+
if (mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED") {
|
|
2074
|
+
const postMergeRef = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
2075
|
+
cwd: mergeWorkDir,
|
|
2076
|
+
encoding: "utf-8",
|
|
2077
|
+
});
|
|
2078
|
+
if (postMergeRef.status === 0) {
|
|
2079
|
+
mergedHEAD = postMergeRef.stdout.trim();
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// ── TP-033: Initialize transaction record for this lane ──
|
|
2084
|
+
let txnStatus: TransactionStatus = failedLane !== null ? "merge_failed" : "committed";
|
|
2085
|
+
let txnRollbackAttempted = false;
|
|
2086
|
+
let txnRollbackResult: string | null = null;
|
|
2087
|
+
let txnRecoveryCommands: string[] = [];
|
|
2088
|
+
|
|
2089
|
+
// ── Orchestrator-side post-merge verification (TP-032) ──────
|
|
2090
|
+
// After a successful merge (SUCCESS/CONFLICT_RESOLVED), capture
|
|
2091
|
+
// post-merge fingerprints and diff against baseline. New failures
|
|
2092
|
+
// that weren't in the baseline block merge advancement.
|
|
2093
|
+
if (
|
|
2094
|
+
baseline !== null &&
|
|
2095
|
+
hasTestingCommands &&
|
|
2096
|
+
verificationEnabled &&
|
|
2097
|
+
failedLane === null &&
|
|
2098
|
+
(mergeResult.status === "SUCCESS" || mergeResult.status === "CONFLICT_RESOLVED")
|
|
2099
|
+
) {
|
|
2100
|
+
const verificationResult = runPostMergeVerification(
|
|
2101
|
+
testingCommands!,
|
|
2102
|
+
mergeWorkDir,
|
|
2103
|
+
baseline,
|
|
2104
|
+
lane.laneNumber,
|
|
2105
|
+
waveIndex,
|
|
2106
|
+
batchId,
|
|
2107
|
+
opId,
|
|
2108
|
+
sessionName,
|
|
2109
|
+
stateRoot ?? repoRoot,
|
|
2110
|
+
repoId,
|
|
2111
|
+
flakyReruns,
|
|
2112
|
+
);
|
|
2113
|
+
|
|
2114
|
+
// Attach verification result to the lane result
|
|
2115
|
+
laneResult.verificationBaseline = verificationResult;
|
|
2116
|
+
|
|
2117
|
+
if (verificationResult.classification === "verification_new_failure") {
|
|
2118
|
+
execLog("merge", sessionName, "orchestrator-side verification detected new failures", {
|
|
2119
|
+
newFailures: verificationResult.newFailureCount,
|
|
2120
|
+
preExisting: verificationResult.preExistingCount,
|
|
2121
|
+
summary: verificationResult.newFailureSummary.slice(0, 200),
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
// ── TP-032: Rollback merge commit on verification_new_failure ──
|
|
2125
|
+
// Reset the temp branch to pre-lane HEAD so the failed lane's
|
|
2126
|
+
// merge commit doesn't get included in branch advancement.
|
|
2127
|
+
// TP-032 R006-2: Mark lane as errored so it's excluded from success
|
|
2128
|
+
// counters and branch advancement (R006-3).
|
|
2129
|
+
laneResult.error = `verification_new_failure: ${verificationResult.newFailureCount} new failure(s)`;
|
|
2130
|
+
|
|
2131
|
+
if (preLaneHead) {
|
|
2132
|
+
txnRollbackAttempted = true;
|
|
2133
|
+
execLog("merge", sessionName, "rolling back temp branch to pre-lane HEAD", {
|
|
2134
|
+
preLaneHead: preLaneHead.slice(0, 8),
|
|
2135
|
+
});
|
|
2136
|
+
const resetResult = spawnSync("git", ["reset", "--hard", preLaneHead], { cwd: mergeWorkDir });
|
|
2137
|
+
if (resetResult.status === 0) {
|
|
2138
|
+
execLog("merge", sessionName, "temp branch rolled back successfully");
|
|
2139
|
+
txnStatus = "rolled_back";
|
|
2140
|
+
txnRollbackResult = "success";
|
|
2141
|
+
} else {
|
|
2142
|
+
// TP-032 R006-2: Rollback failure is merge-fatal for this wave.
|
|
2143
|
+
// The temp branch still contains the failing merge commit — target
|
|
2144
|
+
// ref advancement MUST NOT proceed for ANY lane, because the temp
|
|
2145
|
+
// branch HEAD includes the unverified commit.
|
|
2146
|
+
const resetErr = resetResult.stderr?.toString().trim() || "unknown error";
|
|
2147
|
+
laneResult.error =
|
|
2148
|
+
`verification_new_failure: rollback reset failed (${resetErr}) — ` +
|
|
2149
|
+
`temp branch may contain failing merge commit, advancement blocked`;
|
|
2150
|
+
blockAdvancement = true;
|
|
2151
|
+
txnStatus = "rollback_failed";
|
|
2152
|
+
txnRollbackResult = `reset failed: ${resetErr}`;
|
|
2153
|
+
|
|
2154
|
+
// ── TP-033: Safe-stop — emit recovery commands ──
|
|
2155
|
+
txnRecoveryCommands = [
|
|
2156
|
+
`# Recovery: manually reset merge worktree to pre-lane HEAD`,
|
|
2157
|
+
`cd "${mergeWorkDir}"`,
|
|
2158
|
+
`git reset --hard ${preLaneHead}`,
|
|
2159
|
+
`# Then re-run merge or resume orchestration`,
|
|
2160
|
+
];
|
|
2161
|
+
rollbackFailed = true;
|
|
2162
|
+
|
|
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
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
} else {
|
|
2174
|
+
// TP-032 R006-2: No pre-lane HEAD captured — cannot roll back.
|
|
2175
|
+
// Block advancement since the bad commit cannot be removed.
|
|
2176
|
+
laneResult.error =
|
|
2177
|
+
`verification_new_failure: no pre-lane HEAD available for rollback — ` +
|
|
2178
|
+
`advancement blocked`;
|
|
2179
|
+
blockAdvancement = true;
|
|
2180
|
+
txnStatus = "rollback_failed";
|
|
2181
|
+
txnRollbackAttempted = false;
|
|
2182
|
+
txnRollbackResult = "no baseHEAD captured — rollback impossible";
|
|
2183
|
+
|
|
2184
|
+
// ── TP-033: Safe-stop — emit recovery commands ──
|
|
2185
|
+
txnRecoveryCommands = [
|
|
2186
|
+
`# Recovery: no baseHEAD was captured for rollback`,
|
|
2187
|
+
`# Inspect merge worktree state manually:`,
|
|
2188
|
+
`cd "${mergeWorkDir}"`,
|
|
2189
|
+
`git log --oneline -5`,
|
|
2190
|
+
`# Determine the correct pre-merge commit and reset:`,
|
|
2191
|
+
`# git reset --hard <correct-commit>`,
|
|
2192
|
+
];
|
|
2193
|
+
rollbackFailed = true;
|
|
2194
|
+
|
|
2195
|
+
execLog(
|
|
2196
|
+
"merge",
|
|
2197
|
+
sessionName,
|
|
2198
|
+
"CRITICAL: no baseHEAD — cannot roll back, safe-stop triggered",
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
failedLane = lane.laneNumber;
|
|
2203
|
+
failureReason =
|
|
2204
|
+
`Verification baseline comparison detected ${verificationResult.newFailureCount} new failure(s) ` +
|
|
2205
|
+
`in lane ${lane.laneNumber} (${verificationResult.preExistingCount} pre-existing). ` +
|
|
2206
|
+
verificationResult.newFailureSummary.slice(0, 300);
|
|
2207
|
+
} else if (verificationResult.classification === "flaky_suspected") {
|
|
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
|
+
);
|
|
2217
|
+
// Warning only — does not block merge advancement
|
|
2218
|
+
} else {
|
|
2219
|
+
execLog("merge", sessionName, "orchestrator-side verification passed", {
|
|
2220
|
+
preExisting: verificationResult.preExistingCount,
|
|
2221
|
+
fixed: verificationResult.fixedCount,
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// ── TP-033: Persist transaction record for this lane ──
|
|
2227
|
+
const txnRecord: TransactionRecord = {
|
|
2228
|
+
opId,
|
|
2229
|
+
batchId,
|
|
2230
|
+
waveIndex,
|
|
2231
|
+
laneNumber: lane.laneNumber,
|
|
2232
|
+
repoId: repoId ?? null,
|
|
2233
|
+
baseHEAD,
|
|
2234
|
+
laneHEAD,
|
|
2235
|
+
mergedHEAD,
|
|
2236
|
+
status: txnStatus,
|
|
2237
|
+
rollbackAttempted: txnRollbackAttempted,
|
|
2238
|
+
rollbackResult: txnRollbackResult,
|
|
2239
|
+
recoveryCommands: txnRecoveryCommands,
|
|
2240
|
+
startedAt: txnStartedAt,
|
|
2241
|
+
completedAt: new Date().toISOString(),
|
|
2242
|
+
};
|
|
2243
|
+
transactionRecords.push(txnRecord);
|
|
2244
|
+
const txnPersistError = persistTransactionRecord(txnRecord, stateRoot ?? repoRoot);
|
|
2245
|
+
if (txnPersistError) persistenceErrors.push(txnPersistError);
|
|
2246
|
+
|
|
2247
|
+
// Stop merging if this lane failed
|
|
2248
|
+
if (failedLane !== null) break;
|
|
2249
|
+
} catch (err: unknown) {
|
|
2250
|
+
// Clean up request file on error
|
|
2251
|
+
try {
|
|
2252
|
+
if (existsSync(requestFilePath)) unlinkSync(requestFilePath);
|
|
2253
|
+
} catch {
|
|
2254
|
+
// Best effort
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// Kill merge agent if still alive.
|
|
2258
|
+
killMergeAgentV2(sessionName);
|
|
2259
|
+
|
|
2260
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2261
|
+
const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
|
|
2262
|
+
|
|
2263
|
+
execLog("merge", sessionName, `merge error: ${errMsg}`, { code: errCode });
|
|
2264
|
+
|
|
2265
|
+
laneResults.push({
|
|
2266
|
+
laneNumber: lane.laneNumber,
|
|
2267
|
+
laneId: lane.laneId,
|
|
2268
|
+
sourceBranch: lane.branch,
|
|
2269
|
+
targetBranch,
|
|
2270
|
+
result: null,
|
|
2271
|
+
error: errMsg,
|
|
2272
|
+
durationMs: Date.now() - laneStart,
|
|
2273
|
+
repoId: lane.repoId,
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
// ── TP-033: Transaction record for merge error ──
|
|
2277
|
+
const errorTxnRecord: TransactionRecord = {
|
|
2278
|
+
opId,
|
|
2279
|
+
batchId,
|
|
2280
|
+
waveIndex,
|
|
2281
|
+
laneNumber: lane.laneNumber,
|
|
2282
|
+
repoId: repoId ?? null,
|
|
2283
|
+
baseHEAD,
|
|
2284
|
+
laneHEAD,
|
|
2285
|
+
mergedHEAD: null,
|
|
2286
|
+
status: "merge_failed",
|
|
2287
|
+
rollbackAttempted: false,
|
|
2288
|
+
rollbackResult: null,
|
|
2289
|
+
recoveryCommands: [],
|
|
2290
|
+
startedAt: txnStartedAt,
|
|
2291
|
+
completedAt: new Date().toISOString(),
|
|
2292
|
+
};
|
|
2293
|
+
transactionRecords.push(errorTxnRecord);
|
|
2294
|
+
const errorTxnPersistError = persistTransactionRecord(errorTxnRecord, stateRoot ?? repoRoot);
|
|
2295
|
+
if (errorTxnPersistError) persistenceErrors.push(errorTxnPersistError);
|
|
2296
|
+
|
|
2297
|
+
failedLane = lane.laneNumber;
|
|
2298
|
+
failureReason = `Merge error in lane ${lane.laneNumber}: ${errMsg}`;
|
|
2299
|
+
break;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// ── Stage workspace task artifacts into merge worktree ──────────
|
|
2304
|
+
// TP-035: Tightened artifact staging — only allowlisted task-owned files
|
|
2305
|
+
// are staged. The allowlist is derived per-task-folder from completed lanes:
|
|
2306
|
+
// `.DONE`, `STATUS.md`, `REVIEW_VERDICT.json`, and `.reviews/**` files.
|
|
2307
|
+
// Files outside known task folders, worktree internals, and repo-escape
|
|
2308
|
+
// paths are rejected. Uses resolve+relative path containment consistent
|
|
2309
|
+
// with ensureTaskFilesCommitted() in execution.ts.
|
|
2310
|
+
if (mergeWorkDir) {
|
|
2311
|
+
// Build the set of allowed artifact paths (repo-root-relative) from
|
|
2312
|
+
// the completed lanes' task folders.
|
|
2313
|
+
//
|
|
2314
|
+
// Allowlist policy:
|
|
2315
|
+
// - task marker files: .DONE, STATUS.md, REVIEW_VERDICT.json
|
|
2316
|
+
// - review outputs under task-local .reviews/**
|
|
2317
|
+
const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
|
|
2318
|
+
const ALLOWED_ARTIFACT_DIRS = [".reviews"];
|
|
2319
|
+
const resolvedRepoRoot = resolve(repoRoot);
|
|
2320
|
+
const allowedRelPaths = new Set<string>();
|
|
2321
|
+
const relPathToWorktree = new Map<string, string>();
|
|
2322
|
+
|
|
2323
|
+
const listFilesRecursively = (rootDir: string): string[] => {
|
|
2324
|
+
if (!existsSync(rootDir)) return [];
|
|
2325
|
+
const files: string[] = [];
|
|
2326
|
+
const walk = (dir: string): void => {
|
|
2327
|
+
let entries: Dirent[];
|
|
2328
|
+
try {
|
|
2329
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2330
|
+
} catch {
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
for (const entry of entries) {
|
|
2334
|
+
const absPath = join(dir, entry.name);
|
|
2335
|
+
if (entry.isDirectory()) {
|
|
2336
|
+
walk(absPath);
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
if (!entry.isFile()) continue;
|
|
2340
|
+
const relPath = relative(rootDir, absPath).replace(/\\/g, "/");
|
|
2341
|
+
if (!relPath || relPath.startsWith("..") || relPath.startsWith("/")) continue;
|
|
2342
|
+
files.push(relPath);
|
|
2343
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
walk(rootDir);
|
|
2346
|
+
return files;
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
// TP-171: Skipped-artifact lanes get a restricted allowlist — no .DONE
|
|
2350
|
+
// because their code was not merged; staging .DONE would create false
|
|
2351
|
+
// completion markers on the orch branch.
|
|
2352
|
+
const SKIPPED_ARTIFACT_NAMES = ["STATUS.md", "REVIEW_VERDICT.json"];
|
|
2353
|
+
const skippedArtifactLaneNumbers = new Set(skippedArtifactLanes.map((l) => l.laneNumber));
|
|
2354
|
+
|
|
2355
|
+
// Include both merged lanes and skipped-artifact lanes in staging.
|
|
2356
|
+
const artifactStagingLanes = [...orderedLanes, ...skippedArtifactLanes];
|
|
2357
|
+
for (const lane of artifactStagingLanes) {
|
|
2358
|
+
// Select allowlist based on lane type
|
|
2359
|
+
const isSkippedOnly = skippedArtifactLaneNumbers.has(lane.laneNumber);
|
|
2360
|
+
const nameAllowlist = isSkippedOnly ? SKIPPED_ARTIFACT_NAMES : ALLOWED_ARTIFACT_NAMES;
|
|
2361
|
+
|
|
2362
|
+
for (const allocTask of lane.tasks) {
|
|
2363
|
+
if (!allocTask.task?.taskFolder?.trim()) {
|
|
2364
|
+
execLog(
|
|
2365
|
+
"merge",
|
|
2366
|
+
`W${waveIndex}`,
|
|
2367
|
+
`skipping task with missing taskFolder (possibly dynamically expanded)`,
|
|
2368
|
+
{
|
|
2369
|
+
taskId: allocTask.taskId,
|
|
2370
|
+
},
|
|
2371
|
+
);
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
const absFolder = resolve(allocTask.task.taskFolder);
|
|
2375
|
+
const relFolder = relative(resolvedRepoRoot, absFolder).replace(/\\/g, "/");
|
|
2376
|
+
|
|
2377
|
+
// Reject paths that escape the repo root
|
|
2378
|
+
if (relFolder.startsWith("..") || relFolder.startsWith("/")) {
|
|
2379
|
+
execLog("merge", `W${waveIndex}`, `skipping task folder outside repo root`, {
|
|
2380
|
+
taskId: allocTask.taskId,
|
|
2381
|
+
folder: relFolder,
|
|
2382
|
+
});
|
|
2383
|
+
continue;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
for (const name of nameAllowlist) {
|
|
2387
|
+
const rp = `${relFolder}/${name}`;
|
|
2388
|
+
allowedRelPaths.add(rp);
|
|
2389
|
+
relPathToWorktree.set(rp, join(lane.worktreePath, rp));
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
for (const dirName of ALLOWED_ARTIFACT_DIRS) {
|
|
2393
|
+
const laneDir = join(lane.worktreePath, relFolder, dirName);
|
|
2394
|
+
for (const relFile of listFilesRecursively(laneDir)) {
|
|
2395
|
+
const rp = `${relFolder}/${dirName}/${relFile}`;
|
|
2396
|
+
allowedRelPaths.add(rp);
|
|
2397
|
+
relPathToWorktree.set(rp, join(lane.worktreePath, rp));
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
const repoDir = join(repoRoot, relFolder, dirName);
|
|
2401
|
+
for (const relFile of listFilesRecursively(repoDir)) {
|
|
2402
|
+
const rp = `${relFolder}/${dirName}/${relFile}`;
|
|
2403
|
+
allowedRelPaths.add(rp);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (allowedRelPaths.size > 0) {
|
|
2410
|
+
let staged = 0;
|
|
2411
|
+
let skipped = 0;
|
|
2412
|
+
let preserved = 0;
|
|
2413
|
+
|
|
2414
|
+
for (const relPath of allowedRelPaths) {
|
|
2415
|
+
const destPath = join(mergeWorkDir, relPath);
|
|
2416
|
+
|
|
2417
|
+
// TP-099: If the file already exists in mergeWorkDir (from lane merge),
|
|
2418
|
+
// do NOT overwrite it — the lane merge brought the correct worker-updated
|
|
2419
|
+
// version (e.g., STATUS.md with checked items, execution log, discoveries).
|
|
2420
|
+
// Overwriting from repoRoot would revert to the pre-execution template.
|
|
2421
|
+
if (existsSync(destPath)) {
|
|
2422
|
+
preserved++;
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// File missing from mergeWorkDir — backfill from best available source.
|
|
2427
|
+
// Primary: lane worktree (has worker-generated .DONE/STATUS/.reviews content).
|
|
2428
|
+
// Fallback: repoRoot (original task folder, with path containment check).
|
|
2429
|
+
const worktreeSrc = relPathToWorktree.get(relPath);
|
|
2430
|
+
let srcPath: string | null = null;
|
|
2431
|
+
|
|
2432
|
+
// Try lane worktree first (trusted engine-allocated path)
|
|
2433
|
+
if (worktreeSrc && existsSync(worktreeSrc)) {
|
|
2434
|
+
srcPath = worktreeSrc;
|
|
2435
|
+
} else {
|
|
2436
|
+
// Fallback to repoRoot with path containment check (TP-035 hardening)
|
|
2437
|
+
const repoRootSrc = join(repoRoot, relPath);
|
|
2438
|
+
if (existsSync(repoRootSrc)) {
|
|
2439
|
+
const resolvedSrc = resolve(repoRootSrc);
|
|
2440
|
+
const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/");
|
|
2441
|
+
if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) {
|
|
2442
|
+
execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, {
|
|
2443
|
+
path: relPath,
|
|
2444
|
+
src: repoRootSrc,
|
|
2445
|
+
});
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
srcPath = repoRootSrc;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
if (!srcPath) continue; // File not present anywhere — skip silently
|
|
2452
|
+
|
|
2453
|
+
try {
|
|
2454
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
2455
|
+
copyFileSync(srcPath, destPath);
|
|
2456
|
+
// Use pathspec-safe staging with -- separator
|
|
2457
|
+
spawnSync("git", ["add", "--", relPath], { cwd: mergeWorkDir });
|
|
2458
|
+
staged++;
|
|
2459
|
+
} catch {
|
|
2460
|
+
skipped++;
|
|
2461
|
+
execLog("merge", `W${waveIndex}`, `failed to stage artifact`, { path: relPath });
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
if (staged > 0) {
|
|
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
|
+
);
|
|
2475
|
+
execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
|
|
2476
|
+
skipped,
|
|
2477
|
+
preserved,
|
|
2478
|
+
allowedCandidates: allowedRelPaths.size,
|
|
2479
|
+
});
|
|
2480
|
+
} else {
|
|
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
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// Keep both .DONE and STATUS.md in develop's working tree:
|
|
2489
|
+
// - STATUS.md: dashboard reads current progress from canonical path
|
|
2490
|
+
// - .DONE: harmless untracked files, cleaned up by /orch-integrate stash
|
|
2491
|
+
// Previous approach of deleting .DONE caused them to be missing
|
|
2492
|
+
// after ff integration (git couldn't reliably restore them).
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// ── Update target branch ref and clean up merge worktree ────────
|
|
2497
|
+
// TP-032 R006-2: blockAdvancement overrides all success determination.
|
|
2498
|
+
// When verification rollback fails, the temp branch contains a bad merge commit
|
|
2499
|
+
// that would be included in branch advancement — so we block entirely.
|
|
2500
|
+
// Also exclude verification_new_failure lanes (with successful rollback) from
|
|
2501
|
+
// success accounting: they have laneResult.error set, so !r.error filters them.
|
|
2502
|
+
const anySuccess =
|
|
2503
|
+
!blockAdvancement &&
|
|
2504
|
+
laneResults.some(
|
|
2505
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2506
|
+
);
|
|
2507
|
+
|
|
2508
|
+
if (blockAdvancement) {
|
|
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
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
if (anySuccess) {
|
|
2518
|
+
// Get the temp branch HEAD commit — this is the merged result.
|
|
2519
|
+
const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
|
|
2520
|
+
|
|
2521
|
+
if (revParseResult.status !== 0) {
|
|
2522
|
+
const err = revParseResult.stderr?.toString().trim() || "unknown error";
|
|
2523
|
+
execLog("merge", `W${waveIndex}`, `failed to resolve temp branch HEAD: ${err}`, { tempBranch });
|
|
2524
|
+
failedLane = failedLane ?? -1;
|
|
2525
|
+
failureReason = `Failed to resolve merge temp branch HEAD (${tempBranch}): ${err}`;
|
|
2526
|
+
} else {
|
|
2527
|
+
const tempBranchHead = revParseResult.stdout.toString().trim();
|
|
2528
|
+
|
|
2529
|
+
// Gate advancement strategy:
|
|
2530
|
+
// - If targetBranch is NOT checked out in repoRoot, use update-ref
|
|
2531
|
+
// (safe, does not touch the working tree). This is the common case
|
|
2532
|
+
// for the orch branch in repo mode.
|
|
2533
|
+
// - If targetBranch IS checked out in repoRoot (workspace mode, where
|
|
2534
|
+
// resolveBaseBranch returns the repo's current branch), use
|
|
2535
|
+
// git merge --ff-only to advance HEAD+index+worktree together.
|
|
2536
|
+
const checkedOutBranch = getCurrentBranch(repoRoot);
|
|
2537
|
+
const targetIsCheckedOut = checkedOutBranch === targetBranch;
|
|
2538
|
+
|
|
2539
|
+
if (targetIsCheckedOut) {
|
|
2540
|
+
// Checked-out branch — must use ff-only to keep HEAD/index/worktree in sync.
|
|
2541
|
+
// Dirty working tree may block ff — stash if needed.
|
|
2542
|
+
const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
2543
|
+
|
|
2544
|
+
if (ffResult.status !== 0) {
|
|
2545
|
+
// Dirty working tree may block ff — try stash + ff + pop
|
|
2546
|
+
execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
|
|
2547
|
+
const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
|
|
2548
|
+
spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
|
|
2549
|
+
|
|
2550
|
+
const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
2551
|
+
|
|
2552
|
+
// Always pop stash, regardless of ff result
|
|
2553
|
+
spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
|
|
2554
|
+
|
|
2555
|
+
if (ffRetry.status !== 0) {
|
|
2556
|
+
const err = ffRetry.stderr?.toString().trim() || "unknown error";
|
|
2557
|
+
execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
|
|
2558
|
+
failedLane = failedLane ?? -1;
|
|
2559
|
+
failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
|
|
2560
|
+
} else {
|
|
2561
|
+
execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
|
|
2562
|
+
}
|
|
2563
|
+
} else {
|
|
2564
|
+
execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
|
|
2565
|
+
}
|
|
2566
|
+
} else {
|
|
2567
|
+
// Not checked out — safe to use update-ref without touching the worktree.
|
|
2568
|
+
// Use compare-and-swap (3-arg form) to guard against concurrent branch movement.
|
|
2569
|
+
const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], {
|
|
2570
|
+
cwd: repoRoot,
|
|
2571
|
+
});
|
|
2572
|
+
const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : "";
|
|
2573
|
+
|
|
2574
|
+
const updateRefArgs = oldRef
|
|
2575
|
+
? ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead, oldRef]
|
|
2576
|
+
: ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead];
|
|
2577
|
+
|
|
2578
|
+
const updateRefResult = spawnSync("git", updateRefArgs, { cwd: repoRoot });
|
|
2579
|
+
|
|
2580
|
+
if (updateRefResult.status !== 0) {
|
|
2581
|
+
const err = updateRefResult.stderr?.toString().trim() || "unknown error";
|
|
2582
|
+
execLog("merge", `W${waveIndex}`, `update-ref failed for ${targetBranch}: ${err}`, {
|
|
2583
|
+
targetBranch,
|
|
2584
|
+
tempBranchHead: tempBranchHead.slice(0, 8),
|
|
2585
|
+
});
|
|
2586
|
+
failedLane = failedLane ?? -1;
|
|
2587
|
+
failureReason = `update-ref of ${targetBranch} to ${tempBranchHead.slice(0, 8)} failed: ${err}`;
|
|
2588
|
+
} else {
|
|
2589
|
+
execLog("merge", `W${waveIndex}`, `updated ${targetBranch} ref to merge result`, {
|
|
2590
|
+
targetBranch,
|
|
2591
|
+
commit: tempBranchHead.slice(0, 8),
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// Clean up merge worktree and temp branch.
|
|
2599
|
+
// TP-033: When rollback failed (safe-stop), preserve merge worktree and temp
|
|
2600
|
+
// branch for manual recovery. The operator can use the recovery commands in
|
|
2601
|
+
// the transaction record to restore consistency.
|
|
2602
|
+
if (rollbackFailed) {
|
|
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
|
+
);
|
|
2612
|
+
} else {
|
|
2613
|
+
// TP-029: Apply forceRemoveMergeWorktree fallback so locked/corrupted
|
|
2614
|
+
// merge worktrees don't persist between attempts.
|
|
2615
|
+
forceRemoveMergeWorktree(mergeWorkDir, repoRoot, `W${waveIndex}`);
|
|
2616
|
+
try {
|
|
2617
|
+
// Small delay to ensure worktree lock is released
|
|
2618
|
+
await sleepAsync(500);
|
|
2619
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
2620
|
+
} catch {
|
|
2621
|
+
/* best effort */
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// Determine overall status
|
|
2626
|
+
let status: MergeWaveResult["status"];
|
|
2627
|
+
if (failedLane === null) {
|
|
2628
|
+
status = "succeeded";
|
|
2629
|
+
} else if (anySuccess) {
|
|
2630
|
+
status = "partial";
|
|
2631
|
+
} else {
|
|
2632
|
+
status = "failed";
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
const totalDurationMs = Date.now() - startTime;
|
|
2636
|
+
|
|
2637
|
+
execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
|
|
2638
|
+
mergedLanes: laneResults.filter(
|
|
2639
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2640
|
+
).length,
|
|
2641
|
+
failedLane: failedLane ?? 0,
|
|
2642
|
+
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
const result: MergeWaveResult = {
|
|
2646
|
+
waveIndex,
|
|
2647
|
+
status,
|
|
2648
|
+
laneResults,
|
|
2649
|
+
failedLane,
|
|
2650
|
+
failureReason,
|
|
2651
|
+
totalDurationMs,
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
// TP-033: Attach transaction metadata
|
|
2655
|
+
if (transactionRecords.length > 0) {
|
|
2656
|
+
result.transactionRecords = transactionRecords;
|
|
2657
|
+
}
|
|
2658
|
+
if (rollbackFailed) {
|
|
2659
|
+
result.rollbackFailed = true;
|
|
2660
|
+
}
|
|
2661
|
+
// TP-033 R004-2: Surface persistence failures so operator knows
|
|
2662
|
+
// recovery guidance may reference missing transaction record files
|
|
2663
|
+
if (persistenceErrors.length > 0) {
|
|
2664
|
+
result.persistenceErrors = persistenceErrors;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
return result;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// ── Repo-Scoped Merge ────────────────────────────────────────────────
|
|
2671
|
+
|
|
2672
|
+
/**
|
|
2673
|
+
* Group mergeable lanes by their `repoId`.
|
|
2674
|
+
*
|
|
2675
|
+
* Returns groups sorted deterministically by repoId (undefined/repo-mode
|
|
2676
|
+
* group sorts first as empty string). Lanes within each group preserve
|
|
2677
|
+
* the input order.
|
|
2678
|
+
*
|
|
2679
|
+
* @param lanes - Lanes to group (already filtered for mergeability)
|
|
2680
|
+
* @returns Array of { repoId, lanes } groups in deterministic order
|
|
2681
|
+
*/
|
|
2682
|
+
export function groupLanesByRepo(
|
|
2683
|
+
lanes: AllocatedLane[],
|
|
2684
|
+
): Array<{ repoId: string | undefined; lanes: AllocatedLane[] }> {
|
|
2685
|
+
const groupMap = new Map<string, AllocatedLane[]>();
|
|
2686
|
+
|
|
2687
|
+
for (const lane of lanes) {
|
|
2688
|
+
const key = lane.repoId ?? "";
|
|
2689
|
+
const existing = groupMap.get(key) || [];
|
|
2690
|
+
existing.push(lane);
|
|
2691
|
+
groupMap.set(key, existing);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const sortedKeys = [...groupMap.keys()].sort();
|
|
2695
|
+
return sortedKeys.map((key) => ({
|
|
2696
|
+
repoId: key || undefined,
|
|
2697
|
+
lanes: groupMap.get(key)!,
|
|
2698
|
+
}));
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* Merge a wave's lanes partitioned by repository.
|
|
2703
|
+
*
|
|
2704
|
+
* In repo mode (all lanes have repoId=undefined), this produces a single
|
|
2705
|
+
* repo group and delegates to `mergeWave()` exactly once — a no-op
|
|
2706
|
+
* regression case that preserves existing behavior.
|
|
2707
|
+
*
|
|
2708
|
+
* In workspace mode, lanes are grouped by `repoId`. Each repo group gets:
|
|
2709
|
+
* - Its own repo root (via `resolveRepoRoot()`)
|
|
2710
|
+
* - Its own base branch (via `resolveBaseBranch()`)
|
|
2711
|
+
* - An independent `mergeWave()` call with those repo-scoped parameters
|
|
2712
|
+
*
|
|
2713
|
+
* Repo groups are processed in deterministic order (sorted by repoId).
|
|
2714
|
+
* Per-repo results are aggregated into a single `MergeWaveResult` for
|
|
2715
|
+
* the existing wave-level failure policy handling in `engine.ts`.
|
|
2716
|
+
*
|
|
2717
|
+
* Failure semantics:
|
|
2718
|
+
* - A failure in one repo does NOT stop merging in other repos.
|
|
2719
|
+
* - The aggregate status is "succeeded" only if all repos succeeded.
|
|
2720
|
+
* - If any repo failed and any succeeded, status is "partial".
|
|
2721
|
+
* - `repoResults` field carries per-repo attribution for downstream
|
|
2722
|
+
* reporting (Step 1 will use this for explicit partial-success summaries).
|
|
2723
|
+
*
|
|
2724
|
+
* @param completedLanes - Lanes that completed execution (from wave result)
|
|
2725
|
+
* @param waveResult - The wave execution result (for lane status filtering)
|
|
2726
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
2727
|
+
* @param config - Orchestrator configuration
|
|
2728
|
+
* @param repoRoot - Default repository root (used in repo mode)
|
|
2729
|
+
* @param batchId - Batch ID for session naming
|
|
2730
|
+
* @param baseBranch - Default branch to merge into (captured at batch start)
|
|
2731
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
2732
|
+
* @returns MergeWaveResult with per-lane and per-repo outcomes
|
|
2733
|
+
*/
|
|
2734
|
+
export async function mergeWaveByRepo(
|
|
2735
|
+
completedLanes: AllocatedLane[],
|
|
2736
|
+
waveResult: WaveExecutionResult,
|
|
2737
|
+
waveIndex: number,
|
|
2738
|
+
config: OrchestratorConfig,
|
|
2739
|
+
repoRoot: string,
|
|
2740
|
+
batchId: string,
|
|
2741
|
+
baseBranch: string,
|
|
2742
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
2743
|
+
stateRoot?: string,
|
|
2744
|
+
agentRoot?: string,
|
|
2745
|
+
testingCommands?: Record<string, string>,
|
|
2746
|
+
healthMonitor?: MergeHealthMonitor | null,
|
|
2747
|
+
forceMixedOutcome?: boolean,
|
|
2748
|
+
runtimeBackend?: RuntimeBackend,
|
|
2749
|
+
): Promise<MergeWaveResult> {
|
|
2750
|
+
const startTime = Date.now();
|
|
2751
|
+
|
|
2752
|
+
// Build lane outcome lookup for merge eligibility (same logic as mergeWave).
|
|
2753
|
+
const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
|
|
2754
|
+
for (const laneOutcome of waveResult.laneResults) {
|
|
2755
|
+
laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// Filter to mergeable lanes (same criteria as mergeWave).
|
|
2759
|
+
// TP-078: When forceMixedOutcome is true, lanes with mixed outcomes are also included.
|
|
2760
|
+
const mergeableLanes = completedLanes.filter((lane) => {
|
|
2761
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2762
|
+
if (!outcome) return false;
|
|
2763
|
+
const hasSucceeded = outcome.tasks.some((t) => t.status === "succeeded");
|
|
2764
|
+
const hasHardFailure = outcome.tasks.some((t) => t.status === "failed" || t.status === "stalled");
|
|
2765
|
+
if (forceMixedOutcome) return hasSucceeded;
|
|
2766
|
+
return hasSucceeded && !hasHardFailure;
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
if (mergeableLanes.length === 0) {
|
|
2770
|
+
// TP-171: Even when no lanes are mergeable, skipped-task lanes may have
|
|
2771
|
+
// partial progress that should be staged on the target branch.
|
|
2772
|
+
const skippedOnlyLanes = completedLanes.filter((lane) => {
|
|
2773
|
+
if (!lane.worktreePath) return false;
|
|
2774
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2775
|
+
if (!outcome) return false;
|
|
2776
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
2777
|
+
});
|
|
2778
|
+
if (skippedOnlyLanes.length > 0) {
|
|
2779
|
+
// In workspace mode, group skipped lanes by repo and stage per-repo.
|
|
2780
|
+
const skippedByRepo = groupLanesByRepo(skippedOnlyLanes);
|
|
2781
|
+
for (const group of skippedByRepo) {
|
|
2782
|
+
const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
|
|
2783
|
+
stageSkippedArtifactsToTargetBranch(group.lanes, waveIndex, groupRepoRoot, baseBranch);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
|
|
2788
|
+
return {
|
|
2789
|
+
waveIndex,
|
|
2790
|
+
status: "succeeded",
|
|
2791
|
+
laneResults: [],
|
|
2792
|
+
failedLane: null,
|
|
2793
|
+
failureReason: null,
|
|
2794
|
+
totalDurationMs: Date.now() - startTime,
|
|
2795
|
+
repoResults: [],
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Group lanes by repo
|
|
2800
|
+
const repoGroups = groupLanesByRepo(mergeableLanes);
|
|
2801
|
+
|
|
2802
|
+
execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, {
|
|
2803
|
+
repos: repoGroups.map((g) => g.repoId ?? "(default)").join(", "),
|
|
2804
|
+
totalLanes: mergeableLanes.length,
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
// In repo mode (single group with repoId=undefined), delegate directly
|
|
2808
|
+
// to mergeWave() for zero-overhead backward compatibility.
|
|
2809
|
+
if (repoGroups.length === 1 && repoGroups[0].repoId === undefined) {
|
|
2810
|
+
const result = await mergeWave(
|
|
2811
|
+
completedLanes,
|
|
2812
|
+
waveResult,
|
|
2813
|
+
waveIndex,
|
|
2814
|
+
config,
|
|
2815
|
+
repoRoot,
|
|
2816
|
+
batchId,
|
|
2817
|
+
baseBranch,
|
|
2818
|
+
stateRoot,
|
|
2819
|
+
agentRoot,
|
|
2820
|
+
testingCommands,
|
|
2821
|
+
undefined, // repoId
|
|
2822
|
+
healthMonitor,
|
|
2823
|
+
forceMixedOutcome,
|
|
2824
|
+
runtimeBackend,
|
|
2825
|
+
);
|
|
2826
|
+
// Attach empty repoResults for consistent shape
|
|
2827
|
+
return { ...result, repoResults: [] };
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// ── Workspace mode: per-repo merge loops ─────────────────────
|
|
2831
|
+
const allLaneResults: MergeLaneResult[] = [];
|
|
2832
|
+
const repoOutcomes: RepoMergeOutcome[] = [];
|
|
2833
|
+
const allTransactionRecords: TransactionRecord[] = [];
|
|
2834
|
+
// TP-033 R004-2: Accumulate persistence errors across all repo groups
|
|
2835
|
+
const allPersistenceErrors: string[] = [];
|
|
2836
|
+
let firstFailedLane: number | null = null;
|
|
2837
|
+
let firstFailureReason: string | null = null;
|
|
2838
|
+
// Track repo-level failures independently of lane-level failures.
|
|
2839
|
+
// mergeWave() can return status="failed" with failedLane=null for
|
|
2840
|
+
// pre-lane setup errors (temp branch creation, worktree creation).
|
|
2841
|
+
// We must detect these to avoid misclassifying the aggregate as "succeeded".
|
|
2842
|
+
let anyRepoFailed = false;
|
|
2843
|
+
// TP-033: Track rollback failures across all repo groups
|
|
2844
|
+
let anyRollbackFailed = false;
|
|
2845
|
+
|
|
2846
|
+
for (const group of repoGroups) {
|
|
2847
|
+
const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
|
|
2848
|
+
// In workspace mode with orch branch, always merge into the orch branch
|
|
2849
|
+
// (passed as baseBranch from engine.ts). Do NOT use resolveBaseBranch()
|
|
2850
|
+
// which returns the repo's current branch (e.g., develop), bypassing
|
|
2851
|
+
// the orch branch model entirely.
|
|
2852
|
+
const groupBaseBranch = baseBranch;
|
|
2853
|
+
|
|
2854
|
+
execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, {
|
|
2855
|
+
repoRoot: groupRepoRoot,
|
|
2856
|
+
baseBranch: groupBaseBranch,
|
|
2857
|
+
laneCount: group.lanes.length,
|
|
2858
|
+
lanes: group.lanes.map((l) => l.laneNumber).join(","),
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
// TP-171: Build allGroupLanes from all completed lanes for this repo
|
|
2862
|
+
// (not just mergeable) so mergeWave() can compute skippedArtifactLanes.
|
|
2863
|
+
const groupRepoId = group.repoId;
|
|
2864
|
+
const allGroupLanes = completedLanes.filter((l) => (l.repoId ?? undefined) === groupRepoId);
|
|
2865
|
+
const allGroupLaneNumbers = new Set(allGroupLanes.map((l) => l.laneNumber));
|
|
2866
|
+
|
|
2867
|
+
// Build a filtered WaveExecutionResult containing all lanes for this repo
|
|
2868
|
+
// (including skipped-only lanes that aren't in the mergeable group).
|
|
2869
|
+
const filteredWaveResult: WaveExecutionResult = {
|
|
2870
|
+
...waveResult,
|
|
2871
|
+
laneResults: waveResult.laneResults.filter((lr) => allGroupLaneNumbers.has(lr.laneNumber)),
|
|
2872
|
+
allocatedLanes: waveResult.allocatedLanes.filter((l) => allGroupLaneNumbers.has(l.laneNumber)),
|
|
2873
|
+
};
|
|
2874
|
+
|
|
2875
|
+
const groupResult = await mergeWave(
|
|
2876
|
+
allGroupLanes,
|
|
2877
|
+
filteredWaveResult,
|
|
2878
|
+
waveIndex,
|
|
2879
|
+
config,
|
|
2880
|
+
groupRepoRoot,
|
|
2881
|
+
batchId,
|
|
2882
|
+
groupBaseBranch,
|
|
2883
|
+
stateRoot,
|
|
2884
|
+
agentRoot,
|
|
2885
|
+
testingCommands,
|
|
2886
|
+
group.repoId,
|
|
2887
|
+
healthMonitor,
|
|
2888
|
+
forceMixedOutcome,
|
|
2889
|
+
runtimeBackend,
|
|
2890
|
+
);
|
|
2891
|
+
|
|
2892
|
+
// Accumulate lane results
|
|
2893
|
+
allLaneResults.push(...groupResult.laneResults);
|
|
2894
|
+
|
|
2895
|
+
// TP-033: Accumulate transaction records and rollback status
|
|
2896
|
+
if (groupResult.transactionRecords) {
|
|
2897
|
+
allTransactionRecords.push(...groupResult.transactionRecords);
|
|
2898
|
+
}
|
|
2899
|
+
// TP-033 R004-2: Accumulate persistence errors
|
|
2900
|
+
if (groupResult.persistenceErrors) {
|
|
2901
|
+
allPersistenceErrors.push(...groupResult.persistenceErrors);
|
|
2902
|
+
}
|
|
2903
|
+
if (groupResult.rollbackFailed) {
|
|
2904
|
+
anyRollbackFailed = true;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// Build per-repo outcome
|
|
2908
|
+
const repoOutcome: RepoMergeOutcome = {
|
|
2909
|
+
repoId: group.repoId,
|
|
2910
|
+
status: groupResult.status,
|
|
2911
|
+
laneResults: groupResult.laneResults,
|
|
2912
|
+
failedLane: groupResult.failedLane,
|
|
2913
|
+
failureReason: groupResult.failureReason,
|
|
2914
|
+
};
|
|
2915
|
+
repoOutcomes.push(repoOutcome);
|
|
2916
|
+
|
|
2917
|
+
// Track failures across repos (but continue to merge other repos).
|
|
2918
|
+
// Check groupResult.status (not just failedLane) to catch setup failures
|
|
2919
|
+
// where mergeWave() returns status="failed" with failedLane=null
|
|
2920
|
+
// (e.g., temp branch creation or worktree creation failure).
|
|
2921
|
+
if (groupResult.status !== "succeeded") {
|
|
2922
|
+
anyRepoFailed = true;
|
|
2923
|
+
|
|
2924
|
+
if (firstFailureReason === null) {
|
|
2925
|
+
firstFailedLane = groupResult.failedLane;
|
|
2926
|
+
firstFailureReason = groupResult.failureReason
|
|
2927
|
+
? `[repo:${group.repoId ?? "default"}] ${groupResult.failureReason}`
|
|
2928
|
+
: `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
// TP-033 R004-1: Safe-stop — halt all remaining repo merges immediately
|
|
2933
|
+
// when a rollback failure is detected. Continuing would advance refs in
|
|
2934
|
+
// other repos, making manual recovery harder.
|
|
2935
|
+
if (anyRollbackFailed) {
|
|
2936
|
+
const processedIndex = repoGroups.indexOf(group);
|
|
2937
|
+
const remainingGroups = repoGroups.slice(processedIndex + 1);
|
|
2938
|
+
if (remainingGroups.length > 0) {
|
|
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
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
break;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// TP-171: Stage artifacts for repos that have only skipped lanes but were
|
|
2953
|
+
// not included in the mergeable repoGroups.
|
|
2954
|
+
const processedRepoIds = new Set(repoGroups.map((g) => g.repoId));
|
|
2955
|
+
const skippedOnlyRepoLanes = completedLanes.filter((lane) => {
|
|
2956
|
+
if (!lane.worktreePath) return false;
|
|
2957
|
+
const laneRepoId = lane.repoId ?? undefined;
|
|
2958
|
+
if (processedRepoIds.has(laneRepoId)) return false; // already handled by mergeWave
|
|
2959
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
2960
|
+
if (!outcome) return false;
|
|
2961
|
+
return outcome.tasks.some((t) => t.status === "skipped");
|
|
2962
|
+
});
|
|
2963
|
+
// TP-171 R004: Gate artifact staging behind safe-stop — do not advance
|
|
2964
|
+
// any branch refs when a rollback failure has been detected.
|
|
2965
|
+
if (skippedOnlyRepoLanes.length > 0 && !anyRollbackFailed) {
|
|
2966
|
+
const skippedRepoGroups = groupLanesByRepo(skippedOnlyRepoLanes);
|
|
2967
|
+
for (const group of skippedRepoGroups) {
|
|
2968
|
+
const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
|
|
2969
|
+
stageSkippedArtifactsToTargetBranch(group.lanes, waveIndex, groupRepoRoot, baseBranch);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
// ── Aggregate status ─────────────────────────────────────────
|
|
2974
|
+
// Use both lane-level and repo-level evidence for correct classification:
|
|
2975
|
+
// - anyLaneSucceeded: at least one lane merged successfully across all repos
|
|
2976
|
+
// - anyRepoFailed: at least one repo had a non-succeeded status (includes
|
|
2977
|
+
// both lane-level failures AND repo setup failures with failedLane=null)
|
|
2978
|
+
// TP-032 R006-3: Exclude verification_new_failure lanes from success determination
|
|
2979
|
+
const anyLaneSucceeded = allLaneResults.some(
|
|
2980
|
+
(r) => !r.error && (r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED"),
|
|
2981
|
+
);
|
|
2982
|
+
|
|
2983
|
+
let status: MergeWaveResult["status"];
|
|
2984
|
+
if (!anyRepoFailed) {
|
|
2985
|
+
status = "succeeded";
|
|
2986
|
+
} else if (anyLaneSucceeded) {
|
|
2987
|
+
status = "partial";
|
|
2988
|
+
} else {
|
|
2989
|
+
status = "failed";
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
const totalDurationMs = Date.now() - startTime;
|
|
2993
|
+
|
|
2994
|
+
execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
|
|
2995
|
+
repoCount: repoOutcomes.length,
|
|
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,
|
|
3000
|
+
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
3001
|
+
});
|
|
3002
|
+
|
|
3003
|
+
const aggregateResult: MergeWaveResult = {
|
|
3004
|
+
waveIndex,
|
|
3005
|
+
status,
|
|
3006
|
+
laneResults: allLaneResults,
|
|
3007
|
+
failedLane: firstFailedLane,
|
|
3008
|
+
failureReason: firstFailureReason,
|
|
3009
|
+
totalDurationMs,
|
|
3010
|
+
repoResults: repoOutcomes,
|
|
3011
|
+
};
|
|
3012
|
+
|
|
3013
|
+
// TP-033: Attach transaction metadata from all repo groups
|
|
3014
|
+
if (allTransactionRecords.length > 0) {
|
|
3015
|
+
aggregateResult.transactionRecords = allTransactionRecords;
|
|
3016
|
+
}
|
|
3017
|
+
if (anyRollbackFailed) {
|
|
3018
|
+
aggregateResult.rollbackFailed = true;
|
|
3019
|
+
}
|
|
3020
|
+
// TP-033 R004-2: Surface persistence errors from all repo groups
|
|
3021
|
+
if (allPersistenceErrors.length > 0) {
|
|
3022
|
+
aggregateResult.persistenceErrors = allPersistenceErrors;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
return aggregateResult;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
// ── Auto-Integration ─────────────────────────────────────────────────
|
|
3029
|
+
|
|
3030
|
+
/**
|
|
3031
|
+
* Attempt to fast-forward baseBranch to orchBranch in the main repo.
|
|
3032
|
+
*
|
|
3033
|
+
* Shared by engine.ts (fresh batch) and resume.ts (resumed batch).
|
|
3034
|
+
* The `logCategory` parameter distinguishes the calling context in execLog.
|
|
3035
|
+
*
|
|
3036
|
+
* Failure matrix — all failures are warnings, never batch-fatal:
|
|
3037
|
+
* - **Diverged**: baseBranch has commits not in orchBranch (not fast-forwardable)
|
|
3038
|
+
* - **Detached HEAD / missing base**: baseBranch not resolvable
|
|
3039
|
+
* - **Dirty worktree**: baseBranch is checked out with uncommitted changes
|
|
3040
|
+
* - **Branch not checked out**: baseBranch is not the current branch;
|
|
3041
|
+
* use update-ref (no worktree impact) with compare-and-swap
|
|
3042
|
+
*
|
|
3043
|
+
* @param orchBranch - The orch branch to integrate from
|
|
3044
|
+
* @param baseBranch - The user's branch to advance
|
|
3045
|
+
* @param repoRoot - Absolute path to the primary repo root
|
|
3046
|
+
* @param batchId - Batch identifier for logging
|
|
3047
|
+
* @param logCategory - execLog category ("batch" for engine, "resume" for resume)
|
|
3048
|
+
* @param onNotify - Notification callback
|
|
3049
|
+
* @returns true if integration succeeded, false otherwise
|
|
3050
|
+
*/
|
|
3051
|
+
export function attemptAutoIntegration(
|
|
3052
|
+
orchBranch: string,
|
|
3053
|
+
baseBranch: string,
|
|
3054
|
+
repoRoot: string,
|
|
3055
|
+
batchId: string,
|
|
3056
|
+
logCategory: string,
|
|
3057
|
+
onNotify: (message: string, level: "info" | "warning" | "error") => void,
|
|
3058
|
+
): boolean {
|
|
3059
|
+
// 1. Verify orchBranch exists
|
|
3060
|
+
const orchExists = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoRoot);
|
|
3061
|
+
if (!orchExists.ok) {
|
|
3062
|
+
const reason = `orch branch '${orchBranch}' not found`;
|
|
3063
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
3064
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// 2. Verify baseBranch exists
|
|
3069
|
+
const baseExists = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot);
|
|
3070
|
+
if (!baseExists.ok) {
|
|
3071
|
+
const reason = `base branch '${baseBranch}' not found`;
|
|
3072
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
3073
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3074
|
+
return false;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// 3. Check fast-forwardability: baseBranch must be an ancestor of orchBranch
|
|
3078
|
+
const isAncestor = runGit(["merge-base", "--is-ancestor", baseBranch, orchBranch], repoRoot);
|
|
3079
|
+
if (!isAncestor.ok) {
|
|
3080
|
+
const reason = `branches have diverged (${baseBranch} is not an ancestor of ${orchBranch})`;
|
|
3081
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
3082
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3083
|
+
return false;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// 4. Gate on whether baseBranch is checked out (same pattern as merge advancement)
|
|
3087
|
+
const checkedOutBranch = getCurrentBranch(repoRoot);
|
|
3088
|
+
const baseIsCheckedOut = checkedOutBranch === baseBranch;
|
|
3089
|
+
|
|
3090
|
+
const orchHead = runGit(["rev-parse", orchBranch], repoRoot).stdout.trim();
|
|
3091
|
+
|
|
3092
|
+
if (baseIsCheckedOut) {
|
|
3093
|
+
// baseBranch is checked out — use merge --ff-only (updates worktree)
|
|
3094
|
+
// Check for dirty worktree first
|
|
3095
|
+
const statusCheck = runGit(["status", "--porcelain"], repoRoot);
|
|
3096
|
+
if (statusCheck.ok && statusCheck.stdout.trim()) {
|
|
3097
|
+
const reason = `working tree is dirty (${baseBranch} is checked out with uncommitted changes)`;
|
|
3098
|
+
execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
|
|
3099
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3100
|
+
return false;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
const ffResult = runGit(["merge", "--ff-only", orchBranch], repoRoot);
|
|
3104
|
+
if (!ffResult.ok) {
|
|
3105
|
+
const reason = `fast-forward failed: ${ffResult.stderr || ffResult.stdout || "unknown"}`;
|
|
3106
|
+
execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
|
|
3107
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3108
|
+
return false;
|
|
3109
|
+
}
|
|
3110
|
+
} else {
|
|
3111
|
+
// baseBranch is NOT checked out — use update-ref with compare-and-swap
|
|
3112
|
+
const baseOldRef = runGit(["rev-parse", baseBranch], repoRoot).stdout.trim();
|
|
3113
|
+
const updateResult = runGit(
|
|
3114
|
+
["update-ref", `refs/heads/${baseBranch}`, orchHead, baseOldRef],
|
|
3115
|
+
repoRoot,
|
|
3116
|
+
);
|
|
3117
|
+
if (!updateResult.ok) {
|
|
3118
|
+
const reason = `update-ref failed: ${updateResult.stderr || updateResult.stdout || "unknown"}`;
|
|
3119
|
+
execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
|
|
3120
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
|
|
3121
|
+
return false;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, {
|
|
3126
|
+
orchHead,
|
|
3127
|
+
});
|
|
3128
|
+
onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info");
|
|
3129
|
+
return true;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// ── Merge Health Monitor (TP-056) ────────────────────────────────────
|
|
3133
|
+
|
|
3134
|
+
/**
|
|
3135
|
+
* Classify merge-session health from Runtime V2 liveness and result-file state.
|
|
3136
|
+
*
|
|
3137
|
+
* Without legacy pane capture, warning/stuck are time-based heuristics from
|
|
3138
|
+
* the session registration timestamp (`lastActivityAt`).
|
|
3139
|
+
*
|
|
3140
|
+
* @param sessionAlive - Whether the Runtime V2 merge agent is alive
|
|
3141
|
+
* @param hasResultFile - Whether the merge result file exists
|
|
3142
|
+
* @param healthState - Tracked health state for this session
|
|
3143
|
+
* @param now - Current epoch ms
|
|
3144
|
+
* @returns Updated health status
|
|
3145
|
+
*
|
|
3146
|
+
* @since TP-056
|
|
3147
|
+
*/
|
|
3148
|
+
export function classifyMergeHealth(
|
|
3149
|
+
sessionAlive: boolean,
|
|
3150
|
+
hasResultFile: boolean,
|
|
3151
|
+
healthState: MergeSessionHealthState,
|
|
3152
|
+
now: number,
|
|
3153
|
+
): MergeHealthStatus {
|
|
3154
|
+
if (!sessionAlive && !hasResultFile) {
|
|
3155
|
+
return "dead";
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
if (!sessionAlive && hasResultFile) {
|
|
3159
|
+
return "healthy";
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
const elapsedMs = now - healthState.lastActivityAt;
|
|
3163
|
+
if (elapsedMs >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
|
|
3164
|
+
return "stuck";
|
|
3165
|
+
}
|
|
3166
|
+
if (elapsedMs >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
|
|
3167
|
+
return "warning";
|
|
3168
|
+
}
|
|
3169
|
+
return "healthy";
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* Active merge session health monitor.
|
|
3174
|
+
*
|
|
3175
|
+
* Runs on its own polling interval during the merge phase, checking each
|
|
3176
|
+
* active merge session for liveness and activity. Emits structured events
|
|
3177
|
+
* for the supervisor to consume.
|
|
3178
|
+
*
|
|
3179
|
+
* Design principles (from PROMPT.md):
|
|
3180
|
+
* - Does NOT kill sessions autonomously — emits events for operator decision
|
|
3181
|
+
* - Runs independently of the merge result poll
|
|
3182
|
+
* - Stores session snapshots in memory (ephemeral, not persisted)
|
|
3183
|
+
* - Emits structured events to the unified events.jsonl
|
|
3184
|
+
*
|
|
3185
|
+
* @since TP-056
|
|
3186
|
+
*/
|
|
3187
|
+
export class MergeHealthMonitor {
|
|
3188
|
+
/** Per-session health state, keyed by session name */
|
|
3189
|
+
private sessions: Map<string, MergeSessionHealthState> = new Map();
|
|
3190
|
+
|
|
3191
|
+
/** Timer handle for the polling loop */
|
|
3192
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
3193
|
+
|
|
3194
|
+
/** Whether the monitor is currently running */
|
|
3195
|
+
private _running = false;
|
|
3196
|
+
|
|
3197
|
+
/** Callback invoked when a dead session is detected (for early exit signaling) */
|
|
3198
|
+
private _onDeadSession: ((sessionName: string, laneNumber: number) => void) | null = null;
|
|
3199
|
+
|
|
3200
|
+
/** Event emission context */
|
|
3201
|
+
private stateRoot: string;
|
|
3202
|
+
private batchId: string;
|
|
3203
|
+
private waveIndex: number;
|
|
3204
|
+
private phase: OrchBatchPhase;
|
|
3205
|
+
|
|
3206
|
+
/** Polling interval override (for testing) */
|
|
3207
|
+
private pollIntervalMs: number;
|
|
3208
|
+
|
|
3209
|
+
constructor(opts: {
|
|
3210
|
+
stateRoot: string;
|
|
3211
|
+
batchId: string;
|
|
3212
|
+
waveIndex: number;
|
|
3213
|
+
phase: OrchBatchPhase;
|
|
3214
|
+
pollIntervalMs?: number;
|
|
3215
|
+
onDeadSession?: (sessionName: string, laneNumber: number) => void;
|
|
3216
|
+
}) {
|
|
3217
|
+
this.stateRoot = opts.stateRoot;
|
|
3218
|
+
this.batchId = opts.batchId;
|
|
3219
|
+
this.waveIndex = opts.waveIndex;
|
|
3220
|
+
this.phase = opts.phase;
|
|
3221
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? MERGE_HEALTH_POLL_INTERVAL_MS;
|
|
3222
|
+
this._onDeadSession = opts.onDeadSession ?? null;
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
/** Whether the monitor is currently running */
|
|
3226
|
+
get running(): boolean {
|
|
3227
|
+
return this._running;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
/**
|
|
3231
|
+
* Register a merge session for monitoring.
|
|
3232
|
+
*
|
|
3233
|
+
* @param sessionName - Merge session name
|
|
3234
|
+
* @param laneNumber - Lane number the session belongs to
|
|
3235
|
+
* @param resultPath - Path to the expected merge result file
|
|
3236
|
+
*/
|
|
3237
|
+
addSession(sessionName: string, laneNumber: number, resultPath: string): void {
|
|
3238
|
+
const now = Date.now();
|
|
3239
|
+
this.sessions.set(sessionName, {
|
|
3240
|
+
sessionName,
|
|
3241
|
+
laneNumber,
|
|
3242
|
+
lastSnapshot: null,
|
|
3243
|
+
lastActivityAt: now,
|
|
3244
|
+
status: "healthy",
|
|
3245
|
+
warningEmitted: false,
|
|
3246
|
+
stuckEmitted: false,
|
|
3247
|
+
deadEmitted: false,
|
|
3248
|
+
});
|
|
3249
|
+
// Store resultPath for later lookup
|
|
3250
|
+
this._resultPaths.set(sessionName, resultPath);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
/** Result file paths for each session (for dead-session detection) */
|
|
3254
|
+
private _resultPaths: Map<string, string> = new Map();
|
|
3255
|
+
|
|
3256
|
+
/**
|
|
3257
|
+
* Remove a session from monitoring (e.g., merge completed for this lane).
|
|
3258
|
+
*/
|
|
3259
|
+
removeSession(sessionName: string): void {
|
|
3260
|
+
this.sessions.delete(sessionName);
|
|
3261
|
+
this._resultPaths.delete(sessionName);
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** Overlap guard for async poll (TP-070) */
|
|
3265
|
+
private _polling = false;
|
|
3266
|
+
|
|
3267
|
+
/**
|
|
3268
|
+
* Start the health monitoring polling loop.
|
|
3269
|
+
*/
|
|
3270
|
+
start(): void {
|
|
3271
|
+
if (this._running) return;
|
|
3272
|
+
this._running = true;
|
|
3273
|
+
|
|
3274
|
+
execLog("merge-health", "monitor", "merge health monitor started", {
|
|
3275
|
+
sessionCount: this.sessions.size,
|
|
3276
|
+
pollIntervalMs: this.pollIntervalMs,
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
this.pollTimer = setInterval(async () => {
|
|
3280
|
+
if (this._polling) return; // Overlap guard (TP-070)
|
|
3281
|
+
this._polling = true;
|
|
3282
|
+
try {
|
|
3283
|
+
await this.poll();
|
|
3284
|
+
} finally {
|
|
3285
|
+
this._polling = false;
|
|
3286
|
+
}
|
|
3287
|
+
}, this.pollIntervalMs);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
/**
|
|
3291
|
+
* Stop the health monitoring polling loop.
|
|
3292
|
+
*/
|
|
3293
|
+
stop(): void {
|
|
3294
|
+
if (!this._running) return;
|
|
3295
|
+
this._running = false;
|
|
3296
|
+
|
|
3297
|
+
if (this.pollTimer !== null) {
|
|
3298
|
+
clearInterval(this.pollTimer);
|
|
3299
|
+
this.pollTimer = null;
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
execLog("merge-health", "monitor", "merge health monitor stopped", {
|
|
3303
|
+
sessionCount: this.sessions.size,
|
|
3304
|
+
});
|
|
3305
|
+
|
|
3306
|
+
this.sessions.clear();
|
|
3307
|
+
this._resultPaths.clear();
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
/**
|
|
3311
|
+
* Run a single poll cycle across all monitored sessions.
|
|
3312
|
+
*
|
|
3313
|
+
* Exposed as public for testing — normally called by the interval timer.
|
|
3314
|
+
*/
|
|
3315
|
+
async poll(): Promise<void> {
|
|
3316
|
+
const now = Date.now();
|
|
3317
|
+
|
|
3318
|
+
try {
|
|
3319
|
+
setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId));
|
|
3320
|
+
} catch {
|
|
3321
|
+
setV2LivenessRegistryCache(null);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
try {
|
|
3325
|
+
for (const [sessionName, state] of this.sessions) {
|
|
3326
|
+
const sessionAlive = isV2AgentAlive(sessionName, "v2");
|
|
3327
|
+
const resultPath = this._resultPaths.get(sessionName) ?? "";
|
|
3328
|
+
const hasResultFile = resultPath ? existsSync(resultPath) : false;
|
|
3329
|
+
|
|
3330
|
+
const newStatus = classifyMergeHealth(sessionAlive, hasResultFile, state, now);
|
|
3331
|
+
|
|
3332
|
+
state.status = newStatus;
|
|
3333
|
+
|
|
3334
|
+
// Emit events based on status transitions
|
|
3335
|
+
this._emitHealthEvents(state, now);
|
|
3336
|
+
|
|
3337
|
+
// Signal dead session for early exit
|
|
3338
|
+
if (newStatus === "dead" && !state.deadEmitted) {
|
|
3339
|
+
state.deadEmitted = true;
|
|
3340
|
+
if (this._onDeadSession) {
|
|
3341
|
+
this._onDeadSession(sessionName, state.laneNumber);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
} finally {
|
|
3346
|
+
setV2LivenessRegistryCache(null);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* Emit health events based on current state.
|
|
3352
|
+
* De-duplicates: each event type emitted at most once per session.
|
|
3353
|
+
*/
|
|
3354
|
+
private _emitHealthEvents(state: MergeSessionHealthState, now: number): void {
|
|
3355
|
+
const stalledMinutes = Math.round((now - state.lastActivityAt) / 60_000);
|
|
3356
|
+
|
|
3357
|
+
if (state.status === "warning" && !state.warningEmitted) {
|
|
3358
|
+
state.warningEmitted = true;
|
|
3359
|
+
const event: EngineEvent = {
|
|
3360
|
+
...buildEngineEventBase("merge_health_warning", this.batchId, this.waveIndex, this.phase),
|
|
3361
|
+
laneNumber: state.laneNumber,
|
|
3362
|
+
sessionName: state.sessionName,
|
|
3363
|
+
healthStatus: "warning",
|
|
3364
|
+
stalledMinutes,
|
|
3365
|
+
reason: `Merge agent on lane ${state.laneNumber} may be stalled (${stalledMinutes} min without completion)`,
|
|
3366
|
+
};
|
|
3367
|
+
emitEngineEvent(this.stateRoot, event);
|
|
3368
|
+
execLog("merge-health", state.sessionName, `⚠️ merge session possibly stalled`, {
|
|
3369
|
+
stalledMinutes,
|
|
3370
|
+
laneNumber: state.laneNumber,
|
|
3371
|
+
});
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
if (state.status === "dead" && !state.deadEmitted) {
|
|
3375
|
+
// deadEmitted is set in poll() after onDeadSession callback
|
|
3376
|
+
const event: EngineEvent = {
|
|
3377
|
+
...buildEngineEventBase("merge_health_dead", this.batchId, this.waveIndex, this.phase),
|
|
3378
|
+
laneNumber: state.laneNumber,
|
|
3379
|
+
sessionName: state.sessionName,
|
|
3380
|
+
healthStatus: "dead",
|
|
3381
|
+
reason: `Merge agent on lane ${state.laneNumber} session died without producing a result`,
|
|
3382
|
+
};
|
|
3383
|
+
emitEngineEvent(this.stateRoot, event);
|
|
3384
|
+
execLog("merge-health", state.sessionName, `💀 merge session dead — no result file`, {
|
|
3385
|
+
laneNumber: state.laneNumber,
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
if (state.status === "stuck" && !state.stuckEmitted) {
|
|
3390
|
+
state.stuckEmitted = true;
|
|
3391
|
+
const event: EngineEvent = {
|
|
3392
|
+
...buildEngineEventBase("merge_health_stuck", this.batchId, this.waveIndex, this.phase),
|
|
3393
|
+
laneNumber: state.laneNumber,
|
|
3394
|
+
sessionName: state.sessionName,
|
|
3395
|
+
healthStatus: "stuck",
|
|
3396
|
+
stalledMinutes,
|
|
3397
|
+
reason: `Merge agent on lane ${state.laneNumber} appears stuck (${stalledMinutes} min without completion). Consider killing and retrying.`,
|
|
3398
|
+
};
|
|
3399
|
+
emitEngineEvent(this.stateRoot, event);
|
|
3400
|
+
execLog("merge-health", state.sessionName, `🔒 merge session stuck`, {
|
|
3401
|
+
stalledMinutes,
|
|
3402
|
+
laneNumber: state.laneNumber,
|
|
3403
|
+
});
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
/**
|
|
3408
|
+
* Get the current health states for all monitored sessions.
|
|
3409
|
+
* Used for testing and inspection.
|
|
3410
|
+
*/
|
|
3411
|
+
getSessionStates(): Map<string, MergeSessionHealthState> {
|
|
3412
|
+
return new Map(this.sessions);
|
|
3413
|
+
}
|
|
3414
|
+
}
|