Files
fusion/packages/engine/src/merger.ts
gsxdsm 85c01c6f2f refactor(FN-1317): rename remaining kb references to fn
- Update executor, triage, and merger prompt strings to identify the product as "fn"
- Align branch/worktree documentation examples with fusion/fn-* naming conventions
- Rename dashboard websocket attachment guard from __kbWebSocketsAttached to __fnWebSocketsAttached
- Refresh engine tests, dashboard utility comments, and TUI header text to remove stale kb wording
2026-04-08 14:38:36 -07:00

1844 lines
62 KiB
TypeScript

import { execSync } from "node:child_process";
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 } from "./pi.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 { 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";
/** 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",
];
/** 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 function getStagedFiles(cwd: string): string[] {
try {
const output = execSync("git diff --cached --name-only", {
cwd,
encoding: "utf-8",
stdio: "pipe",
}).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 {
execSync(installCommand, {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
});
} catch (error: any) {
const details = error?.stderr || error?.stdout || error?.message || String(error);
throw new Error(`Dependency sync failed for ${taskId}: ${details}`.trim());
}
}
// ── 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 function getConflictedFiles(cwd: string): string[] {
try {
const output = execSync("git diff --name-only --diff-filter=U", {
cwd,
encoding: "utf-8",
}).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 function isTrivialWhitespaceConflict(filePath: string, cwd: string): 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 result = execSync(
`git diff-tree -p -w -- :2:"${filePath}" :3:"${filePath}"`,
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
);
// 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 = result.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 function classifyConflict(filePath: string, cwd: string): 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 (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 function resolveWithOurs(filePath: string, cwd: string): void {
try {
execSync(`git checkout --ours "${filePath}"`, { cwd, stdio: "pipe" });
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
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 function resolveWithTheirs(filePath: string, cwd: string): void {
try {
execSync(`git checkout --theirs "${filePath}"`, { cwd, stdio: "pipe" });
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
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 function resolveTrivialWhitespace(filePath: string, cwd: string): void {
try {
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
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 function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
const files = getConflictedFiles(rootDir);
return files.map((filePath): ConflictCategory => {
const type = classifyConflict(filePath, rootDir);
switch (type) {
case "lockfile-ours":
return { filePath, autoResolvable: true, strategy: "ours", reason: "lock-file" };
case "generated-theirs":
return { filePath, autoResolvable: true, strategy: "theirs", reason: "generated-file" };
case "trivial-whitespace":
return { filePath, autoResolvable: true, strategy: "ours", reason: "trivial" };
case "complex":
return { filePath, autoResolvable: false, reason: "complex" };
}
});
}
/**
* Auto-resolve a single file using git checkout --ours or --theirs.
* @deprecated Use resolveWithOurs() or resolveWithTheirs() instead.
*/
export function autoResolveFile(
filePath: string,
resolution: ConflictResolution,
rootDir: string,
): void {
if (resolution === "ours") {
resolveWithOurs(filePath, rootDir);
} else {
resolveWithTheirs(filePath, rootDir);
}
}
/**
* Auto-resolve all resolvable conflicts from the categorization.
* @deprecated Use classifyConflict + resolveWithOurs/resolveWithTheirs instead.
*/
export function resolveConflicts(
categories: ConflictCategory[],
rootDir: string,
): string[] {
const remainingComplex: string[] = [];
for (const category of categories) {
if (category.autoResolvable && category.strategy) {
autoResolveFile(category.filePath, category.strategy, rootDir);
} else {
remainingComplex.push(category.filePath);
}
}
return remainingComplex;
}
/**
* 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): string {
const commitFormat = includeTaskId
? `\`\`\`
git commit -m "<type>(<scope>): <summary>" -m "<body>"
\`\`\`
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 "- "
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"
\`\`\``
: `\`\`\`
git commit -m "<type>: <summary>" -m "<body>"
\`\`\`
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 "- "
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"
\`\`\``;
// 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();
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,
};
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
}
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`);
execSync(`git checkout "${mainBranch}"`, {
cwd: rootDir,
stdio: "pipe",
});
}
} catch {
// Fallback: try checking out main directly
try {
execSync("git checkout main", { cwd: rootDir, stdio: "pipe" });
} 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 {
commitLog = execSync(`git log HEAD..${branch} --format="- %s"`, {
cwd: rootDir,
encoding: "utf-8",
}).trim();
} catch {
commitLog = "(unable to read commit log)";
}
try {
const mergeBase = execSync(`git merge-base HEAD ${branch}`, {
cwd: rootDir,
encoding: "utf-8",
}).trim();
diffStat = execSync(`git diff ${mergeBase}..${branch} --stat`, {
cwd: rootDir,
encoding: "utf-8",
}).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
await store.updateTask(taskId, { status: "merging" });
const mergeAttempt = async (attemptNum: 1 | 2 | 3): Promise<boolean> => {
mergerLog.log(`${taskId}: merge attempt ${attemptNum}/3...`);
// Normalize buildCommand: treat empty string as undefined
const buildCommand = settings.buildCommand?.trim() || undefined;
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,
buildCommand,
}, 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" });
} catch { /* ignore cleanup errors */ }
}
return false;
} catch (error: any) {
// Check if it's a build verification failure
if (error.message?.includes("Build verification failed")) {
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" });
} 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" });
} 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 statsOutput = execSync("git show --shortstat --format= HEAD", {
cwd: rootDir,
stdio: "pipe",
encoding: "utf-8",
}).trim();
const normalized = statsOutput.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 {
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
result.branchDeleted = true;
} catch {
try {
execSync(`git branch -D "${branch}"`, { cwd: rootDir, stdio: "pipe" });
result.branchDeleted = 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 {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
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
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;
buildCommand?: string;
}
/** 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,
buildCommand,
} = 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 {
execSync(`git merge --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
} 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 = getConflictedFiles(rootDir);
if (conflictedFiles.length > 0 || mergeExitedWithConflicts) {
// Classify each conflicted file
const classified = conflictedFiles.map((file) => ({
file,
type: classifyConflict(file, rootDir),
}));
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") {
resolveWithOurs(file, rootDir);
} else if (type === "generated-theirs") {
resolveWithTheirs(file, rootDir);
} else if (type === "trivial-whitespace") {
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";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
}
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`);
return true;
}
// No conflicts but has staged changes - continue to AI for commit message
}
} else {
// Attempt 1: Standard merge
execSync(`git merge --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
// 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`);
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 = 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,
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}`);
}
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 } = params;
mergerLog.log(`${taskId}: attempting merge with -X theirs strategy`);
try {
// Use -X theirs to auto-resolve conflicts favoring the incoming branch
execSync(`git merge -X theirs --squash "${branch}"`, {
cwd: rootDir,
stdio: "pipe",
});
// 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
return true;
}
// Commit with fallback message
const escapedLog = commitLog.replace(/"/g, '\\"');
const fallbackPrefix = includeTaskId ? `feat(${taskId})` : "feat";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch} (auto-resolved)" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
mergerLog.log(`${taskId}: committed with -X theirs auto-resolution`);
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;
buildCommand?: string;
}
/**
* Run the AI agent to resolve conflicts and/or write commit message.
* Returns { success: true } on success, { success: false, error: string } on build failure.
* Throws on agent errors or unrecoverable failures.
*/
async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: boolean; error?: string }> {
const {
store,
rootDir,
taskId,
branch,
commitLog,
diffStat,
includeTaskId,
hasConflicts,
simplifiedContext,
options,
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 mergerSystemPrompt = buildSystemPromptWithInstructions(
buildMergeSystemPrompt(includeTaskId, settings.agentPrompts),
mergerInstructions,
);
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,
});
options.onSession?.(session);
try {
// Build appropriate prompt
const prompt = buildMergePrompt({
taskId,
branch,
commitLog: simplifiedContext ? "(see branch commits)" : commitLog,
diffStat,
hasConflicts,
simplifiedContext,
buildCommand,
});
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}`);
},
});
// 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";
execSync(
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
{ cwd: rootDir, stdio: "pipe" },
);
} 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;
buildCommand?: string;
}
function buildMergePrompt(params: MergePromptParams): string {
const { taskId, branch, commitLog, diffStat, hasConflicts, simplifiedContext, buildCommand } = params;
const parts = [
`Finalize the merge of branch \`${branch}\` for task ${taskId}.`,
"",
"## Branch commits",
"```",
commitLog,
"```",
];
if (!simplifiedContext) {
parts.push(
"",
"## Files changed",
"```",
diffStat,
"```",
);
}
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.",
);
} 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.",
);
}
// 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}${result.error}`);
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 {
const output = execSync(scriptCommand, {
cwd: rootDir,
encoding: "utf-8",
timeout: 120_000,
stdio: ["pipe", "pipe", "pipe"],
});
return { success: true, output: output.trim() };
} catch (err: any) {
const stderr = err.stderr?.toString()?.trim() || "";
const stdout = err.stdout?.toString()?.trim() || "";
const exitCode = err.status;
const parts: string[] = [];
if (exitCode !== undefined) parts.push(`Exit code: ${exitCode}`);
if (stdout) parts.push(`stdout: ${stdout}`);
if (stderr) parts.push(`stderr: ${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);
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: postMergeSystemPrompt,
tools: toolMode,
defaultProvider: stepProvider,
defaultModelId: stepModelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
});
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);
}