taskplane 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +2 -20
- package/bin/taskplane.mjs +706 -0
- package/dashboard/public/app.js +900 -0
- package/dashboard/public/index.html +92 -0
- package/dashboard/public/style.css +924 -0
- package/dashboard/server.cjs +531 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/extensions/task-runner.ts +1923 -0
- package/extensions/taskplane/abort.ts +466 -0
- package/extensions/taskplane/config.ts +102 -0
- package/extensions/taskplane/discovery.ts +988 -0
- package/extensions/taskplane/engine.ts +758 -0
- package/extensions/taskplane/execution.ts +1752 -0
- package/extensions/taskplane/extension.ts +577 -0
- package/extensions/taskplane/formatting.ts +718 -0
- package/extensions/taskplane/git.ts +38 -0
- package/extensions/taskplane/index.ts +22 -0
- package/extensions/taskplane/merge.ts +795 -0
- package/extensions/taskplane/messages.ts +134 -0
- package/extensions/taskplane/persistence.ts +1121 -0
- package/extensions/taskplane/resume.ts +1092 -0
- package/extensions/taskplane/sessions.ts +92 -0
- package/extensions/taskplane/types.ts +1514 -0
- package/extensions/taskplane/waves.ts +900 -0
- package/extensions/taskplane/worktree.ts +1624 -0
- package/package.json +50 -4
- package/skills/create-taskplane-task/SKILL.md +326 -0
- package/skills/create-taskplane-task/references/context-template.md +78 -0
- package/skills/create-taskplane-task/references/prompt-template.md +246 -0
- package/templates/agents/task-merger.md +256 -0
- package/templates/agents/task-reviewer.md +81 -0
- package/templates/agents/task-worker.md +140 -0
- package/templates/config/task-orchestrator.yaml +89 -0
- package/templates/config/task-runner.yaml +99 -0
- package/templates/tasks/CONTEXT.md +31 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +90 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge orchestration, merge agents, merge worktree
|
|
3
|
+
* @module orch/merge
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
|
|
9
|
+
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
|
+
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
11
|
+
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, WaveExecutionResult } from "./types.ts";
|
|
12
|
+
import { sleepSync } from "./worktree.ts";
|
|
13
|
+
|
|
14
|
+
// ── Merge Implementation ─────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse and validate a merge result JSON file.
|
|
18
|
+
*
|
|
19
|
+
* Strict validation:
|
|
20
|
+
* - Must be valid JSON
|
|
21
|
+
* - Must have required fields: status, source_branch, verification
|
|
22
|
+
* - status must be a known MergeResultStatus
|
|
23
|
+
* - Unknown status values are mapped to BUILD_FAILURE (fail-safe)
|
|
24
|
+
*
|
|
25
|
+
* Retry-read strategy: if initial parse fails, waits and retries up to
|
|
26
|
+
* MERGE_RESULT_READ_RETRIES times to handle partially-written files.
|
|
27
|
+
*
|
|
28
|
+
* @param resultPath - Absolute path to the merge result JSON file
|
|
29
|
+
* @returns Validated MergeResult
|
|
30
|
+
* @throws MergeError with appropriate code on validation failure
|
|
31
|
+
*/
|
|
32
|
+
export function parseMergeResult(resultPath: string): MergeResult {
|
|
33
|
+
if (!existsSync(resultPath)) {
|
|
34
|
+
throw new MergeError(
|
|
35
|
+
"MERGE_RESULT_INVALID",
|
|
36
|
+
`Merge result file not found: ${resultPath}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Retry-read loop for partially-written files
|
|
41
|
+
let lastParseError = "";
|
|
42
|
+
for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(resultPath, "utf-8").trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
lastParseError = "File is empty";
|
|
47
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
48
|
+
sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
throw new MergeError(
|
|
52
|
+
"MERGE_RESULT_INVALID",
|
|
53
|
+
`Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parsed = JSON.parse(raw);
|
|
58
|
+
|
|
59
|
+
// Validate required fields
|
|
60
|
+
if (typeof parsed.status !== "string") {
|
|
61
|
+
throw new MergeError(
|
|
62
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
63
|
+
`Merge result missing required field "status": ${resultPath}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (typeof parsed.source_branch !== "string") {
|
|
67
|
+
throw new MergeError(
|
|
68
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
69
|
+
`Merge result missing required field "source_branch": ${resultPath}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!parsed.verification || typeof parsed.verification !== "object") {
|
|
73
|
+
throw new MergeError(
|
|
74
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
75
|
+
`Merge result missing required field "verification": ${resultPath}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate status value
|
|
80
|
+
if (!VALID_MERGE_STATUSES.has(parsed.status)) {
|
|
81
|
+
execLog("merge", "parse", `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, {
|
|
82
|
+
resultPath,
|
|
83
|
+
});
|
|
84
|
+
parsed.status = "BUILD_FAILURE";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Normalize optional fields with defaults
|
|
88
|
+
return {
|
|
89
|
+
status: parsed.status as MergeResultStatus,
|
|
90
|
+
source_branch: parsed.source_branch,
|
|
91
|
+
target_branch: parsed.target_branch || "",
|
|
92
|
+
merge_commit: parsed.merge_commit || "",
|
|
93
|
+
conflicts: Array.isArray(parsed.conflicts) ? parsed.conflicts : [],
|
|
94
|
+
verification: {
|
|
95
|
+
ran: !!parsed.verification.ran,
|
|
96
|
+
passed: !!parsed.verification.passed,
|
|
97
|
+
output: typeof parsed.verification.output === "string"
|
|
98
|
+
? parsed.verification.output.slice(0, 2000)
|
|
99
|
+
: "",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
} catch (err: unknown) {
|
|
103
|
+
if (err instanceof MergeError) throw err;
|
|
104
|
+
|
|
105
|
+
// JSON parse error — possibly partially written
|
|
106
|
+
lastParseError = err instanceof Error ? err.message : String(err);
|
|
107
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
108
|
+
sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new MergeError(
|
|
115
|
+
"MERGE_RESULT_INVALID",
|
|
116
|
+
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
117
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Determine merge order for completed lanes.
|
|
123
|
+
*
|
|
124
|
+
* Default heuristic: fewest-files-first.
|
|
125
|
+
* - Lanes with fewer files in their file scope merge first
|
|
126
|
+
* - Smaller changes are less likely to conflict, establishing a clean base
|
|
127
|
+
* - Tie-breaker: branch name alphabetically (deterministic)
|
|
128
|
+
*
|
|
129
|
+
* Alternative: sequential (lane number order).
|
|
130
|
+
*
|
|
131
|
+
* @param lanes - Completed lanes to order
|
|
132
|
+
* @param order - Ordering strategy from config
|
|
133
|
+
* @returns Lanes sorted in merge order
|
|
134
|
+
*/
|
|
135
|
+
export function determineMergeOrder(
|
|
136
|
+
lanes: AllocatedLane[],
|
|
137
|
+
order: "fewest-files-first" | "sequential",
|
|
138
|
+
): AllocatedLane[] {
|
|
139
|
+
const sorted = [...lanes];
|
|
140
|
+
|
|
141
|
+
if (order === "sequential") {
|
|
142
|
+
sorted.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
143
|
+
return sorted;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// fewest-files-first: count total file scope across all tasks in the lane
|
|
147
|
+
sorted.sort((a, b) => {
|
|
148
|
+
const aFiles = a.tasks.reduce((sum, t) => sum + (t.task.fileScope?.length || 0), 0);
|
|
149
|
+
const bFiles = b.tasks.reduce((sum, t) => sum + (t.task.fileScope?.length || 0), 0);
|
|
150
|
+
|
|
151
|
+
if (aFiles !== bFiles) return aFiles - bFiles;
|
|
152
|
+
|
|
153
|
+
// Tie-breaker: branch name alphabetically
|
|
154
|
+
return a.branch.localeCompare(b.branch);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return sorted;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build merge request content for the merge agent.
|
|
162
|
+
*
|
|
163
|
+
* The merge request is a structured text document that tells the merge agent:
|
|
164
|
+
* - Which branch to merge (source)
|
|
165
|
+
* - Which branch to merge into (target)
|
|
166
|
+
* - What tasks were completed in this lane
|
|
167
|
+
* - File scope of those tasks
|
|
168
|
+
* - Verification commands to run
|
|
169
|
+
* - Where to write the result file
|
|
170
|
+
*
|
|
171
|
+
* @param lane - The lane to merge
|
|
172
|
+
* @param targetBranch - Target branch (typically "develop")
|
|
173
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
174
|
+
* @param verifyCommands - Verification commands from config
|
|
175
|
+
* @param resultFilePath - Path where the merge agent should write results
|
|
176
|
+
* @returns Formatted merge request text
|
|
177
|
+
*/
|
|
178
|
+
export function buildMergeRequest(
|
|
179
|
+
lane: AllocatedLane,
|
|
180
|
+
targetBranch: string,
|
|
181
|
+
waveIndex: number,
|
|
182
|
+
verifyCommands: string[],
|
|
183
|
+
resultFilePath: string,
|
|
184
|
+
): string {
|
|
185
|
+
const taskIds = lane.tasks.map(t => t.taskId).join(", ");
|
|
186
|
+
const fileScopes = lane.tasks
|
|
187
|
+
.flatMap(t => t.task.fileScope || [])
|
|
188
|
+
.filter((f, i, arr) => arr.indexOf(f) === i); // deduplicate
|
|
189
|
+
|
|
190
|
+
const mergeMessage = `merge: wave ${waveIndex} lane ${lane.laneNumber} — ${taskIds}`;
|
|
191
|
+
|
|
192
|
+
const lines: string[] = [
|
|
193
|
+
"# Merge Request",
|
|
194
|
+
"",
|
|
195
|
+
`## Source Branch`,
|
|
196
|
+
`${lane.branch}`,
|
|
197
|
+
"",
|
|
198
|
+
`## Target Branch`,
|
|
199
|
+
`${targetBranch}`,
|
|
200
|
+
"",
|
|
201
|
+
`## Merge Message`,
|
|
202
|
+
`${mergeMessage}`,
|
|
203
|
+
"",
|
|
204
|
+
`## Tasks Completed`,
|
|
205
|
+
...lane.tasks.map(t => `- ${t.taskId}: ${t.task.taskName}`),
|
|
206
|
+
"",
|
|
207
|
+
`## File Scope`,
|
|
208
|
+
...(fileScopes.length > 0
|
|
209
|
+
? fileScopes.map(f => `- ${f}`)
|
|
210
|
+
: ["- (no file scope declared)"]),
|
|
211
|
+
"",
|
|
212
|
+
`## Verification Commands`,
|
|
213
|
+
...verifyCommands.map(cmd => `\`\`\`bash\n${cmd}\n\`\`\``),
|
|
214
|
+
"",
|
|
215
|
+
`## Result File`,
|
|
216
|
+
`result_file: ${resultFilePath}`,
|
|
217
|
+
`Write your JSON result to: ${resultFilePath}`,
|
|
218
|
+
"",
|
|
219
|
+
|
|
220
|
+
"## Important",
|
|
221
|
+
"- You are working in an ISOLATED MERGE WORKTREE (not the user's main repo)",
|
|
222
|
+
"- The correct branch is ALREADY checked out — do NOT checkout any other branch",
|
|
223
|
+
"- Simply merge the source branch into the current HEAD",
|
|
224
|
+
"- Run ALL verification commands after a successful merge",
|
|
225
|
+
"- If verification fails, revert the merge commit before writing the result",
|
|
226
|
+
"- Write the result file LAST, after all git operations are complete",
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Spawn a TMUX session for the merge agent.
|
|
234
|
+
*
|
|
235
|
+
* Creates a TMUX session in the main repo directory (not a worktree)
|
|
236
|
+
* that runs pi with the task-merger agent definition and the merge request.
|
|
237
|
+
*
|
|
238
|
+
* Handles:
|
|
239
|
+
* - Stale session cleanup
|
|
240
|
+
* - Retry on transient spawn failures
|
|
241
|
+
* - Structured logging
|
|
242
|
+
*
|
|
243
|
+
* @param sessionName - TMUX session name (e.g., "orch-merge-1")
|
|
244
|
+
* @param repoRoot - Main repository root (merge happens here)
|
|
245
|
+
* @param mergeRequestPath - Path to the merge request temp file
|
|
246
|
+
* @param config - Orchestrator config (for model, tools)
|
|
247
|
+
* @throws MergeError if spawn fails after retries
|
|
248
|
+
*/
|
|
249
|
+
export function spawnMergeAgent(
|
|
250
|
+
sessionName: string,
|
|
251
|
+
repoRoot: string,
|
|
252
|
+
mergeWorkDir: string,
|
|
253
|
+
mergeRequestPath: string,
|
|
254
|
+
config: OrchestratorConfig,
|
|
255
|
+
): void {
|
|
256
|
+
execLog("merge", sessionName, "preparing to spawn merge agent", {
|
|
257
|
+
mergeWorkDir,
|
|
258
|
+
mergeRequestPath,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Clean up stale session if exists
|
|
262
|
+
if (tmuxHasSession(sessionName)) {
|
|
263
|
+
execLog("merge", sessionName, "killing stale merge session");
|
|
264
|
+
tmuxKillSession(sessionName);
|
|
265
|
+
sleepSync(500);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build the pi command for the merge agent.
|
|
269
|
+
// Uses --no-session to prevent interactive session management.
|
|
270
|
+
// --append-system-prompt loads the merger agent definition.
|
|
271
|
+
// The merge request file is passed as a prompt via @file syntax.
|
|
272
|
+
const shellQuote = (s: string): string => {
|
|
273
|
+
if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
|
|
274
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
275
|
+
}
|
|
276
|
+
return s;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Build model args if specified
|
|
280
|
+
const modelArgs = config.merge.model ? `--model ${shellQuote(config.merge.model)}` : "";
|
|
281
|
+
|
|
282
|
+
// Build tools override if specified
|
|
283
|
+
const toolsArgs = config.merge.tools ? `--tools ${shellQuote(config.merge.tools)}` : "";
|
|
284
|
+
|
|
285
|
+
const piCommand = [
|
|
286
|
+
"pi --no-session",
|
|
287
|
+
modelArgs,
|
|
288
|
+
toolsArgs,
|
|
289
|
+
`--append-system-prompt ${shellQuote(join(repoRoot, ".pi", "agents", "task-merger.md"))}`,
|
|
290
|
+
`@${shellQuote(mergeRequestPath)}`,
|
|
291
|
+
].filter(Boolean).join(" ");
|
|
292
|
+
|
|
293
|
+
const tmuxMergeDir = toTmuxPath(mergeWorkDir);
|
|
294
|
+
// Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
|
|
295
|
+
// Force xterm-256color so pi can render and start execution.
|
|
296
|
+
// Same fix as buildTmuxSpawnArgs / buildLaneEnvVars.
|
|
297
|
+
const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && TERM=xterm-256color ${piCommand}`;
|
|
298
|
+
const tmuxArgs = [
|
|
299
|
+
"new-session", "-d",
|
|
300
|
+
"-s", sessionName,
|
|
301
|
+
wrappedCommand,
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
// Attempt to spawn with retry
|
|
305
|
+
let lastError = "";
|
|
306
|
+
for (let attempt = 1; attempt <= MERGE_SPAWN_RETRY_MAX + 1; attempt++) {
|
|
307
|
+
const result = spawnSync("tmux", tmuxArgs);
|
|
308
|
+
|
|
309
|
+
if (result.status === 0) {
|
|
310
|
+
execLog("merge", sessionName, "merge agent session spawned", { attempt });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
lastError = result.stderr?.toString().trim() || "unknown spawn error";
|
|
315
|
+
execLog("merge", sessionName, `merge spawn attempt ${attempt} failed: ${lastError}`);
|
|
316
|
+
|
|
317
|
+
if (attempt <= MERGE_SPAWN_RETRY_MAX) {
|
|
318
|
+
sleepSync(attempt * 1000);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new MergeError(
|
|
323
|
+
"MERGE_SPAWN_FAILED",
|
|
324
|
+
`Failed to create merge TMUX session '${sessionName}' after ` +
|
|
325
|
+
`${MERGE_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Wait for merge agent to produce a result file.
|
|
331
|
+
*
|
|
332
|
+
* Polling loop with timeout and session liveness detection:
|
|
333
|
+
* 1. Check if result file exists → parse and return
|
|
334
|
+
* 2. Check if TMUX session is still alive
|
|
335
|
+
* 3. If session died without result → grace period → check again → fail
|
|
336
|
+
* 4. If timeout exceeded → kill session → fail
|
|
337
|
+
*
|
|
338
|
+
* @param resultPath - Path to the expected result JSON file
|
|
339
|
+
* @param sessionName - TMUX session name for liveness checking
|
|
340
|
+
* @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
|
|
341
|
+
* @returns Validated MergeResult
|
|
342
|
+
* @throws MergeError on timeout, session death, or invalid result
|
|
343
|
+
*/
|
|
344
|
+
export function waitForMergeResult(
|
|
345
|
+
resultPath: string,
|
|
346
|
+
sessionName: string,
|
|
347
|
+
timeoutMs: number = MERGE_TIMEOUT_MS,
|
|
348
|
+
): MergeResult {
|
|
349
|
+
const startTime = Date.now();
|
|
350
|
+
let sessionDiedAt: number | null = null;
|
|
351
|
+
|
|
352
|
+
execLog("merge", sessionName, "waiting for merge result", {
|
|
353
|
+
resultPath,
|
|
354
|
+
timeoutMs,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
while (true) {
|
|
358
|
+
const elapsed = Date.now() - startTime;
|
|
359
|
+
|
|
360
|
+
// Check timeout
|
|
361
|
+
if (elapsed >= timeoutMs) {
|
|
362
|
+
execLog("merge", sessionName, "merge timeout — killing session", {
|
|
363
|
+
elapsed,
|
|
364
|
+
timeoutMs,
|
|
365
|
+
});
|
|
366
|
+
tmuxKillSession(sessionName);
|
|
367
|
+
|
|
368
|
+
// One final check for result file (agent may have written it just before timeout)
|
|
369
|
+
if (existsSync(resultPath)) {
|
|
370
|
+
try {
|
|
371
|
+
return parseMergeResult(resultPath);
|
|
372
|
+
} catch {
|
|
373
|
+
// Fall through to timeout error
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
throw new MergeError(
|
|
378
|
+
"MERGE_TIMEOUT",
|
|
379
|
+
`Merge agent '${sessionName}' did not produce a result within ` +
|
|
380
|
+
`${Math.round(timeoutMs / 1000)}s. The session has been killed. ` +
|
|
381
|
+
`Check the merge request and agent logs.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Check if result file exists
|
|
386
|
+
if (existsSync(resultPath)) {
|
|
387
|
+
try {
|
|
388
|
+
const result = parseMergeResult(resultPath);
|
|
389
|
+
execLog("merge", sessionName, "merge result received", {
|
|
390
|
+
status: result.status,
|
|
391
|
+
elapsed,
|
|
392
|
+
});
|
|
393
|
+
// Kill session if still alive (agent should exit, but ensure cleanup)
|
|
394
|
+
if (tmuxHasSession(sessionName)) {
|
|
395
|
+
tmuxKillSession(sessionName);
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
} catch (err: unknown) {
|
|
399
|
+
// File exists but invalid — might be partially written.
|
|
400
|
+
// parseMergeResult already retries, so if it throws, it's final.
|
|
401
|
+
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
402
|
+
// Wait a bit and try once more (file might still be in flight)
|
|
403
|
+
sleepSync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
404
|
+
if (existsSync(resultPath)) {
|
|
405
|
+
try {
|
|
406
|
+
return parseMergeResult(resultPath);
|
|
407
|
+
} catch {
|
|
408
|
+
// Give up on this file
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// If still failing, continue polling (agent might rewrite)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Check session liveness
|
|
417
|
+
const sessionAlive = tmuxHasSession(sessionName);
|
|
418
|
+
|
|
419
|
+
if (!sessionAlive) {
|
|
420
|
+
if (sessionDiedAt === null) {
|
|
421
|
+
// First detection of session death — start grace period
|
|
422
|
+
sessionDiedAt = Date.now();
|
|
423
|
+
execLog("merge", sessionName, "session exited — starting grace period", {
|
|
424
|
+
graceMs: MERGE_RESULT_GRACE_MS,
|
|
425
|
+
});
|
|
426
|
+
} else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
|
|
427
|
+
// Grace period expired — no result file
|
|
428
|
+
// One final check
|
|
429
|
+
if (existsSync(resultPath)) {
|
|
430
|
+
try {
|
|
431
|
+
return parseMergeResult(resultPath);
|
|
432
|
+
} catch {
|
|
433
|
+
// Fall through to session died error
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
throw new MergeError(
|
|
438
|
+
"MERGE_SESSION_DIED",
|
|
439
|
+
`Merge agent session '${sessionName}' exited without writing ` +
|
|
440
|
+
`a result file to '${resultPath}'. The merge may have crashed. ` +
|
|
441
|
+
`Check the session output: tmux capture-pane is unavailable ` +
|
|
442
|
+
`after session exit.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// Within grace period — continue polling
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Poll interval
|
|
449
|
+
sleepSync(MERGE_POLL_INTERVAL_MS);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Merge a completed wave's lane branches into the integration branch.
|
|
455
|
+
*
|
|
456
|
+
* Orchestration flow:
|
|
457
|
+
* 1. Filter to only succeeded lanes (failed lanes are not merged)
|
|
458
|
+
* 2. Determine merge order (fewest-files-first or sequential)
|
|
459
|
+
* 3. For each lane, sequentially:
|
|
460
|
+
* a. Build merge request content
|
|
461
|
+
* b. Write merge request to temp file
|
|
462
|
+
* c. Spawn merge agent in TMUX session (in main repo)
|
|
463
|
+
* d. Wait for merge result
|
|
464
|
+
* e. Handle result (continue, log, or pause)
|
|
465
|
+
* 4. Return MergeWaveResult
|
|
466
|
+
*
|
|
467
|
+
* Sequential execution is mandatory — the integration branch is a shared
|
|
468
|
+
* resource, and each merge must see the prior merge's result.
|
|
469
|
+
*
|
|
470
|
+
* On CONFLICT_UNRESOLVED or BUILD_FAILURE: stops merging remaining lanes
|
|
471
|
+
* and returns with failure status.
|
|
472
|
+
*
|
|
473
|
+
* Temp file cleanup: merge request files are cleaned up after each lane,
|
|
474
|
+
* regardless of outcome. Result files are left for debugging.
|
|
475
|
+
*
|
|
476
|
+
* @param completedLanes - Lanes that completed execution (from wave result)
|
|
477
|
+
* @param waveResult - The wave execution result (for lane status filtering)
|
|
478
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
479
|
+
* @param config - Orchestrator configuration
|
|
480
|
+
* @param repoRoot - Main repository root
|
|
481
|
+
* @param batchId - Batch ID for session naming
|
|
482
|
+
* @returns MergeWaveResult with per-lane outcomes
|
|
483
|
+
*/
|
|
484
|
+
export function mergeWave(
|
|
485
|
+
completedLanes: AllocatedLane[],
|
|
486
|
+
waveResult: WaveExecutionResult,
|
|
487
|
+
waveIndex: number,
|
|
488
|
+
config: OrchestratorConfig,
|
|
489
|
+
repoRoot: string,
|
|
490
|
+
batchId: string,
|
|
491
|
+
): MergeWaveResult {
|
|
492
|
+
const startTime = Date.now();
|
|
493
|
+
const tmuxPrefix = config.orchestrator.tmux_prefix;
|
|
494
|
+
const targetBranch = config.orchestrator.integration_branch;
|
|
495
|
+
const laneResults: MergeLaneResult[] = [];
|
|
496
|
+
|
|
497
|
+
// Build lane outcome lookup for merge eligibility checks.
|
|
498
|
+
const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
|
|
499
|
+
for (const laneOutcome of waveResult.laneResults) {
|
|
500
|
+
laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A lane is mergeable if:
|
|
504
|
+
// - It has at least one succeeded task, AND
|
|
505
|
+
// - It has no hard failures (failed/stalled).
|
|
506
|
+
//
|
|
507
|
+
// This allows succeeded+skipped lanes (e.g., stop-wave skip of remaining tasks)
|
|
508
|
+
// to merge their committed work, while excluding mixed succeeded+failed lanes.
|
|
509
|
+
const mergeableLanes = completedLanes.filter(lane => {
|
|
510
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
511
|
+
if (!outcome) return false;
|
|
512
|
+
|
|
513
|
+
const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
|
|
514
|
+
const hasHardFailure = outcome.tasks.some(
|
|
515
|
+
t => t.status === "failed" || t.status === "stalled",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
return hasSucceeded && !hasHardFailure;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
if (mergeableLanes.length === 0) {
|
|
522
|
+
execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
|
|
523
|
+
return {
|
|
524
|
+
waveIndex,
|
|
525
|
+
status: "succeeded", // vacuous success — nothing to merge
|
|
526
|
+
laneResults: [],
|
|
527
|
+
failedLane: null,
|
|
528
|
+
failureReason: null,
|
|
529
|
+
totalDurationMs: Date.now() - startTime,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Determine merge order
|
|
534
|
+
const orderedLanes = determineMergeOrder(mergeableLanes, config.merge.order);
|
|
535
|
+
|
|
536
|
+
execLog("merge", `W${waveIndex}`, `merging ${orderedLanes.length} lane(s)`, {
|
|
537
|
+
order: config.merge.order,
|
|
538
|
+
lanes: orderedLanes.map(l => l.laneNumber).join(","),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ── Create isolated merge worktree ──────────────────────────────
|
|
542
|
+
// Merging in a dedicated worktree prevents dirty-worktree failures
|
|
543
|
+
// caused by user edits or orchestrator-generated files in the main repo.
|
|
544
|
+
const tempBranch = `_merge-temp-${batchId}`;
|
|
545
|
+
const mergeWorkDir = join(repoRoot, ".worktrees", "merge-workspace");
|
|
546
|
+
|
|
547
|
+
// Clean up stale merge worktree/branch from prior failed attempt
|
|
548
|
+
try {
|
|
549
|
+
if (existsSync(mergeWorkDir)) {
|
|
550
|
+
spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
|
|
551
|
+
sleepSync(500);
|
|
552
|
+
}
|
|
553
|
+
} catch { /* best effort */ }
|
|
554
|
+
try {
|
|
555
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
556
|
+
} catch { /* branch may not exist */ }
|
|
557
|
+
|
|
558
|
+
// Create temp branch at target branch HEAD, then worktree
|
|
559
|
+
const branchResult = spawnSync("git", ["branch", tempBranch, targetBranch], { cwd: repoRoot });
|
|
560
|
+
if (branchResult.status !== 0) {
|
|
561
|
+
const err = branchResult.stderr?.toString().trim() || "unknown error";
|
|
562
|
+
execLog("merge", `W${waveIndex}`, `failed to create temp branch: ${err}`);
|
|
563
|
+
return {
|
|
564
|
+
waveIndex, status: "failed", laneResults: [],
|
|
565
|
+
failedLane: null, failureReason: `Failed to create merge temp branch: ${err}`,
|
|
566
|
+
totalDurationMs: Date.now() - startTime,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const wtResult = spawnSync("git", ["worktree", "add", mergeWorkDir, tempBranch], { cwd: repoRoot });
|
|
571
|
+
if (wtResult.status !== 0) {
|
|
572
|
+
const err = wtResult.stderr?.toString().trim() || "unknown error";
|
|
573
|
+
execLog("merge", `W${waveIndex}`, `failed to create merge worktree: ${err}`);
|
|
574
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
575
|
+
return {
|
|
576
|
+
waveIndex, status: "failed", laneResults: [],
|
|
577
|
+
failedLane: null, failureReason: `Failed to create merge worktree: ${err}`,
|
|
578
|
+
totalDurationMs: Date.now() - startTime,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
execLog("merge", `W${waveIndex}`, `merge worktree created`, {
|
|
583
|
+
worktree: mergeWorkDir,
|
|
584
|
+
tempBranch,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Sequential merge loop
|
|
588
|
+
let failedLane: number | null = null;
|
|
589
|
+
let failureReason: string | null = null;
|
|
590
|
+
|
|
591
|
+
for (const lane of orderedLanes) {
|
|
592
|
+
const laneStart = Date.now();
|
|
593
|
+
const sessionName = `${tmuxPrefix}-merge-${lane.laneNumber}`;
|
|
594
|
+
const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${batchId}.json`;
|
|
595
|
+
const resultFilePath = join(repoRoot, ".pi", resultFileName);
|
|
596
|
+
const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${batchId}.txt`;
|
|
597
|
+
const requestFilePath = join(repoRoot, ".pi", requestFileName);
|
|
598
|
+
|
|
599
|
+
execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
|
|
600
|
+
sourceBranch: lane.branch,
|
|
601
|
+
targetBranch,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
// Clean up any stale result file from prior attempt
|
|
606
|
+
if (existsSync(resultFilePath)) {
|
|
607
|
+
try {
|
|
608
|
+
unlinkSync(resultFilePath);
|
|
609
|
+
} catch {
|
|
610
|
+
// Best effort
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Build merge request content
|
|
615
|
+
const mergeRequestContent = buildMergeRequest(
|
|
616
|
+
lane,
|
|
617
|
+
targetBranch,
|
|
618
|
+
waveIndex,
|
|
619
|
+
config.merge.verify,
|
|
620
|
+
resultFilePath,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// Write merge request to temp file
|
|
624
|
+
writeFileSync(requestFilePath, mergeRequestContent, "utf-8");
|
|
625
|
+
|
|
626
|
+
// Spawn merge agent in the isolated merge worktree
|
|
627
|
+
spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config);
|
|
628
|
+
|
|
629
|
+
// Wait for result
|
|
630
|
+
const mergeResult = waitForMergeResult(resultFilePath, sessionName);
|
|
631
|
+
|
|
632
|
+
// Clean up request file (leave result file for debugging)
|
|
633
|
+
try {
|
|
634
|
+
unlinkSync(requestFilePath);
|
|
635
|
+
} catch {
|
|
636
|
+
// Best effort
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Record lane result
|
|
640
|
+
laneResults.push({
|
|
641
|
+
laneNumber: lane.laneNumber,
|
|
642
|
+
laneId: lane.laneId,
|
|
643
|
+
sourceBranch: lane.branch,
|
|
644
|
+
targetBranch,
|
|
645
|
+
result: mergeResult,
|
|
646
|
+
error: null,
|
|
647
|
+
durationMs: Date.now() - laneStart,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Handle merge outcome
|
|
651
|
+
switch (mergeResult.status) {
|
|
652
|
+
case "SUCCESS":
|
|
653
|
+
execLog("merge", sessionName, "merge succeeded", {
|
|
654
|
+
mergeCommit: mergeResult.merge_commit.slice(0, 8),
|
|
655
|
+
duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
|
|
656
|
+
});
|
|
657
|
+
break;
|
|
658
|
+
|
|
659
|
+
case "CONFLICT_RESOLVED":
|
|
660
|
+
execLog("merge", sessionName, "merge succeeded with resolved conflicts", {
|
|
661
|
+
mergeCommit: mergeResult.merge_commit.slice(0, 8),
|
|
662
|
+
conflictCount: mergeResult.conflicts.length,
|
|
663
|
+
duration: `${Math.round((Date.now() - laneStart) / 1000)}s`,
|
|
664
|
+
});
|
|
665
|
+
break;
|
|
666
|
+
|
|
667
|
+
case "CONFLICT_UNRESOLVED":
|
|
668
|
+
execLog("merge", sessionName, "merge failed — unresolved conflicts", {
|
|
669
|
+
conflictCount: mergeResult.conflicts.length,
|
|
670
|
+
files: mergeResult.conflicts.map(c => c.file).join(", "),
|
|
671
|
+
});
|
|
672
|
+
failedLane = lane.laneNumber;
|
|
673
|
+
failureReason = `Unresolved merge conflicts in lane ${lane.laneNumber}: ` +
|
|
674
|
+
mergeResult.conflicts.map(c => c.file).join(", ");
|
|
675
|
+
break;
|
|
676
|
+
|
|
677
|
+
case "BUILD_FAILURE":
|
|
678
|
+
execLog("merge", sessionName, "merge failed — verification failed", {
|
|
679
|
+
output: mergeResult.verification.output.slice(0, 200),
|
|
680
|
+
});
|
|
681
|
+
failedLane = lane.laneNumber;
|
|
682
|
+
failureReason = `Post-merge verification failed in lane ${lane.laneNumber}: ` +
|
|
683
|
+
mergeResult.verification.output.slice(0, 500);
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Stop merging if this lane failed
|
|
688
|
+
if (failedLane !== null) break;
|
|
689
|
+
|
|
690
|
+
} catch (err: unknown) {
|
|
691
|
+
// Clean up request file on error
|
|
692
|
+
try {
|
|
693
|
+
if (existsSync(requestFilePath)) unlinkSync(requestFilePath);
|
|
694
|
+
} catch {
|
|
695
|
+
// Best effort
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Kill merge session if still alive
|
|
699
|
+
if (tmuxHasSession(sessionName)) {
|
|
700
|
+
tmuxKillSession(sessionName);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
704
|
+
const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
|
|
705
|
+
|
|
706
|
+
execLog("merge", sessionName, `merge error: ${errMsg}`, { code: errCode });
|
|
707
|
+
|
|
708
|
+
laneResults.push({
|
|
709
|
+
laneNumber: lane.laneNumber,
|
|
710
|
+
laneId: lane.laneId,
|
|
711
|
+
sourceBranch: lane.branch,
|
|
712
|
+
targetBranch,
|
|
713
|
+
result: null,
|
|
714
|
+
error: errMsg,
|
|
715
|
+
durationMs: Date.now() - laneStart,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
failedLane = lane.laneNumber;
|
|
719
|
+
failureReason = `Merge error in lane ${lane.laneNumber}: ${errMsg}`;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Fast-forward develop and clean up merge worktree ────────────
|
|
725
|
+
const anySuccess = laneResults.some(
|
|
726
|
+
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
if (anySuccess) {
|
|
730
|
+
// Fast-forward the real target branch to the temp merge branch.
|
|
731
|
+
// The main repo may have dirty files (user edits) — stash if needed.
|
|
732
|
+
const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
733
|
+
|
|
734
|
+
if (ffResult.status !== 0) {
|
|
735
|
+
// Dirty working tree may block ff — try stash + ff + pop
|
|
736
|
+
execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
|
|
737
|
+
const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
|
|
738
|
+
spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
|
|
739
|
+
|
|
740
|
+
const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
|
|
741
|
+
|
|
742
|
+
// Always pop stash, regardless of ff result
|
|
743
|
+
spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
|
|
744
|
+
|
|
745
|
+
if (ffRetry.status !== 0) {
|
|
746
|
+
const err = ffRetry.stderr?.toString().trim() || "unknown error";
|
|
747
|
+
execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
|
|
748
|
+
failedLane = failedLane ?? -1;
|
|
749
|
+
failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
|
|
750
|
+
} else {
|
|
751
|
+
execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Clean up merge worktree and temp branch (always, regardless of outcome)
|
|
759
|
+
try {
|
|
760
|
+
spawnSync("git", ["worktree", "remove", mergeWorkDir, "--force"], { cwd: repoRoot });
|
|
761
|
+
} catch { /* best effort */ }
|
|
762
|
+
try {
|
|
763
|
+
// Small delay to ensure worktree lock is released
|
|
764
|
+
sleepSync(500);
|
|
765
|
+
spawnSync("git", ["branch", "-D", tempBranch], { cwd: repoRoot });
|
|
766
|
+
} catch { /* best effort */ }
|
|
767
|
+
|
|
768
|
+
// Determine overall status
|
|
769
|
+
let status: MergeWaveResult["status"];
|
|
770
|
+
if (failedLane === null) {
|
|
771
|
+
status = "succeeded";
|
|
772
|
+
} else if (anySuccess) {
|
|
773
|
+
status = "partial";
|
|
774
|
+
} else {
|
|
775
|
+
status = "failed";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const totalDurationMs = Date.now() - startTime;
|
|
779
|
+
|
|
780
|
+
execLog("merge", `W${waveIndex}`, `wave merge complete: ${status}`, {
|
|
781
|
+
mergedLanes: laneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
|
|
782
|
+
failedLane: failedLane ?? 0,
|
|
783
|
+
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
waveIndex,
|
|
788
|
+
status,
|
|
789
|
+
laneResults,
|
|
790
|
+
failedLane,
|
|
791
|
+
failureReason,
|
|
792
|
+
totalDurationMs,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|