diff --git a/.changeset/fn-5633-standalone-ai-merge.md b/.changeset/fn-5633-standalone-ai-merge.md index 13e99bd68..5a4700a92 100644 --- a/.changeset/fn-5633-standalone-ai-merge.md +++ b/.changeset/fn-5633-standalone-ai-merge.md @@ -16,6 +16,16 @@ How it works: Settings: `merger.mode` (`ai` default / `deterministic` legacy), `merger.reviewerModel`, `merger.maxReviewPasses` (default 3), surfaced in Settings → Merge. When AI merge is on, the legacy merge-mechanics settings (integration worktree, conflict strategy, overlap guard, post-merge audit, direct-commit routing) are hidden since they do not apply. +Commit message: the AI agent writes the squash commit subject as a concise summary of the actual changes (not just the task title), and every landed squash carries the board-association trailers — `Fusion-Task-Id: ` plus the canonical lineage trailer when the task has a `lineageId` — guaranteed via an idempotent amend even if the agent omits them, so the board associates the commit with the task. + +Verification: the merge agent is instructed to run the project's tests, type-check, and lint after resolving the merge and to fix any NEW failure the merge introduced (without being on the hook for pre-existing breakage) before committing. + +Editable prompt: the AI merge agent's base persona is the editable "merger" role prompt (Settings → Prompts); the non-negotiable clean-room / verification / commit-trailer rules are always appended so a custom prompt can't drop them. + +Reviewer model: the reviewer agent uses the project's reviewer/validator model lane (`resolveValidatorSettingsModel`: project validator → global validator → project default), not a merge-specific setting. + +No-branch guard: a missing task branch is a benign no-op only when the task was never executed or was already merged (branch cleaned up on re-process); if the task was executed (`baseCommitSha` recorded) and was never merged, the merge fails loudly rather than silently marking the task done. + The legacy `aiMergeTask` pipeline is retained unchanged and used when `merger.mode: "deterministic"`. Tests: `merger-ai.test.ts` covers the verdict parser, clean merge, blocking hard-fail (no advance), advisory land, empty no-op, per-task target branch isolation, missing-target-branch error, and `landSquash` (clean ff, other-branch update-ref, dirty stash-restore, AI-resolved restore conflict). Engine merge-orchestration tests that assert the legacy path are pinned to `merger.mode: "deterministic"`. diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 0fa915c59..b8cdc1b54 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -188,6 +188,7 @@ const defaultSettings = { overlapIgnorePaths: [], autoMerge: true, mergeStrategy: "direct", + merger: { mode: "deterministic" }, directMergeCommitStrategy: "auto", mergeIntegrationWorktree: "reuse-task-worktree", pushAfterMerge: false, diff --git a/packages/engine/src/__tests__/merger-ai.test.ts b/packages/engine/src/__tests__/merger-ai.test.ts index 161f6eb5a..af1111437 100644 --- a/packages/engine/src/__tests__/merger-ai.test.ts +++ b/packages/engine/src/__tests__/merger-ai.test.ts @@ -115,6 +115,20 @@ describe("parseReviewVerdict", () => { expect(buildReviewSystemPrompt().toLowerCase()).toContain("read-only"); expect(buildMergeSystemPrompt().toLowerCase()).toContain("conflict"); }); + + it("merge system prompt enforces new-breakage verification + uses the editable merger prompt", () => { + expect(buildMergeSystemPrompt().toLowerCase()).toContain("type-check"); + expect(buildMergeSystemPrompt()).toMatch(/new failure/i); + // A custom 'merger' role prompt is incorporated as the base, while the hard + // rules (verification + trailers) are still appended. + const cfg = { + templates: [{ id: "custom-merger", role: "merger", name: "Custom", prompt: "CUSTOM MERGER PERSONA" }], + roleAssignments: { merger: "custom-merger" }, + } as any; + const p = buildMergeSystemPrompt(cfg); + expect(p).toContain("CUSTOM MERGER PERSONA"); + expect(p).toContain("Verify before committing"); + }); }); describe("runAiMerge", () => { @@ -134,11 +148,28 @@ describe("runAiMerge", () => { expect(mainAfter).not.toBe(mainBefore); // The squash landed the feature file. expect(existsSync(join(dir, "feature.txt"))).toBe(true); + // The landed commit carries the board-association trailer even though the + // (mock) merge agent committed without it — ensureTaskTrailersOnHead adds it. + expect(git(dir, "log -1 --pretty=%B main")).toContain("Fusion-Task-Id: FN-1"); // Task moved to done + event emitted. expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done"); expect(emitted.some((e) => e.event === "task:merged")).toBe(true); }); + it("includes the lineage trailer when the task has a lineageId", async () => { + const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); + const { store } = makeStore(dir, { lineageId: "lin-abc123" }); + + await runAiMerge(store, dir, "FN-1", { manual: true }, { + mergeAgent: realMergeAgent("fusion/fn-1"), + reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), + }); + + const msg = git(dir, "log -1 --pretty=%B main"); + expect(msg).toContain("Fusion-Task-Id: FN-1"); + expect(msg).toContain("lin-abc123"); // canonical lineage trailer + }); + it("hard-fails (no advance) on a blocking veto past the budget", async () => { const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); const { store } = makeStore(dir); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index d1f21aa9c..74e81e963 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -34,9 +34,12 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + buildTaskLineageTrailer, getTaskMergeBlocker, + resolveAgentPrompt, resolveTaskMergeTarget, resolveValidatorSettingsModel, + type AgentPromptsConfig, type MergeResult, type Settings, type Task, @@ -78,6 +81,30 @@ async function gitOk(args: string[], cwd: string): Promise { } } +const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id"; + +/** Trailers that associate the squash commit with its board task: the + * `Fusion-Task-Id` trailer plus the canonical lineage trailer when available. + * These are what the board's commit→task association parses. */ +function taskTrailers(taskId: string, lineageId?: string | null): string[] { + const trailers = [`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`]; + if (lineageId) trailers.push(buildTaskLineageTrailer(lineageId)); + return trailers; +} + +/** Idempotently guarantee the task trailers are on HEAD — a safety net so board + * association holds even if the AI agent omitted them from its commit. */ +async function ensureTaskTrailersOnHead(mergeRoot: string, trailers: string[]): Promise { + const existing = await git(["log", "-1", "--pretty=%B"], mergeRoot).catch(() => ""); + const missing = trailers.filter((t) => !existing.includes(t)); + if (missing.length === 0) return; + const args = ["-c", "trailer.ifExists=addIfDifferent", "commit", "--amend", "--no-edit"]; + for (const t of missing) args.push("--trailer", t); + await git(args, mergeRoot).catch((err: unknown) => { + aiMergeLog.warn(`failed to amend task trailers onto squash (${err instanceof Error ? err.message : String(err)})`); + }); +} + // --------------------------------------------------------------------------- // Pure helpers (unit-tested) // --------------------------------------------------------------------------- @@ -143,11 +170,17 @@ function extractRejectReasons(lines: string[], verdictLineIndex: number): string return reasons; } -export function buildMergeSystemPrompt(): string { +export function buildMergeSystemPrompt(agentPrompts?: AgentPromptsConfig): string { + // Base persona is the editable "merger" agent prompt (Settings → Prompts); + // the non-negotiable clean-room / verification / commit-trailer rules below + // are always appended so a custom prompt can't drop them. + const base = resolveAgentPrompt("merger", agentPrompts).trim(); return [ - "You are merging a task branch into the integration branch. You are on a", - "CLEAN, detached checkout at the integration branch's current tip. Your job", - "is to land the task branch's work as a single commit.", + base, + base ? "" : undefined, + "## AI merge — clean room", + "You are on a CLEAN, detached checkout at the integration branch's current", + "tip. Land the task branch's work as a single commit.", "", "Constraints:", " - Resolve every conflict in favor of the task branch's intent; never drop", @@ -156,7 +189,24 @@ export function buildMergeSystemPrompt(): string { " - Do NOT push, force-push, or run `git update-ref` / `git reset --hard`", " on any other branch. Only commit on this detached HEAD.", " - Finish with exactly ONE new commit on HEAD containing the task's work.", - ].join("\n"); + "", + "Verify before committing:", + " - After resolving the merge, run the project's checks — tests, type-check,", + " and lint (discover them from the project config / package.json scripts,", + " e.g. test / typecheck / lint / build).", + " - FIX any NEW failure the merge or conflict resolution introduced (a check", + " that passed on the task branch or the integration tip but fails on the", + " merged tree). You do not need to fix failures that were already broken on", + " the integration branch beforehand, but never commit a merge that adds new", + " test, type-check, or lint failures.", + "", + "Commit message:", + " - The subject line must CONCISELY SUMMARIZE the squashed changes in", + " imperative mood (e.g. \"add X\", \"fix Y\") based on the actual diff — do", + " not just restate the task title.", + " - Include the task-id prefix and the trailer lines EXACTLY as given in the", + " task instructions (they associate the commit with the board task).", + ].filter((l) => l !== undefined).join("\n"); } export function buildMergePrompt(input: { @@ -164,16 +214,28 @@ export function buildMergePrompt(input: { branch: string; integrationBranch: string; tipSha: string; - subject: string; + /** Task title — a HINT for the summary, not the literal subject. */ + taskTitle?: string; + /** Whether to prefix the subject with the task id. */ + includeTaskId: boolean; + /** Required trailers to append (board association). */ + trailers: string[]; correctiveReasons?: string[]; }): string { + const subjectShape = input.includeTaskId + ? `"${input.taskId}: "` + : `""`; + const trailerArgs = input.trailers.map((t) => ` -m ${JSON.stringify(t)}`).join(""); const lines = [ `Merge branch "${input.branch}" into "${input.integrationBranch}" (HEAD is detached at ${short(input.tipSha)}).`, "", "Steps:", ` 1. Run: git merge --squash ${input.branch}`, " 2. If there are conflicts, resolve them (favor the task's intent), then `git add` the resolved files.", - ` 3. Commit the staged result as a single commit: git commit -m ${JSON.stringify(input.subject)}`, + " 3. Commit the staged result as a SINGLE commit whose subject summarizes the", + ` actual changes${input.taskTitle ? ` (task title hint: ${JSON.stringify(input.taskTitle)})` : ""}, including the required trailers:`, + ` git commit -m ${subjectShape}${trailerArgs}`, + " Keep the trailer line(s) verbatim — they link the commit to the board task.", " 4. Verify `git log --oneline ${tip}..HEAD` shows exactly one new commit and `git status` is clean.".replace("${tip}", short(input.tipSha)), "", "If `git merge --squash` reports the branch is already up to date (nothing to", @@ -630,10 +692,13 @@ export async function runAiMerge( } const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3)); - const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt()); + const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt(settings.agentPrompts)); const reviewAgent = deps.reviewAgent ?? makeReviewAgent(store, settings, taskId, options, audit); const stashResolveAgent = deps.stashResolveAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildStashResolveSystemPrompt()); - const subject = (task.title?.trim() || `Merge ${taskId}`).split("\n")[0]; + const includeTaskId = settings.includeTaskIdInCommit !== false; + // Trailers that link the squash commit to the board task (FN-id + lineage). + const trailers = taskTrailers(taskId, task.lineageId); + const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined; await setStatus("merging"); let advanceRetries = 0; @@ -652,7 +717,7 @@ export async function runAiMerge( // 2 + 3. Merge + review loop (corrective passes). const squashSha = await mergeAndReview({ - mergeRoot, branch, integrationBranch, tipSha, subject, taskId, + mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal, }); @@ -694,7 +759,9 @@ async function mergeAndReview(input: { branch: string; integrationBranch: string; tipSha: string; - subject: string; + taskTitle?: string; + includeTaskId: boolean; + trailers: string[]; taskId: string; maxPasses: number; mergeAgent: (cwd: string, prompt: string) => Promise; @@ -704,7 +771,7 @@ async function mergeAndReview(input: { setStatus: (status: string | null) => Promise; signal?: AbortSignal; }): Promise { - const { mergeRoot, branch, integrationBranch, tipSha, subject, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input; + const { mergeRoot, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input; let priorReasons: string[] = []; for (let attempt = 0; ; attempt++) { @@ -719,13 +786,18 @@ async function mergeAndReview(input: { await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing: ${priorReasons.join("; ")}`); } await mergeAgent(mergeRoot, buildMergePrompt({ - taskId, branch, integrationBranch, tipSha, subject, + taskId, branch, integrationBranch, tipSha, taskTitle, includeTaskId, trailers, correctiveReasons: priorReasons.length ? priorReasons : undefined, })); - const head = await git(["rev-parse", "HEAD"], mergeRoot); + let head = await git(["rev-parse", "HEAD"], mergeRoot); if (head === tipSha) return null; // empty merge — nothing landed + // Guarantee the board-association trailers are on the squash even if the + // agent omitted them — this amends HEAD, so re-read the sha afterwards. + await ensureTaskTrailersOnHead(mergeRoot, trailers); + head = await git(["rev-parse", "HEAD"], mergeRoot); + await setStatus("reviewing"); const diffStat = await git(["diff", "--stat", `${tipSha}..${head}`], mergeRoot); const verdict = parseReviewVerdict(await reviewAgent(mergeRoot, buildReviewPrompt({