2824 lines
101 KiB
TypeScript
2824 lines
101 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
import { execSync, exec } from "node:child_process";
|
||
import { promisify } from "node:util";
|
||
|
||
const execAsync = promisify(exec);
|
||
import { existsSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
|
||
import { resolveAgentPrompt } from "@fusion/core";
|
||
import { createKbAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
|
||
import { buildSessionSkillContext } from "./session-skill-context.js";
|
||
import type { WorktreePool } from "./worktree-pool.js";
|
||
import { AgentLogger } from "./agent-logger.js";
|
||
import { mergerLog } from "./logger.js";
|
||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||
import { isContextLimitError } from "./context-limit-detector.js";
|
||
import { withRateLimitRetry } from "./rate-limit-retry.js";
|
||
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||
import { Type } from "@sinclair/typebox";
|
||
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
|
||
|
||
/** Conflict type classification for merge conflict resolution */
|
||
export type ConflictType =
|
||
| "lockfile-ours"
|
||
| "generated-theirs"
|
||
| "trivial-whitespace"
|
||
| "complex";
|
||
|
||
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
|
||
export const LOCKFILE_PATTERNS = [
|
||
"package-lock.json",
|
||
"pnpm-lock.yaml",
|
||
"yarn.lock",
|
||
"Gemfile.lock",
|
||
"composer.lock",
|
||
"poetry.lock",
|
||
"bun.lockb",
|
||
"go.sum",
|
||
];
|
||
|
||
/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
|
||
export const GENERATED_PATTERNS = [
|
||
"*.gen.ts",
|
||
"*.gen.js",
|
||
"*.min.js",
|
||
"*.min.css",
|
||
"dist/*",
|
||
"build/*",
|
||
"coverage/*",
|
||
".next/*",
|
||
".nuxt/*",
|
||
".output/*",
|
||
".cache/*",
|
||
"out/*",
|
||
"__generated__/*",
|
||
"generated/*",
|
||
];
|
||
|
||
const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
|
||
"package.json",
|
||
"pnpm-lock.yaml",
|
||
"pnpm-workspace.yaml",
|
||
"package-lock.json",
|
||
"yarn.lock",
|
||
"bun.lockb",
|
||
"bun.lock",
|
||
"packages/*/package.json",
|
||
];
|
||
|
||
const VERIFICATION_COMMAND_MAX_BUFFER = 50 * 1024 * 1024;
|
||
const VERIFICATION_LOG_MAX_CHARS = 20_000;
|
||
const WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS = 4_000;
|
||
|
||
/** Maximum characters for commit log in merge prompt — prevents context overflow on large branches */
|
||
const MERGE_COMMIT_LOG_MAX_CHARS = 5000;
|
||
|
||
/** Maximum characters for diff stat in merge prompt — prevents context overflow on large diffs */
|
||
const MERGE_DIFF_STAT_MAX_CHARS = 3000;
|
||
|
||
/**
|
||
* Truncate text to maxChars with ellipsis indicator.
|
||
* Returns original text if under limit.
|
||
*/
|
||
function truncateWithEllipsis(text: string, maxChars: number): string {
|
||
if (text.length <= maxChars) return text;
|
||
return `${text.slice(0, maxChars)}\n... (truncated)`;
|
||
}
|
||
|
||
// Kept for potential future diagnostics use (may be helpful for detailed error analysis)
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
function truncateVerificationOutput(output: string): string {
|
||
if (output.length <= VERIFICATION_LOG_MAX_CHARS) return output;
|
||
return `... output truncated to last ${VERIFICATION_LOG_MAX_CHARS} characters ...\n${output.slice(-VERIFICATION_LOG_MAX_CHARS)}`;
|
||
}
|
||
|
||
/**
|
||
* Summarize test/build verification failure output into a concise message.
|
||
* Extracts test counts and failure names from common test runner formats,
|
||
* falls back to truncated output for unstructured output.
|
||
*
|
||
* @param output - The raw command output to summarize
|
||
* @param type - The verification type (reserved for future use; currently unused)
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
export function summarizeVerificationOutput(output: string, type: "test" | "build"): string {
|
||
const lines = output.split("\n");
|
||
let summaryLine: string | null = null;
|
||
const failureNames = new Set<string>();
|
||
|
||
// 1. Extract summary line
|
||
for (const line of lines) {
|
||
// vitest/jest: "Tests: 2 failed, 48 passed, 50 total"
|
||
const testsMatch = line.match(/^Tests:\s*(\d+)\s+failed,\s*(\d+)\s+passed(?:,\s*(\d+)\s+total)?/i);
|
||
if (testsMatch) {
|
||
const failed = testsMatch[1];
|
||
const passed = testsMatch[2];
|
||
const total = testsMatch[3] ? `, ${testsMatch[3]} total` : "";
|
||
summaryLine = `Tests: ${failed} failed, ${passed} passed${total}`;
|
||
break;
|
||
}
|
||
|
||
// Generic: "X tests failed, Y passed, Z total"
|
||
const genericMatch = line.match(/^(\d+)\s+tests?\s+failed,\s*(\d+)\s+passed,\s*(\d+)\s+total/i);
|
||
if (genericMatch) {
|
||
summaryLine = `${genericMatch[1]} tests failed, ${genericMatch[2]} passed, ${genericMatch[3]} total`;
|
||
break;
|
||
}
|
||
|
||
// Various runners: "X failing" / "X failures" / "X failed"
|
||
const failCountMatch = line.match(/^(\d+)\s+(failings?|failures?|failed)/i);
|
||
if (failCountMatch) {
|
||
summaryLine = `${failCountMatch[1]} ${failCountMatch[2]}`;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 2. Extract failure names (up to 5 unique names)
|
||
// Priority: markers (✗, ●, -) provide descriptive names, FAIL lines provide file context
|
||
// Process markers first (they give actual test names), then FAIL lines (file context)
|
||
const markerLines: string[] = [];
|
||
const failLines: string[] = [];
|
||
|
||
for (const line of lines) {
|
||
// FAIL <file> — vitest file-level failure header (at start of line)
|
||
const failMatch = line.match(/^(FAIL)\s+(.+)/);
|
||
if (failMatch) {
|
||
failLines.push(failMatch[2].trim());
|
||
continue;
|
||
}
|
||
|
||
// Trim leading whitespace for marker detection (vitest indents failure details)
|
||
const trimmedLine = line.trimStart();
|
||
|
||
// Unicode cross markers: ✗ or ✕ or × (possibly indented)
|
||
const crossMatch = trimmedLine.match(/^[✗✕×]\s*(.+)/);
|
||
if (crossMatch) {
|
||
markerLines.push(crossMatch[1].trim());
|
||
continue;
|
||
}
|
||
|
||
// Jest failure bullet: ● (possibly indented)
|
||
const bulletMatch = trimmedLine.match(/^●\s*(.+)/);
|
||
if (bulletMatch) {
|
||
markerLines.push(bulletMatch[1].trim());
|
||
continue;
|
||
}
|
||
|
||
// Jest/Mocha indented test name: - MyTest › should do something (indented)
|
||
const dashMatch = trimmedLine.match(/^-\s+(\S[\s\S]*?)$/);
|
||
if (dashMatch) {
|
||
const potential = dashMatch[1].trim();
|
||
// Only include lines that look like test names (contain common test patterns)
|
||
if (/[\s›>]|(should|cannot|does|doesn|to|not|throws)/i.test(potential)) {
|
||
markerLines.push(potential);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// AssertionError — generic assertion failures (possibly indented)
|
||
const assertionMatch = trimmedLine.match(/^(AssertionError|AssertionError:.*)$/i);
|
||
if (assertionMatch) {
|
||
markerLines.push(assertionMatch[1]);
|
||
}
|
||
}
|
||
|
||
// Add marker names first (higher priority - they give actual test names)
|
||
for (const name of markerLines) {
|
||
const truncated = name.length > 120 ? name.slice(0, 120) : name;
|
||
failureNames.add(truncated);
|
||
}
|
||
|
||
// Fill remaining slots with FAIL file names (lower priority - just file context)
|
||
for (const name of failLines) {
|
||
const truncated = name.length > 120 ? name.slice(0, 120) : name;
|
||
failureNames.add(truncated);
|
||
}
|
||
|
||
// 3. Build the summary string
|
||
const footer = "(full output available in engine logs)";
|
||
const parts: string[] = [];
|
||
|
||
if (summaryLine) {
|
||
parts.push(summaryLine);
|
||
}
|
||
|
||
if (failureNames.size > 0) {
|
||
const names = Array.from(failureNames);
|
||
if (names.length <= 5) {
|
||
for (const name of names) {
|
||
parts.push(` • ${name}`);
|
||
}
|
||
} else {
|
||
// Show first 5 and note overflow
|
||
for (let i = 0; i < 5; i++) {
|
||
parts.push(` • ${names[i]}`);
|
||
}
|
||
parts.push(` • ... and ${names.length - 5} more failures`);
|
||
}
|
||
}
|
||
|
||
if (parts.length > 0) {
|
||
parts.push(footer);
|
||
return parts.join("\n");
|
||
}
|
||
|
||
// 4. Fallback — no structured data found
|
||
const trimmed = output.trim();
|
||
if (!trimmed) {
|
||
return `Verification command failed with no output\n${footer}`;
|
||
}
|
||
|
||
if (trimmed.length <= 500) {
|
||
return `${trimmed}\n${footer}`;
|
||
}
|
||
|
||
// Truncate at last space or newline boundary
|
||
let cutoff = 500;
|
||
for (let i = 500; i < trimmed.length; i++) {
|
||
if (trimmed[i] === " " || trimmed[i] === "\n") {
|
||
cutoff = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return `${trimmed.slice(0, cutoff)}...\n${footer}`;
|
||
}
|
||
|
||
function truncateWorkflowScriptOutput(output: string): string {
|
||
if (output.length <= WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS) return output;
|
||
return `... output truncated to last ${WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS} characters ...\n${output.slice(-WORKFLOW_SCRIPT_OUTPUT_MAX_CHARS)}`;
|
||
}
|
||
|
||
/** Check if a path matches a glob pattern (simple glob support: * and **) */
|
||
function matchGlob(path: string, pattern: string): boolean {
|
||
// Handle ** which matches across directory boundaries (must do before single *)
|
||
if (pattern.includes("**")) {
|
||
// Convert ** to match any characters including /
|
||
const regexPattern = pattern
|
||
.replace(/\./g, "\\.")
|
||
.replace(/\*\*/g, "<<<DOUBLESTAR>>>")
|
||
.replace(/\*/g, "[^/]*")
|
||
.replace(/<<<DOUBLESTAR>>>/g, ".*");
|
||
const regex = new RegExp(`^${regexPattern}$`);
|
||
return regex.test(path);
|
||
}
|
||
|
||
// Handle patterns with single directory wildcards (e.g., "src/*.ts")
|
||
const lastSlash = pattern.lastIndexOf("/");
|
||
if (lastSlash !== -1) {
|
||
const patternDir = pattern.slice(0, lastSlash);
|
||
const patternFile = pattern.slice(lastSlash + 1);
|
||
const pathDir = path.lastIndexOf("/") !== -1 ? path.slice(0, path.lastIndexOf("/")) : "";
|
||
const pathFile = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/")) : path;
|
||
|
||
// Check if directories match
|
||
if (patternDir.includes("*")) {
|
||
const dirRegex = new RegExp(`^${patternDir.replace(/\./g, "\\.").replace(/\*/g, "[^/]*")}$`);
|
||
if (!dirRegex.test(pathDir)) return false;
|
||
} else if (!pathDir.endsWith(patternDir) && patternDir !== pathDir) {
|
||
return false;
|
||
}
|
||
|
||
// Match filename pattern
|
||
return matchGlob(pathFile, patternFile);
|
||
}
|
||
|
||
// Simple pattern without directory - match against filename only or full path
|
||
const fileName = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/") + 1) : path;
|
||
|
||
// Convert glob to regex
|
||
const regexPattern = pattern
|
||
.replace(/\./g, "\\.")
|
||
.replace(/\*/g, "[^/]*");
|
||
const regex = new RegExp(`^${regexPattern}$`);
|
||
return regex.test(fileName) || regex.test(path);
|
||
}
|
||
|
||
export async function getStagedFiles(cwd: string): Promise<string[]> {
|
||
try {
|
||
const { stdout } = await execAsync("git diff --cached --name-only", {
|
||
cwd,
|
||
encoding: "utf-8",
|
||
});
|
||
const output = stdout.trim();
|
||
return output ? output.split("\n").filter(Boolean) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function hasInstallState(rootDir: string): boolean {
|
||
return existsSync(join(rootDir, "node_modules")) || existsSync(join(rootDir, ".pnp.cjs"));
|
||
}
|
||
|
||
export function shouldSyncDependenciesForMerge(
|
||
stagedFiles: string[],
|
||
installStatePresent: boolean,
|
||
): boolean {
|
||
if (!installStatePresent) return true;
|
||
return stagedFiles.some((file) =>
|
||
DEPENDENCY_SYNC_TRIGGER_PATTERNS.some((pattern) => matchGlob(file, pattern)),
|
||
);
|
||
}
|
||
|
||
function getDependencySyncCommand(rootDir: string): string | null {
|
||
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
|
||
if (existsSync(join(rootDir, "package-lock.json"))) return "npm install";
|
||
if (existsSync(join(rootDir, "yarn.lock"))) return "yarn install --frozen-lockfile";
|
||
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
|
||
return "bun install --frozen-lockfile";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function syncDependenciesForMerge(
|
||
store: TaskStore,
|
||
rootDir: string,
|
||
taskId: string,
|
||
): Promise<void> {
|
||
const installCommand = getDependencySyncCommand(rootDir);
|
||
if (!installCommand) return;
|
||
|
||
mergerLog.log(`${taskId}: syncing dependencies before merge build verification`);
|
||
await store.logEntry(taskId, `Syncing dependencies before merge build verification: ${installCommand}`);
|
||
try {
|
||
await execAsync(installCommand, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
timeout: 300_000,
|
||
});
|
||
} catch (error: any) {
|
||
const details = error?.stderr || error?.stdout || error?.message || String(error);
|
||
throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim());
|
||
}
|
||
}
|
||
|
||
// ── Default test command inference ────────────────────────────────────
|
||
|
||
/** Result of inferring a default test command */
|
||
interface InferredTestCommand {
|
||
command: string;
|
||
/** Source indicates whether this was explicitly configured or inferred from project files */
|
||
testSource: "explicit" | "inferred";
|
||
buildSource?: "explicit" | "inferred";
|
||
}
|
||
|
||
/**
|
||
* Infer a default test command based on project files.
|
||
* Returns the command and whether it was explicitly configured or inferred.
|
||
*
|
||
* Inference rules:
|
||
* - pnpm-lock.yaml → "pnpm test"
|
||
* - yarn.lock → "yarn test"
|
||
* - bun.lock/bun.lockb → "bun test"
|
||
* - package-lock.json → "npm test"
|
||
*
|
||
* Returns null if no test command can be inferred.
|
||
*/
|
||
export function inferDefaultTestCommand(
|
||
rootDir: string,
|
||
explicitTestCommand?: string,
|
||
explicitBuildCommand?: string,
|
||
): InferredTestCommand | null {
|
||
// If explicit test command is set, use it (no inference needed)
|
||
if (explicitTestCommand?.trim()) {
|
||
return {
|
||
command: explicitTestCommand.trim(),
|
||
testSource: "explicit",
|
||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
||
};
|
||
}
|
||
|
||
// Infer test command from lock files
|
||
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) {
|
||
return {
|
||
command: "pnpm test",
|
||
testSource: "inferred",
|
||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
||
};
|
||
}
|
||
|
||
if (existsSync(join(rootDir, "yarn.lock"))) {
|
||
return {
|
||
command: "yarn test",
|
||
testSource: "inferred",
|
||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
||
};
|
||
}
|
||
|
||
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
|
||
return {
|
||
command: "bun test",
|
||
testSource: "inferred",
|
||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
||
};
|
||
}
|
||
|
||
if (existsSync(join(rootDir, "package-lock.json"))) {
|
||
return {
|
||
command: "npm test",
|
||
testSource: "inferred",
|
||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
||
};
|
||
}
|
||
|
||
// No inference possible — return null, letting the caller decide what to do
|
||
return null;
|
||
}
|
||
|
||
// ── Deterministic merge verification ──────────────────────────────────
|
||
|
||
/** Result of running a single verification command */
|
||
export interface VerificationCommandResult {
|
||
command: string;
|
||
exitCode: number | null;
|
||
stdout: string;
|
||
stderr: string;
|
||
success: boolean;
|
||
}
|
||
|
||
/** Result of running all verification commands */
|
||
export interface VerificationResult {
|
||
testResult?: VerificationCommandResult;
|
||
buildResult?: VerificationCommandResult;
|
||
allPassed: boolean;
|
||
failedCommand?: string;
|
||
}
|
||
|
||
/**
|
||
* Run verification commands deterministically in the engine.
|
||
* Executes testCommand first, then buildCommand (when both are configured).
|
||
* Returns structured results so failures are logged with actionable detail.
|
||
* Throws VerificationError on failure with command details.
|
||
*/
|
||
export class VerificationError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public readonly verificationResult: VerificationResult,
|
||
) {
|
||
super(message);
|
||
this.name = "VerificationError";
|
||
}
|
||
}
|
||
|
||
async function runDeterministicVerification(
|
||
store: TaskStore,
|
||
rootDir: string,
|
||
taskId: string,
|
||
testCommand?: string,
|
||
buildCommand?: string,
|
||
testSource?: "explicit" | "inferred",
|
||
buildSource?: "explicit" | "inferred",
|
||
): Promise<VerificationResult> {
|
||
const result: VerificationResult = { allPassed: true };
|
||
|
||
// Nothing to verify
|
||
if (!testCommand && !buildCommand) {
|
||
mergerLog.log(`${taskId}: no verification commands configured — skipping`);
|
||
return result;
|
||
}
|
||
|
||
const normalizedTestCommand = testCommand?.trim();
|
||
const normalizedBuildCommand = buildCommand?.trim();
|
||
const hasTestCommand = !!normalizedTestCommand;
|
||
const hasBuildCommand = !!normalizedBuildCommand;
|
||
|
||
// Build source indicator for logging
|
||
const testSourceLabel = testSource === "inferred" ? " [inferred]" : "";
|
||
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
|
||
|
||
mergerLog.log(
|
||
`${taskId}: running deterministic verification` +
|
||
(hasTestCommand ? ` [test:${testSourceLabel} ${normalizedTestCommand}]` : "") +
|
||
(hasBuildCommand ? ` [build:${buildSourceLabel} ${normalizedBuildCommand}]` : ""),
|
||
);
|
||
await store.logEntry(
|
||
taskId,
|
||
"Running deterministic merge verification" +
|
||
(hasTestCommand ? ` (test${testSource === "inferred" ? " [inferred]" : ""}: ${normalizedTestCommand})` : "") +
|
||
(hasBuildCommand ? ` (build${buildSource === "inferred" ? " [inferred]" : ""}: ${normalizedBuildCommand})` : ""),
|
||
);
|
||
|
||
// Run test command first if configured
|
||
if (hasTestCommand) {
|
||
const testResult = await runVerificationCommand(
|
||
store, rootDir, taskId, normalizedTestCommand!, "test",
|
||
);
|
||
result.testResult = testResult;
|
||
|
||
if (!testResult.success) {
|
||
result.allPassed = false;
|
||
result.failedCommand = "testCommand";
|
||
await store.logEntry(
|
||
taskId,
|
||
`Deterministic test verification failed (exit ${testResult.exitCode}) — see prior [verification] entry for truncated output`,
|
||
"VerificationError",
|
||
);
|
||
throw new VerificationError(
|
||
`Deterministic test verification failed for ${taskId}`,
|
||
result,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Run build command second if configured
|
||
if (hasBuildCommand) {
|
||
const buildResult = await runVerificationCommand(
|
||
store, rootDir, taskId, normalizedBuildCommand!, "build",
|
||
);
|
||
result.buildResult = buildResult;
|
||
|
||
if (!buildResult.success) {
|
||
result.allPassed = false;
|
||
result.failedCommand = "buildCommand";
|
||
await store.logEntry(
|
||
taskId,
|
||
`Deterministic build verification failed (exit ${buildResult.exitCode}) — see prior [verification] entry for truncated output`,
|
||
"VerificationError",
|
||
);
|
||
throw new VerificationError(
|
||
`Deterministic build verification failed for ${taskId}`,
|
||
result,
|
||
);
|
||
}
|
||
}
|
||
|
||
mergerLog.log(`${taskId}: deterministic verification passed`);
|
||
await store.logEntry(taskId, "Deterministic merge verification passed");
|
||
return result;
|
||
}
|
||
|
||
async function runVerificationCommand(
|
||
store: TaskStore,
|
||
rootDir: string,
|
||
taskId: string,
|
||
command: string,
|
||
type: "test" | "build",
|
||
): Promise<VerificationCommandResult> {
|
||
mergerLog.log(`${taskId}: running ${type} command: ${command}`);
|
||
await store.logEntry(taskId, `[verification] Running ${type} command: ${command}`);
|
||
|
||
const result: VerificationCommandResult = {
|
||
command,
|
||
exitCode: null,
|
||
stdout: "",
|
||
stderr: "",
|
||
success: false,
|
||
};
|
||
|
||
try {
|
||
const { stdout, stderr } = await execAsync(command, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
timeout: 300_000,
|
||
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
|
||
});
|
||
|
||
result.stdout = stdout?.toString?.() || "";
|
||
result.stderr = stderr?.toString?.() || "";
|
||
result.exitCode = 0;
|
||
result.success = true;
|
||
|
||
mergerLog.log(`${taskId}: ${type} command succeeded`);
|
||
await store.logEntry(taskId, `[verification] ${type} command succeeded (exit 0)`);
|
||
return result;
|
||
} catch (error: any) {
|
||
result.stdout = error?.stdout?.toString?.() || "";
|
||
result.stderr = error?.stderr?.toString?.() || "";
|
||
result.exitCode = typeof error?.status === "number"
|
||
? error.status
|
||
: (typeof error?.code === "number" ? error.code : null);
|
||
|
||
const maxBufferExceeded = error?.code === "ENOBUFS"
|
||
|| error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||
|| String(error?.message ?? "").includes("maxBuffer");
|
||
result.success = maxBufferExceeded && result.exitCode === 0;
|
||
|
||
if (result.success) {
|
||
mergerLog.log(`${taskId}: ${type} command succeeded (exit 0, output exceeded buffer)`);
|
||
await store.logEntry(
|
||
taskId,
|
||
`[verification] ${type} command succeeded (exit 0, output exceeded buffer)`,
|
||
);
|
||
return result;
|
||
}
|
||
|
||
// Keep command output out of process logs. The bounded excerpt is stored on
|
||
// the task for diagnostics without dumping test output to the engine stdout.
|
||
const output = result.stderr || result.stdout || error?.message || "Unknown error";
|
||
const summary = summarizeVerificationOutput(output, type);
|
||
mergerLog.error(`${taskId}: ${type} command failed (exit ${result.exitCode}); output captured in task log`);
|
||
await store.logEntry(
|
||
taskId,
|
||
`[verification] ${type} command failed (exit ${result.exitCode}):\n${summary}`,
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Attempt an in-merge verification fix by spawning an AI agent on the main branch.
|
||
* Returns true if verification passes after the fix, false otherwise.
|
||
* Never throws — errors are caught and logged, and the function returns false.
|
||
*/
|
||
async function attemptInMergeVerificationFix(
|
||
store: TaskStore,
|
||
rootDir: string,
|
||
taskId: string,
|
||
failureContext: {
|
||
command: string;
|
||
exitCode: number | null;
|
||
output: string;
|
||
type: "test" | "build";
|
||
},
|
||
settings: Settings,
|
||
options: MergerOptions,
|
||
_testCommand?: string,
|
||
_buildCommand?: string,
|
||
): Promise<boolean> {
|
||
try {
|
||
mergerLog.log(`${taskId}: spawning in-merge verification fix agent`);
|
||
|
||
// Build skill selection context
|
||
let skillContext = undefined;
|
||
if (options.agentStore) {
|
||
try {
|
||
const task = await store.getTask(taskId);
|
||
skillContext = await buildSessionSkillContext({
|
||
agentStore: options.agentStore,
|
||
task,
|
||
sessionPurpose: "merger",
|
||
projectRootDir: rootDir,
|
||
});
|
||
} catch {
|
||
// Graceful fallback - no skill selection
|
||
}
|
||
}
|
||
|
||
// Create the fix agent session
|
||
const { session } = await createKbAgent({
|
||
cwd: rootDir, // Runs on the main branch in the project root
|
||
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
|
||
|
||
A merge has been applied and the verification command failed. Your job is to fix the failing code directly in the working directory.
|
||
|
||
## Rules
|
||
1. Read the error output carefully to understand what's failing
|
||
2. Make targeted fixes to the failing code
|
||
3. After fixing, run the verification command to confirm the fix works
|
||
4. Do NOT make any git commits — just fix the code
|
||
5. Do NOT modify files unrelated to the failure
|
||
6. If you cannot fix the issue, explain why`,
|
||
tools: "coding", // Agent needs read/write file access
|
||
defaultProvider: settings.defaultProvider,
|
||
defaultModelId: settings.defaultModelId,
|
||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||
});
|
||
|
||
try {
|
||
// Build the fix prompt
|
||
const fixPrompt = `Fix the failing ${failureContext.type} verification for task ${taskId}.
|
||
|
||
## Failed command
|
||
Command: \`${failureContext.command}\`
|
||
Exit code: ${failureContext.exitCode}
|
||
|
||
## Error output
|
||
${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
||
|
||
## Instructions
|
||
1. Read the error output and identify the root cause
|
||
2. Make targeted fixes to resolve the failure
|
||
3. Run the verification command \`${failureContext.command}\` to confirm your fix works
|
||
4. If the fix doesn't work, try a different approach
|
||
5. Do NOT make any git commits`;
|
||
|
||
// Run the agent with rate limit retry
|
||
await withRateLimitRetry(async () => {
|
||
await promptWithFallback(session, fixPrompt);
|
||
}, {
|
||
onRetry: (attempt, delayMs, error) => {
|
||
const delaySec = Math.round(delayMs / 1000);
|
||
mergerLog.warn(`⏳ ${taskId} in-merge fix rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||
},
|
||
});
|
||
|
||
// Re-run the verification command that failed
|
||
const reRunResult = await runVerificationCommand(
|
||
store, rootDir, taskId, failureContext.command, failureContext.type,
|
||
);
|
||
|
||
return reRunResult.success;
|
||
} finally {
|
||
// Always dispose the session
|
||
await session.dispose();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||
mergerLog.warn(`${taskId}: in-merge fix agent error: ${errorMessage}`);
|
||
await store.logEntry(taskId, "In-merge verification fix agent encountered an error", errorMessage);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stage any changes and amend the merge commit to include verification fixes.
|
||
* Returns true if changes were amended, false if no changes to amend.
|
||
* Never throws — errors are logged and the function returns false.
|
||
*/
|
||
async function amendMergeCommitWithFixes(
|
||
rootDir: string,
|
||
taskId: string,
|
||
authorArg: string,
|
||
): Promise<boolean> {
|
||
try {
|
||
// Check for staged and unstaged changes
|
||
const { stdout: stagedFiles } = await execAsync("git diff --cached --name-only", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
const { stdout: unstagedFiles } = await execAsync("git diff --name-only", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
|
||
const hasChanges = stagedFiles.trim().length > 0 || unstagedFiles.trim().length > 0;
|
||
if (!hasChanges) {
|
||
mergerLog.log(`${taskId}: no changes to amend after verification fix`);
|
||
return false;
|
||
}
|
||
|
||
// Stage any unstaged changes
|
||
if (unstagedFiles.trim().length > 0) {
|
||
await execAsync("git add -A", { cwd: rootDir });
|
||
}
|
||
|
||
// Check if there are staged changes to amend
|
||
const { stdout: finalStaged } = await execAsync("git diff --cached --name-only", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
|
||
if (finalStaged.trim().length > 0) {
|
||
await execAsync(`git commit --amend --no-edit${authorArg}`, { cwd: rootDir });
|
||
mergerLog.log(`${taskId}: amended merge commit with verification fixes`);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (err: unknown) {
|
||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||
mergerLog.warn(`${taskId}: failed to amend merge commit: ${errorMessage}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Pre-merge diffstat scope validation ──────────────────────────────
|
||
|
||
interface DiffFileEntry {
|
||
file: string;
|
||
insertions: number;
|
||
deletions: number;
|
||
}
|
||
|
||
interface DiffScopeResult {
|
||
warnings: string[];
|
||
outOfScopeFiles: string[];
|
||
largeOutOfScopeDeletions: { file: string; deletions: number }[];
|
||
}
|
||
|
||
/**
|
||
* Parse git `--stat` output into per-file insertion/deletion counts.
|
||
*
|
||
* Example line: ` packages/core/src/types.ts | 9 ++--`
|
||
* Binary line: ` some/image.png | Bin 0 -> 1234 bytes`
|
||
*/
|
||
export function parseDiffStat(diffStat: string): DiffFileEntry[] {
|
||
const entries: DiffFileEntry[] = [];
|
||
for (const line of diffStat.split("\n")) {
|
||
// Skip the summary line ("5 files changed, 10 insertions(+), 3 deletions(-)")
|
||
if (line.includes("files changed") || line.includes("file changed")) continue;
|
||
// Match: " path/to/file | 42 +++---" or " path/to/file | Bin ..."
|
||
const match = line.match(/^\s*(.+?)\s+\|\s+(\d+)\s+(\+*)(-*)\s*$/);
|
||
if (!match) continue;
|
||
const file = match[1].trim();
|
||
const plusses = match[3].length;
|
||
const minuses = match[4].length;
|
||
// The number is total changes; +/- chars show the ratio
|
||
const total = parseInt(match[2], 10);
|
||
if (total === 0) continue;
|
||
const ratio = plusses + minuses > 0 ? plusses / (plusses + minuses) : 0.5;
|
||
entries.push({
|
||
file,
|
||
insertions: Math.round(total * ratio),
|
||
deletions: Math.round(total * (1 - ratio)),
|
||
});
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* Extract the `## File Scope` section from a PROMPT.md string.
|
||
* Returns an array of file/glob patterns (lines starting with `- \``).
|
||
*/
|
||
export function extractFileScope(promptContent: string): string[] {
|
||
const lines = promptContent.split("\n");
|
||
const patterns: string[] = [];
|
||
let inScope = false;
|
||
for (const line of lines) {
|
||
if (/^##\s+File Scope/.test(line)) {
|
||
inScope = true;
|
||
continue;
|
||
}
|
||
if (inScope && /^##\s/.test(line)) break; // next section
|
||
if (inScope) {
|
||
// Match "- `path/to/file`" or "- path/to/file"
|
||
const m = line.match(/^-\s+`?([^`\s]+)`?\s*(?:\(.*\))?\s*$/);
|
||
if (m) patterns.push(m[1]);
|
||
}
|
||
}
|
||
return patterns;
|
||
}
|
||
|
||
/**
|
||
* Check whether a file path matches any of the declared scope patterns.
|
||
* Reuses the existing `matchGlob` helper. Also matches if the file is
|
||
* inside a directory that's in scope (e.g., scope has `src/utils/*` and
|
||
* file is `src/utils/helpers.ts`).
|
||
*/
|
||
function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
||
for (const pattern of scopePatterns) {
|
||
if (matchGlob(filePath, pattern)) return true;
|
||
// Directory match: if pattern ends with /* or /**, check prefix
|
||
const dirPattern = pattern.replace(/\/\*+$/, "");
|
||
if (dirPattern !== pattern && filePath.startsWith(dirPattern + "/")) return true;
|
||
// Exact directory match: scope says `src/foo/` and file is inside it
|
||
if (pattern.endsWith("/") && filePath.startsWith(pattern)) return true;
|
||
// Also match if both share the same directory
|
||
const patternDir = pattern.lastIndexOf("/") >= 0 ? pattern.slice(0, pattern.lastIndexOf("/")) : "";
|
||
const fileDir = filePath.lastIndexOf("/") >= 0 ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
|
||
if (patternDir && fileDir === patternDir) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Validate that the diff stays within the task's declared File Scope.
|
||
* Returns warnings for out-of-scope changes, especially large deletions.
|
||
*
|
||
* When `strict` is true, throws an error on scope violations instead of
|
||
* just returning warnings (hard guardrail that blocks merge).
|
||
*/
|
||
export async function validateDiffScope(
|
||
store: TaskStore,
|
||
taskId: string,
|
||
diffStat: string,
|
||
strict: boolean = false,
|
||
): Promise<DiffScopeResult> {
|
||
const result: DiffScopeResult = { warnings: [], outOfScopeFiles: [], largeOutOfScopeDeletions: [] };
|
||
|
||
// Parse the diffstat
|
||
const entries = parseDiffStat(diffStat);
|
||
if (entries.length === 0) return result;
|
||
|
||
// Read the task's PROMPT.md for file scope
|
||
let promptContent = "";
|
||
try {
|
||
const task = await store.getTask(taskId);
|
||
promptContent = task.prompt || "";
|
||
} catch {
|
||
return result; // can't validate without prompt
|
||
}
|
||
|
||
const scopePatterns = extractFileScope(promptContent);
|
||
if (scopePatterns.length === 0) return result; // no scope declared, skip
|
||
|
||
// Check each changed file
|
||
for (const entry of entries) {
|
||
// Skip changeset files — always allowed
|
||
if (entry.file.startsWith(".changeset/")) continue;
|
||
|
||
if (!matchesScope(entry.file, scopePatterns)) {
|
||
result.outOfScopeFiles.push(entry.file);
|
||
|
||
// Flag large deletions outside scope (>50 net deletions or 100% deletions)
|
||
const netDeletions = entry.deletions - entry.insertions;
|
||
if (netDeletions > 50 || (entry.deletions > 0 && entry.insertions === 0)) {
|
||
result.largeOutOfScopeDeletions.push({ file: entry.file, deletions: entry.deletions });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build warnings
|
||
if (result.largeOutOfScopeDeletions.length > 0) {
|
||
const files = result.largeOutOfScopeDeletions
|
||
.map((d) => `${d.file} (${d.deletions} deletions)`)
|
||
.join(", ");
|
||
result.warnings.push(
|
||
`⚠ SCOPE WARNING: Large deletions outside File Scope: ${files}`,
|
||
);
|
||
} else if (result.outOfScopeFiles.length > 3) {
|
||
result.warnings.push(
|
||
`⚠ SCOPE WARNING: ${result.outOfScopeFiles.length} files changed outside declared File Scope`,
|
||
);
|
||
}
|
||
|
||
// In strict mode, scope violations block the merge
|
||
if (strict && result.warnings.length > 0) {
|
||
throw new Error(
|
||
`Scope enforcement failed for ${taskId}: ${result.warnings.join("; ")}`,
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Get list of conflicted files from git.
|
||
* Runs `git diff --name-only --diff-filter=U` and returns array of file paths.
|
||
*/
|
||
export async function getConflictedFiles(cwd: string): Promise<string[]> {
|
||
try {
|
||
const { stdout } = await execAsync("git diff --name-only --diff-filter=U", {
|
||
cwd,
|
||
encoding: "utf-8",
|
||
});
|
||
const output = stdout.trim();
|
||
|
||
if (!output) return [];
|
||
return output.split("\n").filter(Boolean);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a file has only trivial whitespace conflicts using git.
|
||
* Compares ours (:2) and theirs (:3) versions with whitespace ignored.
|
||
*/
|
||
export async function isTrivialWhitespaceConflict(filePath: string, cwd: string): Promise<boolean> {
|
||
try {
|
||
// Use git diff-tree to compare index entries with whitespace ignored
|
||
// :2 = ours (current branch), :3 = theirs (incoming branch)
|
||
// -w flag ignores whitespace
|
||
const { stdout } = await execAsync(
|
||
`git diff-tree -p -w -- :2:"${filePath}" :3:"${filePath}"`,
|
||
{ cwd, encoding: "utf-8" }
|
||
);
|
||
|
||
// If the diff output is empty or contains no actual changes, it's trivial
|
||
// The diff output will have headers but no +/- content lines for whitespace-only changes
|
||
const lines = stdout.split("\n");
|
||
const contentChanges = lines.filter(
|
||
(line: string) => (line.startsWith("+") || line.startsWith("-")) &&
|
||
!line.startsWith("+++") && !line.startsWith("---")
|
||
);
|
||
return contentChanges.length === 0;
|
||
} catch (error: any) {
|
||
// git diff-tree may exit with code 1 when there are differences
|
||
// Check if the error output indicates substantive changes
|
||
if (error.stdout && typeof error.stdout === "string") {
|
||
const lines = error.stdout.split("\n");
|
||
const contentChanges = lines.filter(
|
||
(line: string) => (line.startsWith("+") || line.startsWith("-")) &&
|
||
!line.startsWith("+++") && !line.startsWith("---")
|
||
);
|
||
return contentChanges.length === 0;
|
||
}
|
||
// On other errors, assume complex conflict (don't fallback to isTrivialConflict
|
||
// which reads working directory files with conflict markers)
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Classify a single conflicted file for auto-resolution.
|
||
* Returns one of: 'lockfile-ours', 'generated-theirs', 'trivial-whitespace', 'complex'
|
||
*/
|
||
export async function classifyConflict(filePath: string, cwd: string): Promise<ConflictType> {
|
||
// Check for lock files - always take "ours" (current branch's version)
|
||
if (LOCKFILE_PATTERNS.some((pattern) => matchGlob(filePath, pattern))) {
|
||
return "lockfile-ours";
|
||
}
|
||
|
||
// Check for generated files - take "theirs" (keep branch's fresh generation)
|
||
if (GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern))) {
|
||
return "generated-theirs";
|
||
}
|
||
|
||
// Check for trivial conflicts (whitespace-only)
|
||
if (await isTrivialWhitespaceConflict(filePath, cwd)) {
|
||
return "trivial-whitespace";
|
||
}
|
||
|
||
// Complex conflicts require AI intervention
|
||
return "complex";
|
||
}
|
||
|
||
/**
|
||
* Resolve a conflicted file using "ours" (current branch's version).
|
||
* Runs `git checkout --ours` and `git add`.
|
||
*/
|
||
export async function resolveWithOurs(filePath: string, cwd: string): Promise<void> {
|
||
try {
|
||
await execAsync(`git checkout --ours "${filePath}"`, { cwd });
|
||
await execAsync(`git add "${filePath}"`, { cwd });
|
||
mergerLog.log(`Auto-resolved ${filePath} using --ours`);
|
||
} catch (error) {
|
||
throw new Error(`Failed to auto-resolve ${filePath} with ours: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve a conflicted file using "theirs" (incoming branch's version).
|
||
* Runs `git checkout --theirs` and `git add`.
|
||
*/
|
||
export async function resolveWithTheirs(filePath: string, cwd: string): Promise<void> {
|
||
try {
|
||
await execAsync(`git checkout --theirs "${filePath}"`, { cwd });
|
||
await execAsync(`git add "${filePath}"`, { cwd });
|
||
mergerLog.log(`Auto-resolved ${filePath} using --theirs`);
|
||
} catch (error) {
|
||
throw new Error(`Failed to auto-resolve ${filePath} with theirs: ${error}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve a trivial whitespace conflict.
|
||
* For trivial conflicts, we can just stage the file (git considers it resolved).
|
||
*/
|
||
export async function resolveTrivialWhitespace(filePath: string, cwd: string): Promise<void> {
|
||
try {
|
||
await execAsync(`git add "${filePath}"`, { cwd });
|
||
mergerLog.log(`Auto-resolved ${filePath} (trivial whitespace)`);
|
||
} catch (error) {
|
||
throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
|
||
}
|
||
}
|
||
|
||
// Legacy types re-exported for backward compatibility (tests may reference them)
|
||
/** @deprecated Use ConflictType instead */
|
||
export type ConflictResolution = "ours" | "theirs";
|
||
|
||
/** @deprecated Use classifyConflict + getConflictedFiles instead */
|
||
export interface ConflictCategory {
|
||
filePath: string;
|
||
autoResolvable: boolean;
|
||
strategy?: ConflictResolution;
|
||
reason: "lock-file" | "generated-file" | "trivial" | "complex";
|
||
}
|
||
|
||
/**
|
||
* Detect and categorize merge conflicts. Delegates to the new classifyConflict API.
|
||
* @deprecated Use getConflictedFiles() + classifyConflict() instead.
|
||
*/
|
||
export async function detectResolvableConflicts(rootDir: string): Promise<ConflictCategory[]> {
|
||
const files = await getConflictedFiles(rootDir);
|
||
const results: ConflictCategory[] = [];
|
||
for (const filePath of files) {
|
||
const type = await classifyConflict(filePath, rootDir);
|
||
switch (type) {
|
||
case "lockfile-ours":
|
||
results.push({ filePath, autoResolvable: true, strategy: "ours", reason: "lock-file" });
|
||
break;
|
||
case "generated-theirs":
|
||
results.push({ filePath, autoResolvable: true, strategy: "theirs", reason: "generated-file" });
|
||
break;
|
||
case "trivial-whitespace":
|
||
results.push({ filePath, autoResolvable: true, strategy: "ours", reason: "trivial" });
|
||
break;
|
||
case "complex":
|
||
results.push({ filePath, autoResolvable: false, reason: "complex" });
|
||
break;
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Auto-resolve a single file using git checkout --ours or --theirs.
|
||
* @deprecated Use resolveWithOurs() or resolveWithTheirs() instead.
|
||
*/
|
||
export async function autoResolveFile(
|
||
filePath: string,
|
||
resolution: ConflictResolution,
|
||
rootDir: string,
|
||
): Promise<void> {
|
||
if (resolution === "ours") {
|
||
await resolveWithOurs(filePath, rootDir);
|
||
} else {
|
||
await resolveWithTheirs(filePath, rootDir);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auto-resolve all resolvable conflicts from the categorization.
|
||
* @deprecated Use classifyConflict + resolveWithOurs/resolveWithTheirs instead.
|
||
*/
|
||
export async function resolveConflicts(
|
||
categories: ConflictCategory[],
|
||
rootDir: string,
|
||
): Promise<string[]> {
|
||
const remainingComplex: string[] = [];
|
||
for (const category of categories) {
|
||
if (category.autoResolvable && category.strategy) {
|
||
await autoResolveFile(category.filePath, category.strategy, rootDir);
|
||
} else {
|
||
remainingComplex.push(category.filePath);
|
||
}
|
||
}
|
||
return remainingComplex;
|
||
}
|
||
|
||
/** Build the --author flag for git commits based on project settings. */
|
||
function getCommitAuthorArg(settings: {
|
||
commitAuthorEnabled?: boolean;
|
||
commitAuthorName?: string;
|
||
commitAuthorEmail?: string;
|
||
}): string {
|
||
if (settings.commitAuthorEnabled === false) return "";
|
||
const name = settings.commitAuthorName || "Fusion";
|
||
const email = settings.commitAuthorEmail || "noreply@runfusion.ai";
|
||
return ` --author="${name} <${email}>"`;
|
||
}
|
||
|
||
/**
|
||
* Build the merge system prompt. When `includeTaskId` is true (default),
|
||
* the commit format uses `<type>(<scope>): <summary>` where scope is the
|
||
* task ID. When false, it uses `<type>: <summary>` with no scope.
|
||
*/
|
||
function buildMergeSystemPrompt(includeTaskId: boolean, agentPrompts?: AgentPromptsConfig, authorArg?: string): string {
|
||
const commitFormat = includeTaskId
|
||
? `\`\`\`
|
||
git commit -m "<type>(<scope>): <summary>" -m "<body>"${authorArg || ""}
|
||
\`\`\`
|
||
|
||
Message format:
|
||
- **Type:** feat, fix, refactor, docs, test, chore
|
||
- **Scope:** the task ID (e.g., KB-001)
|
||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||
${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
|
||
|
||
Example:
|
||
\`\`\`
|
||
git commit -m "feat(KB-003): add user profile page" -m "- Add /profile route with avatar upload
|
||
- Create ProfileCard and EditProfileForm components
|
||
- Add profile image resizing via sharp
|
||
- Update nav bar with profile link
|
||
- Add profile e2e tests"${authorArg || ""}
|
||
\`\`\``
|
||
: `\`\`\`
|
||
git commit -m "<type>: <summary>" -m "<body>"${authorArg || ""}
|
||
\`\`\`
|
||
|
||
Message format:
|
||
- **Type:** feat, fix, refactor, docs, test, chore
|
||
- **Summary:** one line describing what the squash brings in (imperative mood)
|
||
- **Body:** 2-5 bullet points summarizing the key changes, each starting with "- "
|
||
${authorArg ? `- **Author:** Always include the --author flag as shown in the example above.` : ""}
|
||
Do NOT include a scope in the commit message type.
|
||
|
||
Example:
|
||
\`\`\`
|
||
git commit -m "feat: add user profile page" -m "- Add /profile route with avatar upload
|
||
- Create ProfileCard and EditProfileForm components
|
||
- Add profile image resizing via sharp
|
||
- Update nav bar with profile link
|
||
- Add profile e2e tests"${authorArg || ""}
|
||
\`\`\``;
|
||
|
||
// Resolve the base merger prompt from agent prompts config, falling back to the inline default
|
||
const basePrompt = resolveAgentPrompt("merger", agentPrompts);
|
||
|
||
// If a custom merger prompt is configured, use it as the base with commit format appended
|
||
const customAssignment = agentPrompts?.roleAssignments?.merger;
|
||
if (customAssignment && basePrompt) {
|
||
return `${basePrompt}
|
||
|
||
## Commit message
|
||
After all conflicts are resolved (or if there were none), write and execute the squash commit.
|
||
|
||
Look at the branch commits and diff to understand what was done, then run:
|
||
${commitFormat}
|
||
|
||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||
Base the message on the ACTUAL work done in the branch commits.
|
||
|
||
## Build verification
|
||
|
||
If a build command is configured for this project, build verification is a hard gate.
|
||
You MUST run the exact configured build command in this worktree before committing.
|
||
Do not assume the build passes. Do not describe it as passing unless you actually ran it
|
||
and the bash tool returned exit code 0.
|
||
|
||
1. Run the build command (shown in the prompt context below)
|
||
2. If the build succeeds (exit code 0), proceed with the commit
|
||
3. If the build fails (non-zero exit code), DO NOT commit. Instead:
|
||
- Call the \`report_build_failure\` tool with the real error details
|
||
- Stop immediately and do not run \`git commit\`
|
||
- Do not claim success in plain text
|
||
|
||
The merge will only be completed if the build passes or no build command is configured.`;
|
||
}
|
||
|
||
return `You are a merge agent for "fn", an AI-orchestrated task board.
|
||
|
||
Your job is to finalize a squash merge: resolve any conflicts and write a good commit message.
|
||
All changes from the branch are squashed into a single commit.
|
||
|
||
## Conflict resolution
|
||
If there are merge conflicts:
|
||
1. Run \`git diff --name-only --diff-filter=U\` to list conflicted files
|
||
2. Read each conflicted file — look for the <<<<<<< / ======= / >>>>>>> markers
|
||
3. Understand the intent of BOTH sides, then edit the file to produce the correct merged result
|
||
4. Remove ALL conflict markers — the result must be clean, compilable code
|
||
5. Run \`git add <file>\` for each resolved file
|
||
6. Do NOT change anything beyond what's needed to resolve the conflict
|
||
|
||
## Commit message
|
||
After all conflicts are resolved (or if there were none), write and execute the squash commit.
|
||
|
||
Look at the branch commits and diff to understand what was done, then run:
|
||
${commitFormat}
|
||
|
||
Do NOT use generic messages like "merge branch" or "resolve conflicts".
|
||
Base the message on the ACTUAL work done in the branch commits.
|
||
|
||
## Build verification
|
||
|
||
If a build command is configured for this project, build verification is a hard gate.
|
||
You MUST run the exact configured build command in this worktree before committing.
|
||
Do not assume the build passes. Do not describe it as passing unless you actually ran it
|
||
and the bash tool returned exit code 0.
|
||
|
||
1. Run the build command (shown in the prompt context below)
|
||
2. If the build succeeds (exit code 0), proceed with the commit
|
||
3. If the build fails (non-zero exit code), DO NOT commit. Instead:
|
||
- Call the \`report_build_failure\` tool with the real error details
|
||
- Stop immediately and do not run \`git commit\`
|
||
- Do not claim success in plain text
|
||
|
||
The merge will only be completed if the build passes or no build command is configured.`;
|
||
}
|
||
|
||
/**
|
||
* Check if any non-done task (other than `excludeTaskId`) references the given
|
||
* worktree path. Returns the first matching task ID, or null if the worktree
|
||
* is safe to remove. Used by both the merger and executor cleanup to avoid
|
||
* deleting worktrees that are shared across dependent tasks.
|
||
*/
|
||
export async function findWorktreeUser(
|
||
store: TaskStore,
|
||
worktreePath: string,
|
||
excludeTaskId: string,
|
||
): Promise<string | null> {
|
||
const tasks = await store.listTasks({ slim: true, includeArchived: false });
|
||
for (const t of tasks) {
|
||
if (t.id === excludeTaskId) continue;
|
||
if (t.worktree === worktreePath && t.column !== "done") {
|
||
return t.id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export interface MergerOptions {
|
||
/** Called with agent text output */
|
||
onAgentText?: (delta: string) => void;
|
||
/** Called with agent tool usage */
|
||
onAgentTool?: (toolName: string) => void;
|
||
/** Worktree pool — when provided and `recycleWorktrees` is enabled,
|
||
* worktrees are released to the pool instead of being removed. */
|
||
pool?: WorktreePool;
|
||
/** Usage limit pauser — triggers global pause when API limits are detected. */
|
||
usageLimitPauser?: UsageLimitPauser;
|
||
/** Called with the agent session immediately after creation. Enables the
|
||
* caller (e.g. dashboard.ts) to track and externally dispose the session
|
||
* when a global pause is triggered. */
|
||
onSession?: (session: { dispose: () => void }) => void;
|
||
/** AgentStore for resolving per-agent custom instructions. */
|
||
agentStore?: import("@fusion/core").AgentStore;
|
||
}
|
||
|
||
/**
|
||
* AI-powered merge with 3-attempt retry logic when autoResolveConflicts is enabled.
|
||
*
|
||
* Attempt 1: Standard merge + AI agent with full context
|
||
* Attempt 2 (if enabled and Attempt 1 failed): Auto-resolve lock/generated files, retry AI
|
||
* Attempt 3 (if enabled and Attempt 2 failed): Reset and use git merge -X theirs --squash
|
||
*
|
||
* When `options.pool` is provided and `recycleWorktrees` is enabled in
|
||
* settings, the worktree is detached from its branch and released to the
|
||
* idle pool instead of being removed. The task's branch is always deleted
|
||
* regardless of pooling. On next task execution, the pooled worktree will
|
||
* be acquired and prepared with a fresh branch via {@link WorktreePool.prepareForTask}.
|
||
*/
|
||
export async function aiMergeTask(
|
||
store: TaskStore,
|
||
rootDir: string,
|
||
taskId: string,
|
||
options: MergerOptions = {},
|
||
): Promise<MergeResult> {
|
||
// 1. Validate task state
|
||
const task = await store.getTask(taskId);
|
||
const mergeBlocker = getTaskMergeBlocker(task);
|
||
if (mergeBlocker) {
|
||
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
|
||
}
|
||
|
||
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
|
||
const worktreePath = task.worktree;
|
||
const result: MergeResult = {
|
||
task,
|
||
branch,
|
||
merged: false,
|
||
worktreeRemoved: false,
|
||
branchDeleted: false,
|
||
};
|
||
|
||
// Build merge-run context for audit instrumentation (FN-1404)
|
||
const mergeRunId = generateSyntheticRunId("merge", taskId);
|
||
const engineRunContext: EngineRunContext = {
|
||
runId: mergeRunId,
|
||
agentId: "merger",
|
||
taskId,
|
||
phase: "merge",
|
||
};
|
||
|
||
// Create run auditor for TaskStore-backed audit emission (no-ops if store doesn't support it)
|
||
const audit = createRunAuditor(store, engineRunContext);
|
||
|
||
if (!worktreePath) {
|
||
mergerLog.warn(`${taskId}: no worktree path set — skipping worktree cleanup`);
|
||
}
|
||
|
||
// 2. Read settings
|
||
const settings = await store.getSettings();
|
||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
||
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
||
|
||
// 3. Check branch exists
|
||
try {
|
||
execSync(`git rev-parse --verify "${branch}"`, {
|
||
cwd: rootDir,
|
||
stdio: "pipe",
|
||
});
|
||
} catch {
|
||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||
// Best-effort: try to capture current HEAD commitSha even though branch is missing
|
||
try {
|
||
const commitSha = execSync("git rev-parse HEAD", {
|
||
cwd: rootDir,
|
||
stdio: "pipe",
|
||
encoding: "utf-8",
|
||
}).trim() || undefined;
|
||
if (commitSha) {
|
||
await store.updateTask(taskId, {
|
||
mergeDetails: {
|
||
commitSha,
|
||
mergedAt: new Date().toISOString(),
|
||
mergeConfirmed: false,
|
||
},
|
||
});
|
||
mergerLog.log(`${taskId}: branch not found but captured commitSha ${commitSha.slice(0, 8)}`);
|
||
}
|
||
} catch {
|
||
// No commit SHA available — task will show summary fallback
|
||
}
|
||
// Audit trail: record merge completion (FN-1404)
|
||
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: false } });
|
||
await completeTask(store, taskId, result);
|
||
return result;
|
||
}
|
||
|
||
// 3b. Ensure rootDir is on the main branch before merging.
|
||
// Without this, a merge could land on whatever branch was last checked out,
|
||
// causing feature code to be committed to the wrong lineage.
|
||
try {
|
||
const currentBranch = execSync("git symbolic-ref --short HEAD", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
stdio: "pipe",
|
||
}).trim();
|
||
const mainBranch = execSync("git rev-parse --abbrev-ref origin/HEAD", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
stdio: "pipe",
|
||
}).trim().replace(/^origin\//, "");
|
||
if (currentBranch !== mainBranch) {
|
||
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mainBranch}' before merge`);
|
||
await execAsync(`git checkout "${mainBranch}"`, {
|
||
cwd: rootDir,
|
||
});
|
||
// Audit trail: record git checkout (FN-1404)
|
||
await audit.git({ type: "branch:checkout", target: mainBranch });
|
||
}
|
||
} catch {
|
||
// Fallback: try checking out main directly
|
||
try {
|
||
await execAsync("git checkout main", { cwd: rootDir });
|
||
// Audit trail: record git checkout (FN-1404)
|
||
await audit.git({ type: "branch:checkout", target: "main" });
|
||
} catch {
|
||
mergerLog.warn(`${taskId}: unable to verify/checkout main branch — proceeding on current HEAD`);
|
||
}
|
||
}
|
||
|
||
// 4. Gather context for the agent (used in all attempts)
|
||
let commitLog = "";
|
||
let diffStat = "";
|
||
try {
|
||
const { stdout: logOutput } = await execAsync(`git log HEAD..${branch} --format="- %s"`, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
commitLog = logOutput.trim();
|
||
} catch {
|
||
commitLog = "(unable to read commit log)";
|
||
}
|
||
try {
|
||
const { stdout: mergeBaseOutput } = await execAsync(`git merge-base HEAD ${branch}`, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
const mergeBase = mergeBaseOutput.trim();
|
||
const { stdout: diffOutput } = await execAsync(`git diff ${mergeBase}..${branch} --stat`, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
diffStat = diffOutput.trim();
|
||
} catch {
|
||
diffStat = "(unable to read diff)";
|
||
}
|
||
|
||
// 4b. Validate diff scope against task's declared File Scope
|
||
try {
|
||
const scopeResult = await validateDiffScope(store, taskId, diffStat, settings.strictScopeEnforcement);
|
||
for (const warning of scopeResult.warnings) {
|
||
mergerLog.warn(`${taskId}: ${warning}`);
|
||
await store.logEntry(taskId, warning);
|
||
}
|
||
} catch (scopeError: any) {
|
||
if (settings.strictScopeEnforcement && scopeError.message?.includes("Scope enforcement failed")) {
|
||
// Strict mode — block the merge
|
||
await store.logEntry(taskId, `Merge blocked: ${scopeError.message}`);
|
||
throw scopeError;
|
||
}
|
||
// Soft mode — scope validation is best-effort
|
||
}
|
||
|
||
// 5. Execute merge with retry logic
|
||
// Cross-process safety net: abort if another task is already mid-merge.
|
||
// The engine's drainMergeQueue also checks, but this catches direct callers.
|
||
const activeMerge = store.getActiveMergingTask(taskId);
|
||
if (activeMerge) {
|
||
throw new Error(
|
||
`Cannot merge ${taskId}: task ${activeMerge} is already merging (cross-process conflict)`,
|
||
);
|
||
}
|
||
await store.updateTask(taskId, { status: "merging" });
|
||
|
||
// Normalize explicit verification commands from settings
|
||
const explicitTestCommand = settings.testCommand?.trim() || undefined;
|
||
const explicitBuildCommand = settings.buildCommand?.trim() || undefined;
|
||
|
||
// Infer default test command if explicit testCommand is not set
|
||
// This ensures merge verification runs even when settings.testCommand is not configured
|
||
const inferredTest = inferDefaultTestCommand(rootDir, explicitTestCommand, explicitBuildCommand);
|
||
const effectiveTestCommand = inferredTest?.command || explicitTestCommand;
|
||
const effectiveTestSource = inferredTest?.testSource;
|
||
const effectiveBuildCommand = explicitBuildCommand;
|
||
const effectiveBuildSource = inferredTest?.buildSource;
|
||
|
||
// Log what verification commands will be used
|
||
if (effectiveTestCommand || effectiveBuildCommand) {
|
||
mergerLog.log(
|
||
`${taskId}: merge verification commands` +
|
||
(effectiveTestCommand ? ` [test: ${effectiveTestCommand} (${effectiveTestSource || "explicit"})]` : "") +
|
||
(effectiveBuildCommand ? ` [build: ${effectiveBuildCommand} (${effectiveBuildSource || "explicit"})]` : ""),
|
||
);
|
||
}
|
||
|
||
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
|
||
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
|
||
|
||
try {
|
||
// Try the merge with appropriate strategy for this attempt
|
||
const success = await executeMergeAttempt({
|
||
store,
|
||
rootDir,
|
||
taskId,
|
||
branch,
|
||
commitLog,
|
||
diffStat,
|
||
includeTaskId,
|
||
smartConflictResolution,
|
||
attemptNum,
|
||
options,
|
||
result,
|
||
settings,
|
||
testCommand: effectiveTestCommand,
|
||
buildCommand: effectiveBuildCommand,
|
||
testSource: effectiveTestSource,
|
||
buildSource: effectiveBuildSource,
|
||
}, aiTracker);
|
||
|
||
if (success) {
|
||
result.attemptsMade = attemptNum;
|
||
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
|
||
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
|
||
result.merged = true;
|
||
return true;
|
||
}
|
||
|
||
// If not successful and we have more attempts, clean up and try again
|
||
if (attemptNum < 3) {
|
||
mergerLog.log(`${taskId}: attempt ${attemptNum} failed, cleaning up for retry...`);
|
||
try {
|
||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||
// Audit trail: record git reset for merge cleanup (FN-1404)
|
||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "merge-cleanup", attempt: attemptNum } });
|
||
} catch { /* ignore cleanup errors */ }
|
||
}
|
||
|
||
return false;
|
||
} catch (error: any) {
|
||
// Check if it's a deterministic verification failure (testCommand or buildCommand failed)
|
||
// Try in-merge fix attempts before propagating
|
||
if (error.name === "VerificationError") {
|
||
const verificationErr = error as VerificationError;
|
||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 1, 3);
|
||
|
||
if (maxFixRetries > 0 && (verificationErr.verificationResult.testResult || verificationErr.verificationResult.buildResult)) {
|
||
mergerLog.log(`${taskId}: deterministic verification failed — attempting in-merge fix (up to ${maxFixRetries} attempts)`);
|
||
await store.logEntry(taskId, `Verification failed during merge — attempting in-merge fix (up to ${maxFixRetries} attempts)`);
|
||
|
||
// Extract failure context from the VerificationError
|
||
const failedResult = verificationErr.verificationResult.testResult?.success === false
|
||
? verificationErr.verificationResult.testResult
|
||
: verificationErr.verificationResult.buildResult;
|
||
const failedType = verificationErr.verificationResult.testResult?.success === false
|
||
? "test" as const
|
||
: "build" as const;
|
||
|
||
if (failedResult) {
|
||
let fixSuccess = false;
|
||
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
|
||
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||
await store.logEntry(taskId, `In-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||
|
||
fixSuccess = await attemptInMergeVerificationFix(
|
||
store, rootDir, taskId,
|
||
{
|
||
command: failedResult.command,
|
||
exitCode: failedResult.exitCode,
|
||
output: summarizeVerificationOutput(failedResult.stderr || failedResult.stdout, failedType),
|
||
type: failedType,
|
||
},
|
||
settings, options, effectiveTestCommand, effectiveBuildCommand,
|
||
);
|
||
|
||
if (fixSuccess) {
|
||
mergerLog.log(`${taskId}: in-merge verification fix succeeded on attempt ${fixAttempt}`);
|
||
await store.logEntry(taskId, `In-merge verification fix succeeded — verification now passes`);
|
||
break;
|
||
}
|
||
|
||
mergerLog.warn(`${taskId}: in-merge verification fix attempt ${fixAttempt} — verification still fails`);
|
||
await store.logEntry(taskId, `In-merge verification fix attempt ${fixAttempt} — verification still fails`);
|
||
}
|
||
|
||
if (fixSuccess) {
|
||
// Amend the merge commit to include the fixes
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
await amendMergeCommitWithFixes(rootDir, taskId, authorArg);
|
||
return true; // Merge succeeds
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fix attempts exhausted or disabled — fall back to existing behavior
|
||
mergerLog.error(`${taskId}: deterministic verification failed — aborting merge (in-merge fix exhausted or disabled)`);
|
||
throw error;
|
||
}
|
||
|
||
// Check if it's a build verification failure
|
||
if (error.message?.includes("Build verification failed")) {
|
||
const maxFixRetries = Math.min(settings.verificationFixRetries ?? 1, 3);
|
||
|
||
// Try in-merge fix before falling back to build retry
|
||
if (maxFixRetries > 0 && (effectiveTestCommand || effectiveBuildCommand)) {
|
||
mergerLog.log(`${taskId}: build verification failed — attempting in-merge fix`);
|
||
await store.logEntry(taskId, `Build verification failed during merge — attempting in-merge fix`);
|
||
|
||
const fixCommand = effectiveBuildCommand || effectiveTestCommand!;
|
||
const fixType = effectiveBuildCommand ? "build" as const : "test" as const;
|
||
|
||
let fixSuccess = false;
|
||
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
|
||
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||
await store.logEntry(taskId, `In-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
|
||
|
||
fixSuccess = await attemptInMergeVerificationFix(
|
||
store, rootDir, taskId,
|
||
{
|
||
command: fixCommand,
|
||
exitCode: 1,
|
||
output: error.message || "Build verification failed",
|
||
type: fixType,
|
||
},
|
||
settings, options, effectiveTestCommand, effectiveBuildCommand,
|
||
);
|
||
|
||
if (fixSuccess) {
|
||
mergerLog.log(`${taskId}: in-merge verification fix succeeded on attempt ${fixAttempt}`);
|
||
await store.logEntry(taskId, `In-merge verification fix succeeded`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (fixSuccess) {
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
await amendMergeCommitWithFixes(rootDir, taskId, authorArg);
|
||
return true; // Merge succeeds
|
||
}
|
||
}
|
||
|
||
// Fall through to existing buildRetryCount logic
|
||
const buildRetryCount = settings.buildRetryCount ?? 0;
|
||
if (buildRetryCount > 0 && !result._buildRetried) {
|
||
// Allow one build retry — reset merge state and re-attempt same strategy
|
||
mergerLog.log(`${taskId}: build failed, retrying (${buildRetryCount} retry allowed)...`);
|
||
await store.logEntry(taskId, "Build failed — retrying merge attempt");
|
||
result._buildRetried = true;
|
||
try {
|
||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||
// Audit trail: record git reset for build retry (FN-1404)
|
||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "build-retry" } });
|
||
} catch { /* ignore cleanup errors */ }
|
||
return false; // Retry
|
||
}
|
||
throw error; // No retries left — fatal
|
||
}
|
||
|
||
// Clean up on error before potentially rethrowing or retrying
|
||
if (attemptNum < 3 && smartConflictResolution) {
|
||
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
|
||
try {
|
||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||
// Audit trail: record git reset for retry (FN-1404)
|
||
await audit.git({ type: "reset:hard", target: branch, metadata: { purpose: "merge-retry", attempt: attemptNum } });
|
||
} catch { /* ignore cleanup errors */ }
|
||
return false; // Allow retry
|
||
}
|
||
throw error; // Last attempt or auto-resolve disabled - propagate error
|
||
}
|
||
};
|
||
|
||
// Track AI agent invocation for resolutionMethod calculation
|
||
const aiTracker: AiInvocationTracker = { aiWasInvoked: false };
|
||
|
||
// Execute attempts with escalation
|
||
let merged = false;
|
||
|
||
// Attempt 1: Standard AI merge
|
||
merged = await mergeAttempt(1);
|
||
|
||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
|
||
if (!merged && smartConflictResolution) {
|
||
merged = await mergeAttempt(2);
|
||
}
|
||
|
||
// Attempt 3: Use -X theirs merge strategy (if enabled)
|
||
if (!merged && smartConflictResolution) {
|
||
merged = await mergeAttempt(3);
|
||
}
|
||
|
||
// If all attempts failed
|
||
if (!merged) {
|
||
// Final cleanup
|
||
try {
|
||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||
} catch { /* */ }
|
||
throw new Error(`AI merge failed for ${taskId}: all 3 attempts exhausted`);
|
||
}
|
||
|
||
// 5b. Collect merge details and store on task
|
||
try {
|
||
const commitSha = execSync("git rev-parse HEAD", {
|
||
cwd: rootDir,
|
||
stdio: "pipe",
|
||
encoding: "utf-8",
|
||
}).trim() || undefined;
|
||
|
||
let filesChanged: number | undefined;
|
||
let insertions: number | undefined;
|
||
let deletions: number | undefined;
|
||
|
||
try {
|
||
const { stdout: statsOutput } = await execAsync("git show --shortstat --format= HEAD", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
});
|
||
const normalized = statsOutput.trim().replace(/\n/g, " ");
|
||
const filesMatch = normalized.match(/(\d+) files? changed/);
|
||
const insertionsMatch = normalized.match(/(\d+) insertions?\(\+\)/);
|
||
const deletionsMatch = normalized.match(/(\d+) deletions?\(-\)/);
|
||
filesChanged = filesMatch ? Number.parseInt(filesMatch[1], 10) : 0;
|
||
insertions = insertionsMatch ? Number.parseInt(insertionsMatch[1], 10) : 0;
|
||
deletions = deletionsMatch ? Number.parseInt(deletionsMatch[1], 10) : 0;
|
||
} catch { /* non-fatal */ }
|
||
|
||
const mergeDetails: MergeDetails = {
|
||
commitSha,
|
||
filesChanged,
|
||
insertions,
|
||
deletions,
|
||
mergeCommitMessage: commitLog,
|
||
mergedAt: new Date().toISOString(),
|
||
mergeConfirmed: true,
|
||
resolutionStrategy: result.resolutionStrategy,
|
||
resolutionMethod: result.resolutionMethod,
|
||
attemptsMade: result.attemptsMade,
|
||
autoResolvedCount: result.autoResolvedCount,
|
||
};
|
||
|
||
await store.updateTask(taskId, { mergeDetails });
|
||
mergerLog.log(`${taskId}: merge details stored (commitSha: ${commitSha?.slice(0, 8)})`);
|
||
} catch (err: any) {
|
||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||
}
|
||
|
||
// 6. Delete branch
|
||
try {
|
||
await execAsync(`git branch -d "${branch}"`, { cwd: rootDir });
|
||
result.branchDeleted = true;
|
||
// Audit trail: record branch deletion (FN-1404)
|
||
await audit.git({ type: "branch:delete", target: branch });
|
||
} catch {
|
||
try {
|
||
await execAsync(`git branch -D "${branch}"`, { cwd: rootDir });
|
||
result.branchDeleted = true;
|
||
// Audit trail: record branch deletion (force) (FN-1404)
|
||
await audit.git({ type: "branch:delete", target: branch, metadata: { force: true } });
|
||
} catch { /* non-fatal */ }
|
||
}
|
||
|
||
// 7. Clean up worktree
|
||
if (worktreePath && existsSync(worktreePath)) {
|
||
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
|
||
if (otherUser) {
|
||
mergerLog.log(`Worktree retained — still needed by ${otherUser}`);
|
||
result.worktreeRemoved = false;
|
||
} else if (options.pool && settings.recycleWorktrees) {
|
||
options.pool.release(worktreePath);
|
||
result.worktreeRemoved = false;
|
||
} else {
|
||
try {
|
||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||
cwd: rootDir,
|
||
});
|
||
// Audit trail: record worktree removal (FN-1404)
|
||
await audit.git({ type: "worktree:remove", target: worktreePath });
|
||
result.worktreeRemoved = true;
|
||
} catch { /* non-fatal */ }
|
||
}
|
||
}
|
||
|
||
// 8. Run post-merge workflow steps (failures logged but do not block completion)
|
||
try {
|
||
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings, options);
|
||
} catch (err: any) {
|
||
mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`);
|
||
// Non-fatal — task still moves to done
|
||
}
|
||
|
||
// 9. Move task to done
|
||
// Audit trail: record merge completion (FN-1404)
|
||
await audit.database({
|
||
type: "task:move",
|
||
target: taskId,
|
||
metadata: {
|
||
to: "done",
|
||
merged: true,
|
||
resolutionStrategy: result.resolutionStrategy,
|
||
resolutionMethod: result.resolutionMethod,
|
||
attemptsMade: result.attemptsMade,
|
||
},
|
||
});
|
||
await completeTask(store, taskId, result);
|
||
return result;
|
||
}
|
||
|
||
/** Get the resolution strategy based on attempt number and settings */
|
||
function getResolutionStrategy(
|
||
attemptNum: 1 | 2 | 3,
|
||
smartConflictResolution: boolean,
|
||
): MergeResult["resolutionStrategy"] {
|
||
if (!smartConflictResolution || attemptNum === 1) {
|
||
return "ai";
|
||
}
|
||
if (attemptNum === 2) {
|
||
return "auto-resolve";
|
||
}
|
||
return "theirs";
|
||
}
|
||
|
||
/** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */
|
||
function getResolutionMethod(
|
||
strategy: MergeResult["resolutionStrategy"],
|
||
autoResolvedCount?: number,
|
||
aiWasUsed?: boolean,
|
||
): MergeResult["resolutionMethod"] {
|
||
if (strategy === "ai") return "ai";
|
||
if (strategy === "theirs") return "theirs";
|
||
if (strategy === "auto-resolve") {
|
||
// auto-resolve strategy: determine if pure auto or mixed with AI
|
||
if (autoResolvedCount && autoResolvedCount > 0) {
|
||
// If AI was actually invoked during auto-resolve attempt, it's mixed
|
||
return aiWasUsed ? "mixed" : "auto";
|
||
}
|
||
return "auto";
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
interface MergeAttemptParams {
|
||
store: TaskStore;
|
||
rootDir: string;
|
||
taskId: string;
|
||
branch: string;
|
||
commitLog: string;
|
||
diffStat: string;
|
||
includeTaskId: boolean;
|
||
smartConflictResolution: boolean;
|
||
attemptNum: 1 | 2 | 3;
|
||
options: MergerOptions;
|
||
result: MergeResult;
|
||
settings: {
|
||
commitAuthorEnabled?: boolean;
|
||
commitAuthorName?: string;
|
||
commitAuthorEmail?: string;
|
||
};
|
||
testCommand?: string;
|
||
buildCommand?: string;
|
||
/** Source of the test command: 'explicit' from settings or 'inferred' from project files */
|
||
testSource?: "explicit" | "inferred";
|
||
/** Source of the build command: 'explicit' from settings or 'inferred' (future use) */
|
||
buildSource?: "explicit" | "inferred";
|
||
}
|
||
|
||
/** Mutable flag to track AI agent invocation */
|
||
interface AiInvocationTracker {
|
||
aiWasInvoked: boolean;
|
||
}
|
||
|
||
/**
|
||
* Execute a single merge attempt with the specified strategy.
|
||
* Returns true if merge succeeded, false if should retry (for attempts 1-2).
|
||
* Throws on unrecoverable errors.
|
||
*/
|
||
async function executeMergeAttempt(
|
||
params: MergeAttemptParams,
|
||
aiTracker: AiInvocationTracker,
|
||
): Promise<boolean> {
|
||
const {
|
||
store,
|
||
rootDir,
|
||
taskId,
|
||
branch,
|
||
commitLog,
|
||
diffStat,
|
||
includeTaskId,
|
||
smartConflictResolution,
|
||
attemptNum,
|
||
options,
|
||
result,
|
||
settings,
|
||
testCommand,
|
||
buildCommand,
|
||
testSource,
|
||
buildSource,
|
||
} = params;
|
||
|
||
// Attempt 3: Use -X theirs strategy
|
||
if (attemptNum === 3) {
|
||
return attemptWithTheirsStrategy(params);
|
||
}
|
||
|
||
// Attempt 1 & 2: Standard squash merge
|
||
let hasConflicts = false;
|
||
try {
|
||
// For attempt 2, try with smart auto-resolution first
|
||
if (attemptNum === 2 && smartConflictResolution) {
|
||
// First, do a standard merge to get conflicts
|
||
// Note: git merge --squash exits with code 1 when conflicts exist
|
||
// This is expected - we catch it and proceed with auto-resolution
|
||
let mergeExitedWithConflicts = false;
|
||
try {
|
||
await execAsync(`git merge --squash "${branch}"`, {
|
||
cwd: rootDir,
|
||
});
|
||
} catch {
|
||
// Merge exits with code 1 when conflicts exist - this is expected
|
||
mergeExitedWithConflicts = true;
|
||
}
|
||
|
||
// Use new API: get conflicted files and classify them
|
||
const conflictedFiles = await getConflictedFiles(rootDir);
|
||
if (conflictedFiles.length > 0 || mergeExitedWithConflicts) {
|
||
// Classify each conflicted file
|
||
const classified: { file: string; type: ConflictType }[] = [];
|
||
for (const file of conflictedFiles) {
|
||
const type = await classifyConflict(file, rootDir);
|
||
classified.push({ file, type });
|
||
}
|
||
|
||
const autoResolvable = classified.filter(
|
||
(c) => c.type !== "complex",
|
||
);
|
||
const complex = classified.filter(
|
||
(c) => c.type === "complex",
|
||
);
|
||
|
||
// Auto-resolve each file based on its classification
|
||
if (autoResolvable.length > 0) {
|
||
mergerLog.log(
|
||
`${taskId}: auto-resolving ${autoResolvable.length} lock/generated/trivial file(s) before AI retry`,
|
||
);
|
||
for (const { file, type } of autoResolvable) {
|
||
try {
|
||
if (type === "lockfile-ours") {
|
||
await resolveWithOurs(file, rootDir);
|
||
} else if (type === "generated-theirs") {
|
||
await resolveWithTheirs(file, rootDir);
|
||
} else if (type === "trivial-whitespace") {
|
||
await resolveTrivialWhitespace(file, rootDir);
|
||
}
|
||
result.autoResolvedCount = (result.autoResolvedCount || 0) + 1;
|
||
} catch (error) {
|
||
// If auto-resolution fails, treat as complex conflict
|
||
mergerLog.warn(`${taskId}: auto-resolution failed for ${file}: ${error}`);
|
||
complex.push({ file, type: "complex" });
|
||
}
|
||
}
|
||
}
|
||
|
||
// If only auto-resolvable conflicts (or all were resolved), commit directly
|
||
if (complex.length === 0) {
|
||
// All conflicts auto-resolved, commit with fallback message
|
||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
}).trim();
|
||
|
||
if (staged !== "0") {
|
||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
await execAsync(
|
||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg}`,
|
||
{ cwd: rootDir },
|
||
);
|
||
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
|
||
}
|
||
// Run deterministic verification before completing the merge
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Has complex conflicts - continue to AI agent
|
||
hasConflicts = true;
|
||
} else {
|
||
// No conflicts - check if squash is empty
|
||
const squashIsEmpty = execSync(
|
||
"git diff --cached --quiet 2>&1; echo $?",
|
||
{ cwd: rootDir, encoding: "utf-8" },
|
||
).trim() === "0";
|
||
|
||
if (squashIsEmpty) {
|
||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
||
// Run deterministic verification (nothing staged but still verify)
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
return true;
|
||
}
|
||
// No conflicts but has staged changes - continue to AI for commit message
|
||
}
|
||
} else {
|
||
// Attempt 1: Standard merge
|
||
await execAsync(`git merge --squash "${branch}"`, {
|
||
cwd: rootDir,
|
||
});
|
||
|
||
// Check if squash is empty
|
||
const squashIsEmpty = execSync(
|
||
"git diff --cached --quiet 2>&1; echo $?",
|
||
{ cwd: rootDir, encoding: "utf-8" },
|
||
).trim() === "0";
|
||
|
||
if (squashIsEmpty) {
|
||
mergerLog.log(`${taskId}: squash merge staged nothing — already merged`);
|
||
// Run deterministic verification (nothing staged but still verify)
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Check for conflicts
|
||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
}).trim();
|
||
hasConflicts = conflictedOutput.length > 0;
|
||
|
||
if (hasConflicts && !smartConflictResolution) {
|
||
// No auto-resolve - AI will handle all conflicts
|
||
mergerLog.log(`${taskId}: conflicts detected, AI will resolve`);
|
||
} else if (hasConflicts && smartConflictResolution) {
|
||
// Has conflicts and auto-resolve enabled - should be handled in attempt 2
|
||
// Reset and return false to trigger attempt 2
|
||
mergerLog.log(`${taskId}: conflicts detected, will retry with auto-resolution`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (buildCommand) {
|
||
const stagedFiles = await getStagedFiles(rootDir);
|
||
if (shouldSyncDependenciesForMerge(stagedFiles, hasInstallState(rootDir))) {
|
||
await syncDependenciesForMerge(store, rootDir, taskId);
|
||
}
|
||
}
|
||
|
||
// At this point, either:
|
||
// - No conflicts (attempt 1) - AI writes commit message
|
||
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
|
||
// Spawn AI agent
|
||
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
||
const agentResult = await runAiAgentForCommit({
|
||
store,
|
||
rootDir,
|
||
taskId,
|
||
branch,
|
||
commitLog,
|
||
diffStat,
|
||
includeTaskId,
|
||
hasConflicts,
|
||
simplifiedContext: attemptNum === 2,
|
||
options,
|
||
testCommand,
|
||
buildCommand,
|
||
});
|
||
|
||
// Handle build failure
|
||
if (!agentResult.success) {
|
||
// Build verification failed - log, reset staged changes, and throw
|
||
const errorMessage = agentResult.error || "Build verification failed";
|
||
await store.logEntry(taskId, "Build verification failed during merge", errorMessage);
|
||
|
||
// Reset staged changes to abort the merge
|
||
try {
|
||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||
} catch {
|
||
// Ignore reset errors
|
||
}
|
||
|
||
throw new Error(`Build verification failed for ${taskId}: ${errorMessage}`);
|
||
}
|
||
|
||
// Run deterministic verification after AI agent commits
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
// Check if it's a build verification failure - don't retry, propagate immediately
|
||
if (error.message?.includes("Build verification failed")) {
|
||
throw error; // Fatal - don't retry build failures
|
||
}
|
||
|
||
// Check if it's a non-conflict merge failure
|
||
if (error.message?.includes("Merge failed")) {
|
||
throw error; // Fatal
|
||
}
|
||
|
||
// For attempt 1, return false to trigger attempt 2
|
||
if (attemptNum === 1 && smartConflictResolution) {
|
||
return false;
|
||
}
|
||
|
||
// Otherwise propagate
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attempt 3: Use git merge -X theirs --squash strategy
|
||
*/
|
||
async function attemptWithTheirsStrategy(params: MergeAttemptParams): Promise<boolean> {
|
||
const { rootDir, branch, commitLog, includeTaskId, taskId, store, settings, testCommand, buildCommand, testSource, buildSource } = params;
|
||
|
||
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
|
||
|
||
try {
|
||
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
|
||
await execAsync(`git merge -X theirs --squash "${branch}"`, {
|
||
cwd: rootDir,
|
||
});
|
||
|
||
// Check if there are still conflicts (some types can't be auto-resolved)
|
||
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
}).trim();
|
||
|
||
if (conflictedOutput.length > 0) {
|
||
mergerLog.warn(`${taskId}: -X theirs left unresolved conflicts: ${conflictedOutput}`);
|
||
return false; // Still has conflicts after -X theirs
|
||
}
|
||
|
||
// Check if there's anything staged
|
||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
}).trim();
|
||
|
||
if (staged === "0") {
|
||
// Nothing staged - already merged
|
||
// Run deterministic verification even when nothing is staged
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Commit with fallback message
|
||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
await execAsync(
|
||
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"${authorArg}`,
|
||
{ cwd: rootDir },
|
||
);
|
||
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
|
||
|
||
// Run deterministic verification after committing
|
||
if (testCommand || buildCommand) {
|
||
await runDeterministicVerification(store, rootDir, taskId, testCommand, buildCommand, testSource, buildSource);
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
mergerLog.error(`${taskId}: -X theirs merge failed: ${error}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
interface AiAgentParams {
|
||
store: TaskStore;
|
||
rootDir: string;
|
||
taskId: string;
|
||
branch: string;
|
||
commitLog: string;
|
||
diffStat: string;
|
||
includeTaskId: boolean;
|
||
hasConflicts: boolean;
|
||
simplifiedContext: boolean;
|
||
options: MergerOptions;
|
||
testCommand?: string;
|
||
buildCommand?: string;
|
||
}
|
||
|
||
/**
|
||
* Run the AI agent to resolve conflicts and/or write commit message.
|
||
*
|
||
* Each invocation creates a **fresh session** via `createKbAgent` to ensure
|
||
* no stale conversation state from previous merge attempts or unrelated sessions
|
||
* pollutes the merge context. The session is disposed in the `finally` block
|
||
* regardless of success or failure.
|
||
*
|
||
* **Context-limit recovery:** If the session's `prompt()` call throws a
|
||
* context-window overflow error (detected via `isContextLimitError`), this
|
||
* function attempts a single **compact-and-retry** cycle:
|
||
* 1. Calls `compactSessionContext()` to compress the conversation history
|
||
* 2. Retries the `prompt()` call with the compacted session
|
||
* 3. If compaction is unavailable or fails, propagates the original error
|
||
*
|
||
* Non-context errors (network, rate limits, build failures) are propagated
|
||
* immediately without compaction recovery.
|
||
*
|
||
* @returns `{ success: true }` on successful commit, `{ success: false, error }`
|
||
* when build verification fails, or throws on unrecoverable errors.
|
||
*/
|
||
async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: boolean; error?: string }> {
|
||
const {
|
||
store,
|
||
rootDir,
|
||
taskId,
|
||
branch,
|
||
commitLog,
|
||
diffStat,
|
||
includeTaskId,
|
||
hasConflicts,
|
||
simplifiedContext,
|
||
options,
|
||
testCommand,
|
||
buildCommand,
|
||
} = params;
|
||
|
||
const settings = await store.getSettings();
|
||
|
||
// Track build failure state
|
||
let buildFailed = false;
|
||
let buildErrorMessage = "";
|
||
|
||
// Create custom tool for reporting build failures
|
||
const reportBuildFailureTool: ToolDefinition = {
|
||
name: "report_build_failure",
|
||
label: "Report Build Failure",
|
||
description: "Report that the build verification failed. Use this when the build command returns a non-zero exit code. Provide the error details in the message parameter.",
|
||
parameters: Type.Object({
|
||
message: Type.String({ description: "Error message describing why the build failed" }),
|
||
}),
|
||
execute: async (_toolCallId: string, params: unknown) => {
|
||
const { message } = params as { message: string };
|
||
buildFailed = true;
|
||
buildErrorMessage = message;
|
||
return {
|
||
content: [{ type: "text", text: `Build failure reported: ${message}` }],
|
||
details: undefined
|
||
};
|
||
},
|
||
};
|
||
|
||
mergerLog.log(`${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`);
|
||
|
||
const agentLogger = new AgentLogger({
|
||
store,
|
||
taskId,
|
||
agent: "merger",
|
||
onAgentText: options.onAgentText
|
||
? (_id, delta) => options.onAgentText!(delta)
|
||
: undefined,
|
||
onAgentTool: options.onAgentTool
|
||
? (_id, name) => options.onAgentTool!(name)
|
||
: undefined,
|
||
});
|
||
|
||
// Resolve per-agent custom instructions for the merger role
|
||
let mergerInstructions = "";
|
||
if (options.agentStore) {
|
||
try {
|
||
const agents = await options.agentStore.listAgents({ role: "merger" });
|
||
for (const agent of agents) {
|
||
if (agent.instructionsText || agent.instructionsPath) {
|
||
mergerInstructions = await resolveAgentInstructions(agent, rootDir);
|
||
break;
|
||
}
|
||
}
|
||
} catch {
|
||
// Graceful fallback
|
||
}
|
||
}
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
const mergerSystemPrompt = buildSystemPromptWithInstructions(
|
||
buildMergeSystemPrompt(includeTaskId, settings.agentPrompts, authorArg),
|
||
mergerInstructions,
|
||
);
|
||
|
||
// Build skill selection context (assigned agent skills take precedence over role fallback)
|
||
let skillContext = undefined;
|
||
if (options.agentStore) {
|
||
try {
|
||
const task = await store.getTask(taskId);
|
||
skillContext = await buildSessionSkillContext({
|
||
agentStore: options.agentStore,
|
||
task,
|
||
sessionPurpose: "merger",
|
||
projectRootDir: rootDir,
|
||
});
|
||
} catch {
|
||
// Graceful fallback - no skill selection
|
||
}
|
||
}
|
||
|
||
const { session } = await createKbAgent({
|
||
cwd: rootDir,
|
||
systemPrompt: mergerSystemPrompt,
|
||
tools: "coding",
|
||
customTools: [reportBuildFailureTool],
|
||
onText: agentLogger.onText,
|
||
onThinking: agentLogger.onThinking,
|
||
onToolStart: agentLogger.onToolStart,
|
||
onToolEnd: agentLogger.onToolEnd,
|
||
defaultProvider: settings.defaultProvider,
|
||
defaultModelId: settings.defaultModelId,
|
||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
|
||
});
|
||
|
||
options.onSession?.(session);
|
||
|
||
try {
|
||
// Build appropriate prompt
|
||
const prompt = buildMergePrompt({
|
||
taskId,
|
||
branch,
|
||
commitLog: simplifiedContext ? "(see branch commits)" : commitLog,
|
||
diffStat,
|
||
hasConflicts,
|
||
simplifiedContext,
|
||
testCommand,
|
||
buildCommand,
|
||
authorArg,
|
||
});
|
||
|
||
// Attempt prompting with fresh session (first attempt).
|
||
// Log message distinguishes fresh-session start from compaction recovery path.
|
||
mergerLog.log(`${taskId}: starting fresh merge agent session`);
|
||
|
||
try {
|
||
await withRateLimitRetry(async () => {
|
||
await promptWithFallback(session, prompt);
|
||
checkSessionError(session);
|
||
}, {
|
||
onRetry: (attempt, delayMs, error) => {
|
||
const delaySec = Math.round(delayMs / 1000);
|
||
mergerLog.warn(`⏳ ${taskId} rate limited — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||
},
|
||
});
|
||
} catch (err: unknown) {
|
||
// Context-limit error after promptWithFallback's auto-compaction already attempted recovery.
|
||
// Try truncated prompt retry as second-level fallback.
|
||
// This detects when the LLM rejects the prompt due to context-window overflow.
|
||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||
if (isContextLimitError(errorMessage)) {
|
||
mergerLog.warn(`${taskId}: context limit hit after auto-compaction — retrying with minimal merge prompt`);
|
||
await store.logEntry(taskId, "Context limit reached during merge after auto-compaction — retrying with reduced prompt");
|
||
|
||
// Build minimal prompt: omit diff stat, use placeholder for commit log
|
||
const truncatedPrompt = buildMergePrompt({
|
||
taskId,
|
||
branch,
|
||
commitLog: "(see git log)", // Minimal placeholder instead of full commit log
|
||
diffStat: "", // Omit diff stat entirely
|
||
hasConflicts,
|
||
simplifiedContext: true, // Also skip detailed context
|
||
testCommand,
|
||
buildCommand,
|
||
authorArg,
|
||
});
|
||
|
||
try {
|
||
await withRateLimitRetry(async () => {
|
||
await promptWithFallback(session, truncatedPrompt);
|
||
checkSessionError(session);
|
||
}, {
|
||
onRetry: (attempt, delayMs, error) => {
|
||
const delaySec = Math.round(delayMs / 1000);
|
||
mergerLog.warn(`⏳ ${taskId} rate limited during truncated retry — retry ${attempt} in ${delaySec}s: ${error.message}`);
|
||
},
|
||
});
|
||
} catch (retryErr: unknown) {
|
||
// Truncated retry also failed: propagate original error
|
||
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||
if (isContextLimitError(retryErrorMessage)) {
|
||
mergerLog.error(`${taskId}: truncated retry also hit context limit — propagating original error`);
|
||
throw err; // Throw original error with original context
|
||
}
|
||
throw retryErr; // Non-context error or other failure
|
||
}
|
||
} else {
|
||
// Non-context error (network, rate limit, build failure): propagate immediately.
|
||
// Rate limit errors are handled by withRateLimitRetry above; this catches
|
||
// errors that bubble up after retries are exhausted.
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// Check if build failed
|
||
if (buildFailed) {
|
||
mergerLog.error(`Build verification failed for ${taskId}: ${buildErrorMessage}`);
|
||
return { success: false, error: buildErrorMessage };
|
||
}
|
||
|
||
// Verify commit happened
|
||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
}).trim();
|
||
|
||
if (staged !== "0") {
|
||
// Only use fallback commit if no build command was configured
|
||
// If build command was configured, agent should have committed or reported failure
|
||
if (!buildCommand) {
|
||
mergerLog.log("Agent didn't commit — committing with fallback message");
|
||
const escapedLog = commitLog.replace(/"/g, '\\"');
|
||
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
|
||
const authorArg = getCommitAuthorArg(settings);
|
||
await execAsync(
|
||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"${authorArg}`,
|
||
{ cwd: rootDir },
|
||
);
|
||
} else {
|
||
// Build command was configured but agent didn't commit and didn't report failure
|
||
// This is an error condition - agent didn't follow instructions
|
||
throw new Error(`Agent did not commit and did not report build failure for ${taskId}`);
|
||
}
|
||
}
|
||
|
||
return { success: true };
|
||
} catch (err: any) {
|
||
mergerLog.error(`Agent failed: ${err.message}`);
|
||
|
||
if (options.usageLimitPauser && isUsageLimitError(err.message)) {
|
||
await options.usageLimitPauser.onUsageLimitHit("merger", taskId, err.message);
|
||
}
|
||
|
||
throw err;
|
||
} finally {
|
||
await agentLogger.flush();
|
||
session.dispose();
|
||
}
|
||
}
|
||
|
||
interface MergePromptParams {
|
||
taskId: string;
|
||
branch: string;
|
||
commitLog: string;
|
||
diffStat: string;
|
||
hasConflicts: boolean;
|
||
simplifiedContext?: boolean;
|
||
testCommand?: string;
|
||
buildCommand?: string;
|
||
authorArg?: string;
|
||
}
|
||
|
||
export function buildMergePrompt(params: MergePromptParams): string {
|
||
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, testCommand, buildCommand, authorArg } = params;
|
||
|
||
// Apply truncation to prevent context overflow for large branches/diffs
|
||
const truncatedCommitLog = truncateWithEllipsis(commitLog, MERGE_COMMIT_LOG_MAX_CHARS);
|
||
const truncatedDiffStat = truncateWithEllipsis(diffStat, MERGE_DIFF_STAT_MAX_CHARS);
|
||
|
||
const parts = [
|
||
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
|
||
"",
|
||
"## Branch commits",
|
||
"```",
|
||
truncatedCommitLog,
|
||
"```",
|
||
];
|
||
|
||
if (!simplifiedContext) {
|
||
parts.push(
|
||
"",
|
||
"## Files changed",
|
||
"```",
|
||
truncatedDiffStat,
|
||
"```",
|
||
);
|
||
}
|
||
|
||
if (hasConflicts) {
|
||
parts.push(
|
||
"",
|
||
"## ⚠️ There are merge conflicts",
|
||
"Run `git diff --name-only --diff-filter=U` to see which files.",
|
||
"Resolve each conflict, then `git add` the resolved files.",
|
||
`After resolving all conflicts, write and run the commit command.${authorArg ? ` Be sure to include \`${authorArg.trim()}\` in the commit command.` : ""}`,
|
||
);
|
||
} else {
|
||
parts.push(
|
||
"",
|
||
"## No conflicts",
|
||
"The merge applied cleanly. All changes are staged.",
|
||
`Write and run the \`git commit\` command with a good message summarizing the work.${authorArg ? ` Be sure to include \`${authorArg.trim()}\` in the commit command.` : ""}`,
|
||
);
|
||
}
|
||
|
||
// Add test command section if provided
|
||
if (testCommand) {
|
||
parts.push(
|
||
"",
|
||
"## Test command",
|
||
`Test command: \`${testCommand}\``,
|
||
"",
|
||
"This command is mandatory before commit.",
|
||
"Run it with the bash tool in the current worktree and inspect the actual exit code.",
|
||
"Only proceed if it exits 0.",
|
||
"If it exits non-zero, call `report_build_failure` with the concrete error output and stop without committing.",
|
||
);
|
||
}
|
||
|
||
// Add build command section if provided
|
||
if (buildCommand) {
|
||
parts.push(
|
||
"",
|
||
"## Build command",
|
||
`Build command: \`${buildCommand}\``,
|
||
"",
|
||
"This command is mandatory before commit.",
|
||
"Run it with the bash tool in the current worktree and inspect the actual exit code.",
|
||
"Only commit if it exits 0.",
|
||
"If it exits non-zero, call `report_build_failure` with the concrete error output and stop without committing.",
|
||
);
|
||
}
|
||
|
||
return parts.join("\n");
|
||
}
|
||
|
||
/**
|
||
* Run post-merge workflow steps for a task after the merge succeeds.
|
||
* These steps run in the root directory (after merge, worktree may be cleaned up).
|
||
* Failures are logged but do NOT block task completion — the merge is already committed.
|
||
*/
|
||
async function runPostMergeWorkflowSteps(
|
||
store: TaskStore,
|
||
taskId: string,
|
||
rootDir: string,
|
||
settings: Settings,
|
||
mergeOptions: MergerOptions = {},
|
||
): Promise<void> {
|
||
const task = await store.getTask(taskId);
|
||
if (!task.enabledWorkflowSteps?.length) return;
|
||
|
||
// Get existing pre-merge results to append to
|
||
const existingResults: WorkflowStepResult[] = task.workflowStepResults || [];
|
||
|
||
for (const wsId of task.enabledWorkflowSteps) {
|
||
const ws = await store.getWorkflowStep(wsId);
|
||
if (!ws) {
|
||
mergerLog.log(`${taskId}: [post-merge] workflow step ${wsId} not found — skipping`);
|
||
continue;
|
||
}
|
||
|
||
// Normalize legacy steps: undefined phase → "pre-merge"
|
||
const stepPhase = ws.phase || "pre-merge";
|
||
|
||
// Only run post-merge steps here
|
||
if (stepPhase !== "post-merge") continue;
|
||
|
||
// Normalize legacy steps without mode to prompt-mode
|
||
const stepMode: "prompt" | "script" = ws.mode || "prompt";
|
||
|
||
// Skip validation per mode
|
||
if (stepMode === "prompt" && !ws.prompt?.trim()) {
|
||
await store.logEntry(taskId, `[post-merge] Workflow step '${ws.name}' has no prompt — skipping`);
|
||
existingResults.push({
|
||
workflowStepId: ws.id,
|
||
workflowStepName: ws.name,
|
||
phase: "post-merge",
|
||
status: "skipped",
|
||
output: "No prompt configured for this workflow step",
|
||
});
|
||
await store.updateTask(taskId, { workflowStepResults: existingResults });
|
||
continue;
|
||
}
|
||
|
||
if (stepMode === "script" && !ws.scriptName?.trim()) {
|
||
await store.logEntry(taskId, `[post-merge] Workflow step '${ws.name}' has no scriptName — skipping`);
|
||
existingResults.push({
|
||
workflowStepId: ws.id,
|
||
workflowStepName: ws.name,
|
||
phase: "post-merge",
|
||
status: "skipped",
|
||
output: "No scriptName configured for this workflow step",
|
||
});
|
||
await store.updateTask(taskId, { workflowStepResults: existingResults });
|
||
continue;
|
||
}
|
||
|
||
await store.logEntry(taskId, `[post-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
|
||
mergerLog.log(`${taskId}: [post-merge] running workflow step: ${ws.name} (${stepMode} mode)`);
|
||
|
||
const startedAt = new Date().toISOString();
|
||
|
||
try {
|
||
const result = stepMode === "script"
|
||
? await executePostMergeScriptStep(store, taskId, ws, rootDir, settings)
|
||
: await executePostMergePromptStep(store, taskId, ws, rootDir, settings, mergeOptions);
|
||
const completedAt = new Date().toISOString();
|
||
|
||
if (result.success) {
|
||
await store.logEntry(taskId, `[post-merge] Workflow step completed: ${ws.name}`);
|
||
mergerLog.log(`${taskId}: [post-merge] workflow step passed: ${ws.name}`);
|
||
existingResults.push({
|
||
workflowStepId: ws.id,
|
||
workflowStepName: ws.name,
|
||
phase: "post-merge",
|
||
status: "passed",
|
||
output: result.output,
|
||
startedAt,
|
||
completedAt,
|
||
});
|
||
} else {
|
||
// Post-merge failures are logged but do NOT block task completion
|
||
await store.logEntry(taskId, `[post-merge] Workflow step failed: ${ws.name}`, result.error || "Unknown error");
|
||
mergerLog.error(`${taskId}: [post-merge] workflow step failed: ${ws.name}; output captured in task log`);
|
||
existingResults.push({
|
||
workflowStepId: ws.id,
|
||
workflowStepName: ws.name,
|
||
phase: "post-merge",
|
||
status: "failed",
|
||
output: result.error || "Workflow step failed",
|
||
startedAt,
|
||
completedAt,
|
||
});
|
||
}
|
||
} catch (err: any) {
|
||
const completedAt = new Date().toISOString();
|
||
await store.logEntry(taskId, `[post-merge] Workflow step error: ${ws.name}`, err.message || "Unknown error");
|
||
mergerLog.error(`${taskId}: [post-merge] workflow step error: ${ws.name} — ${err.message}`);
|
||
existingResults.push({
|
||
workflowStepId: ws.id,
|
||
workflowStepName: ws.name,
|
||
phase: "post-merge",
|
||
status: "failed",
|
||
output: err.message || "Workflow step error",
|
||
startedAt,
|
||
completedAt,
|
||
});
|
||
}
|
||
|
||
// Save results after each step (partial results preserved on crash)
|
||
await store.updateTask(taskId, { workflowStepResults: existingResults });
|
||
}
|
||
}
|
||
|
||
/** Execute a script-mode post-merge workflow step */
|
||
async function executePostMergeScriptStep(
|
||
store: TaskStore,
|
||
taskId: string,
|
||
workflowStep: WorkflowStep,
|
||
rootDir: string,
|
||
settings: Settings,
|
||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||
const scriptName = workflowStep.scriptName!.trim();
|
||
const scripts = settings.scripts || {};
|
||
const scriptCommand = scripts[scriptName];
|
||
|
||
if (!scriptCommand) {
|
||
return { success: false, error: `Script '${scriptName}' not found in project settings` };
|
||
}
|
||
|
||
try {
|
||
await execAsync(scriptCommand, {
|
||
cwd: rootDir,
|
||
encoding: "utf-8",
|
||
timeout: 120_000,
|
||
maxBuffer: 10 * 1024 * 1024,
|
||
});
|
||
return { success: true, output: `Script '${scriptName}' completed successfully` };
|
||
} catch (err: any) {
|
||
const stderr = err.stderr?.toString()?.trim() || "";
|
||
const stdout = err.stdout?.toString()?.trim() || "";
|
||
const exitCode = err.code ?? err.status;
|
||
const parts: string[] = [];
|
||
if (exitCode !== undefined) parts.push(`Exit code: ${exitCode}`);
|
||
if (stdout) parts.push(`stdout: ${truncateWorkflowScriptOutput(stdout)}`);
|
||
if (stderr) parts.push(`stderr: ${truncateWorkflowScriptOutput(stderr)}`);
|
||
if (!parts.length) parts.push(err.message || "Unknown error");
|
||
return { success: false, error: parts.join("\n") };
|
||
}
|
||
}
|
||
|
||
/** Execute a prompt-mode post-merge workflow step using AI agent */
|
||
async function executePostMergePromptStep(
|
||
store: TaskStore,
|
||
taskId: string,
|
||
workflowStep: WorkflowStep,
|
||
rootDir: string,
|
||
settings: Settings,
|
||
mergeOptions: MergerOptions = {},
|
||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
|
||
const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name}
|
||
|
||
Task Context:
|
||
- Task ID: ${taskId}
|
||
- The merge has already been completed successfully.
|
||
- You are running in the project's root directory with the merged code.
|
||
|
||
Your Instructions:
|
||
${workflowStep.prompt}
|
||
|
||
You have access to the file system to review the merged changes.
|
||
When your review is complete and everything looks good, simply state your findings.
|
||
If issues are found that need attention, describe them clearly.`;
|
||
|
||
const agentLogger = new AgentLogger({
|
||
store,
|
||
taskId,
|
||
agent: "merger",
|
||
});
|
||
|
||
try {
|
||
const stepProvider = workflowStep.modelProvider || settings.defaultProvider;
|
||
const stepModelId = workflowStep.modelId || settings.defaultModelId;
|
||
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
|
||
|
||
// Post-merge step agents inherit merger instructions
|
||
let postMergeInstructions = "";
|
||
if (mergeOptions.agentStore) {
|
||
try {
|
||
const agents = await mergeOptions.agentStore.listAgents({ role: "merger" });
|
||
for (const agent of agents) {
|
||
if (agent.instructionsText || agent.instructionsPath) {
|
||
postMergeInstructions = await resolveAgentInstructions(agent, rootDir);
|
||
break;
|
||
}
|
||
}
|
||
} catch {
|
||
// Graceful fallback
|
||
}
|
||
}
|
||
const postMergeSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, postMergeInstructions);
|
||
|
||
// Build skill selection context for post-merge session
|
||
let postMergeSkillContext = undefined;
|
||
if (mergeOptions.agentStore) {
|
||
try {
|
||
const task = await store.getTask(taskId);
|
||
postMergeSkillContext = await buildSessionSkillContext({
|
||
agentStore: mergeOptions.agentStore,
|
||
task,
|
||
sessionPurpose: "merger",
|
||
projectRootDir: rootDir,
|
||
});
|
||
} catch {
|
||
// Graceful fallback - no skill selection
|
||
}
|
||
}
|
||
|
||
const { session } = await createKbAgent({
|
||
cwd: rootDir,
|
||
systemPrompt: postMergeSystemPrompt,
|
||
tools: toolMode,
|
||
defaultProvider: stepProvider,
|
||
defaultModelId: stepModelId,
|
||
fallbackProvider: settings.fallbackProvider,
|
||
fallbackModelId: settings.fallbackModelId,
|
||
defaultThinkingLevel: settings.defaultThinkingLevel,
|
||
// Skill selection: use assigned agent skills if available, otherwise role fallback
|
||
...(postMergeSkillContext?.skillSelectionContext ? { skillSelection: postMergeSkillContext.skillSelectionContext } : {}),
|
||
});
|
||
|
||
mergerLog.log(`${taskId}: [post-merge] workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
|
||
await store.logEntry(taskId, `[post-merge] Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride ? " (workflow step override)" : ""}`);
|
||
|
||
let output = "";
|
||
session.subscribe((event) => {
|
||
if (event.type === "message_update") {
|
||
const msgEvent = event.assistantMessageEvent;
|
||
if (msgEvent.type === "text_delta") {
|
||
output += msgEvent.delta;
|
||
}
|
||
}
|
||
});
|
||
|
||
await promptWithFallback(
|
||
session,
|
||
`Execute the post-merge workflow step "${workflowStep.name}" for task ${taskId}.\n\n` +
|
||
`Review the merged code in the project root and evaluate it against your instructions.`,
|
||
);
|
||
|
||
checkSessionError(session);
|
||
session.dispose();
|
||
await agentLogger.flush();
|
||
|
||
return { success: true, output };
|
||
} catch (err: any) {
|
||
await agentLogger.flush();
|
||
return { success: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
async function completeTask(
|
||
store: TaskStore,
|
||
taskId: string,
|
||
result: MergeResult,
|
||
): Promise<void> {
|
||
mergerLog.log(`${taskId}: completeTask — clearing status, moving to done`);
|
||
// Clear transient status before moving to done
|
||
await store.updateTask(taskId, { status: null });
|
||
// Use moveTask for proper event emission
|
||
const task = await store.moveTask(taskId, "done");
|
||
result.task = task;
|
||
store.emit("task:merged", result);
|
||
}
|