feat(FN-5633): standalone AI merge path (clean-room merge + AI reviewer)

New default merge path (merger.mode="ai"), self-contained in merger-ai.ts and
dispatched from ProjectEngine.onMerge instead of the legacy aiMergeTask pipeline
(kept for merger.mode="deterministic").

Flow: clean-room detached worktree at the target branch tip → AI agent merges
the task branch + squashes (resolving conflicts) → fresh read-only AI reviewer
audits with corrective retries (blocking vs advisory; advisory lands, unfixable
correctness hard-fails via AiMergeBlockedError; fail-safe verdict parsing) →
land via `git merge --ff-only` when the checkout is on the target (else
update-ref CAS) → sync the local checkout (stash → ff → restore; AI reconciles
a conflicting restore and keeps the original edits in a backup stash;
un-stashable dirt advances the ref + warns) → finalize (delete task branch —
never the integration branch — task→done, remove temp worktree).

- Per-task target branch honored (falls back to the default integration
  branch); local checkout synced only when on that target.
- Structurally immune to the dirty-clobber and stale-base/non-FF bug classes of
  the legacy path (clean room + FF-by-construction).
- Progress surfaced on the task status pill + task log stream.
- Clear error when the target branch has no local ref.

Settings: merger.mode / merger.reviewerModel / merger.maxReviewPasses, surfaced
in Settings → Merge; legacy merge-mechanics settings hidden when AI mode is on.

Tests: merger-ai.test.ts (verdict parser, clean merge, blocking hard-fail,
advisory land, empty no-op, target-branch isolation, missing-target error,
landSquash clean/other-branch/dirty-restore/AI-resolved). Legacy
merge-orchestration tests pinned to deterministic mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-28 18:50:23 -07:00
parent e75c4dae28
commit 29ac58f8d0
13 changed files with 1242 additions and 8 deletions

View File

@@ -4586,6 +4586,63 @@ export function SettingsModal({
<small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</details>
</div>
<div className="form-group">
<label htmlFor="mergerMode">AI merge</label>
<select
id="mergerMode"
className="select"
value={form.merger?.mode ?? "ai"}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), mode: e.target.value as "ai" | "deterministic" } }))
}
>
<option value="ai">AI merge (default) — AI merges in a clean room, an AI reviewer audits with retries, then lands</option>
<option value="deterministic">Deterministic (legacy) — rebase / conflict-strategy / audit pipeline</option>
</select>
<details className="settings-option-details">
<summary>More details</summary>
<small>
AI mode merges the task branch into an isolated clean-room checkout at the target
branch&apos;s tip, has an AI reviewer audit the squash (with corrective retries —
advisory concerns land with a logged warning, an unfixable correctness concern
hard-fails), then fast-forwards the target branch and syncs your local checkout
(AI reconciles a conflicting restore). Each task merges to its own target branch,
or the default integration branch. <strong>The legacy merge settings below do not
apply while AI merge is on.</strong>
</small>
</details>
</div>
{(form.merger?.mode ?? "ai") === "ai" && (
<>
<div className="form-group">
<label htmlFor="mergerMaxReviewPasses">Max AI review passes</label>
<input
id="mergerMaxReviewPasses"
type="number"
min={0}
max={10}
value={form.merger?.maxReviewPasses ?? 3}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))
}
/>
<small>AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3.</small>
</div>
<div className="form-group">
<label htmlFor="mergerReviewerModel">Reviewer model (optional)</label>
<input
id="mergerReviewerModel"
type="text"
placeholder="provider/modelId — defaults to the merger model"
value={form.merger?.reviewerModel ?? ""}
onChange={(e) =>
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), reviewerModel: e.target.value || undefined } }))
}
/>
<small>Model for the read-only reviewer agent (a cheaper/faster model is fine). Leave blank to reuse the merger model.</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="testMode" className="checkbox-label">
<input
@@ -4757,7 +4814,7 @@ export function SettingsModal({
</small>
</details>
</div>
{form.mergeStrategy !== "pull-request" && (
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="directMergeCommitStrategy">Direct merge commit routing</label>
@@ -4998,6 +5055,8 @@ export function SettingsModal({
<small>When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.</small>
</details>
</div>
{(form.merger?.mode ?? "ai") !== "ai" && (
<>
<div className="form-group">
<label htmlFor="smartConflictResolution" className="checkbox-label">
<input
@@ -5087,6 +5146,8 @@ export function SettingsModal({
Controls the post-merge audit gate. <strong>Warn</strong> (default) logs findings but auto-completes the merge. <strong>Block</strong> is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. <strong>Off</strong> skips the audit entirely. Switching to Off is recommended only if you trust your branches don&apos;t silently drop edits.
</small>
</div>
</>
)}
<div className="form-group">
<label htmlFor="pushAfterMerge" className="checkbox-label">
<input