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

@@ -0,0 +1,21 @@
---
"@runfusion/fusion": patch
---
feat(FN-5633): standalone AI merge path (clean-room merge + AI reviewer)
Adds a self-contained AI merge path (`merger.mode: "ai"`, the new default) that the engine dispatches to instead of the legacy `aiMergeTask` pipeline. It does not share the legacy scaffolding (prerebase / conflict-strategy ladder / post-merge audit / transient self-heal), which was buggy and error-prone.
How it works:
- **Clean room**: a throwaway detached worktree is created at the target branch's current tip, so the user's real checkout is never the merge surface — dirty files cannot be clobbered and the landing is a fast-forward by construction.
- **AI merge**: an AI agent merges the task branch into the clean room and produces one squash commit, resolving conflicts in favor of the task's intent.
- **AI reviewer with retries**: a fresh read-only reviewer audits the squash (completeness / collateral / conflict-soundness) and classifies any veto blocking vs advisory. It drives up to `merger.maxReviewPasses` corrective re-merges. After the budget, advisory concerns land with a logged warning; an unfixable BLOCKING (correctness) concern hard-fails (`AiMergeBlockedError`) rather than ship wrong code. Verdict parsing fails safe to blocking.
- **Per-task target branch**: each task merges into its own target branch (or the default integration branch). The local checkout is only synced when it is on that target.
- **Local checkout sync**: when the checkout is on the target branch, the ref + working tree advance together via `git merge --ff-only` (dirty state read accurately before the move); dirty edits are stashed, fast-forwarded, and restored — and if the restore conflicts the AI merger reconciles them (the original edits are also kept in a stash as a backup). A checkout on a different branch is advanced via `update-ref` and left untouched. Un-stashable dirty state advances the ref and leaves the working tree with a warning. Concurrent advances trigger a bounded rebuild on the new tip.
- **Status + logs**: progress (merging / reviewing / corrective passes / landing / blocked / landed) is written to the task status pill and the task log stream.
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.
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"`.

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";

View File

@@ -262,6 +262,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
],
prerebaseDivergenceThreshold: 50,
mergeConflictStrategy: "smart-prefer-main",
merger: { mode: "ai", reviewerModel: undefined, maxReviewPasses: 3 },
mergeDiffVolumeMinLines: undefined,
mergeDiffVolumeThreshold: undefined,
mergeDiffVolumeAllowlist: undefined,

View File

@@ -313,6 +313,36 @@ export function normalizeMergeAuditAutoRecovery(value: unknown): MergeAuditAutoR
: "ai-assisted";
}
export const MERGER_MODES = ["ai", "deterministic"] as const;
/**
* Merge execution path (FN-5633).
* - "ai" (default): the standalone AI merge path — a clean-room worktree where
* an AI agent merges the task branch and an AI reviewer audits it (with
* corrective retries) before a fast-forward landing. Bypasses the legacy
* scaffolding entirely.
* - "deterministic": the legacy `aiMergeTask` pipeline (prerebase /
* conflict-strategy ladder / post-merge audit / transient self-heal).
*/
export type MergerMode = (typeof MERGER_MODES)[number];
export function normalizeMergerMode(value: unknown): MergerMode {
return typeof value === "string" && (MERGER_MODES as readonly string[]).includes(value)
? (value as MergerMode)
: "ai";
}
/** Settings for the AI merge path (FN-5633). */
export interface MergerSettings {
/** Which merge path to use. Default: "ai". */
mode?: MergerMode;
/** Optional `provider/modelId` override for the read-only reviewer agent. */
reviewerModel?: string;
/** How many AI corrective rounds before landing the best result (advisory) or
* hard-failing (blocking). Default: 3. */
maxReviewPasses?: number;
}
export const AUTO_RECOVERY_MODES = ["off", "deterministic-only", "programmatic", "ai-assisted"] as const;
export type AutoRecoveryMode = (typeof AUTO_RECOVERY_MODES)[number];
@@ -3015,6 +3045,10 @@ export interface ProjectSettings {
/** Strategy used when a merge conflict can't be resolved by AI. See
* {@link MergeConflictStrategy}. Default: "smart". */
mergeConflictStrategy?: MergeConflictStrategy;
/** AI merge path configuration (FN-5633). See {@link MergerSettings}.
* When mode is "ai" (default), the standalone AI merge path is used and the
* legacy merge settings above/below it do not apply. */
merger?: MergerSettings;
/** Minimum branch net line volume before the pre-commit diff-volume gate evaluates a file. Default applied at read site: 20. */
mergeDiffVolumeMinLines?: number;
/** Minimum staged/branch-net ratio required by the pre-commit diff-volume gate. Default applied at read site: 0.2. */

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

View File

@@ -113,6 +113,9 @@ function makeStore({
globalPause: false,
enginePaused: false,
pollIntervalMs: 15_000,
// These tests mock + assert aiMergeTask (the legacy merge path); pin the
// legacy merger so onMerge routes there rather than the AI merge path.
merger: { mode: "deterministic" },
...settings,
})),
listTasks: vi.fn(async () => listedTasks ?? taskSequence.filter((task): task is MockTask => Boolean(task))),

View File

@@ -0,0 +1,289 @@
import { describe, it, expect, vi, afterAll } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import {
runAiMerge,
landSquash,
parseReviewVerdict,
buildMergeSystemPrompt,
buildReviewSystemPrompt,
REVIEW_VERDICT_MARKER,
AiMergeBlockedError,
} from "../merger-ai.js";
const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
const tracked = new Set<string>();
afterAll(() => {
for (const d of tracked) {
try { rmSync(d, RM); } catch { /* best effort */ }
}
});
function git(cwd: string, args: string): string {
return execSync(`git ${args}`, { cwd, encoding: "utf-8" }).trim();
}
/** A repo on `main` with one base commit + a task branch carrying one change. */
function initRepoWithBranch(opts: { branch: string; conflict?: boolean } = { branch: "fusion/fn-1" }): { dir: string } {
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-test-"));
tracked.add(dir);
git(dir, "init -q -b main");
git(dir, "config user.email t@t.t");
git(dir, "config user.name t");
writeFileSync(join(dir, "base.txt"), "base\n");
git(dir, "add -A");
git(dir, "commit -q -m base");
git(dir, `checkout -q -b ${opts.branch}`);
writeFileSync(join(dir, "feature.txt"), "feature work\n");
if (opts.conflict) writeFileSync(join(dir, "base.txt"), "base\nbranch-change\n");
git(dir, "add -A");
git(dir, "commit -q -m 'feat: work'");
git(dir, "checkout -q main");
if (opts.conflict) {
writeFileSync(join(dir, "base.txt"), "base\nmain-change\n");
git(dir, "add -A");
git(dir, "commit -q -m 'main: divergent'");
}
return { dir };
}
function makeStore(_dir: string, taskOverrides: Record<string, unknown> = {}, settingsOverrides: Record<string, unknown> = {}) {
const task: any = {
id: "FN-1",
column: "in-review",
status: null,
branch: "fusion/fn-1",
worktree: null,
title: "do the thing",
steps: [],
baseBranch: undefined,
...taskOverrides,
};
const emitted: Array<{ event: string; payload: unknown }> = [];
const logs: string[] = [];
const store: any = {
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 1 }, ...settingsOverrides })),
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => { Object.assign(task, patch); return task; }),
moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }),
emit: vi.fn((event: string, payload: unknown) => { emitted.push({ event, payload }); }),
logEntry: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
appendAgentLog: vi.fn(async (_id: string, m: string) => { logs.push(m); }),
};
return { store, task, emitted, logs };
}
// A merge agent that actually performs the squash merge with git.
function realMergeAgent(branch: string) {
return vi.fn(async (cwd: string) => {
try {
execSync(`git merge --squash ${branch}`, { cwd, stdio: "pipe" });
} catch {
// conflict — resolve by taking the branch side, then continue
execSync("git checkout --theirs . || true", { cwd, stdio: "pipe", shell: "/bin/bash" } as any);
execSync("git add -A", { cwd, stdio: "pipe" });
}
execSync("git add -A", { cwd, stdio: "pipe" });
execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" });
});
}
describe("parseReviewVerdict", () => {
it("approves cleanly", () => {
expect(parseReviewVerdict("ok\nREVIEW_VERDICT: approve")).toEqual({ verdict: "approve", reasons: [] });
});
it("rejects with blocking severity by default", () => {
expect(parseReviewVerdict("REVIEW_VERDICT: reject\n- dropped a hunk")).toEqual({
verdict: "reject", severity: "blocking", reasons: ["dropped a hunk"],
});
});
it("parses advisory severity and drops the SEVERITY line from reasons", () => {
expect(parseReviewVerdict("REVIEW_VERDICT: reject\nSEVERITY: advisory\n- nit")).toEqual({
verdict: "reject", severity: "advisory", reasons: ["nit"],
});
});
it("fails safe to blocking on empty/garbled output", () => {
expect(parseReviewVerdict("").severity).toBe("blocking");
expect(parseReviewVerdict("looks fine ship it").verdict).toBe("reject");
});
it("system prompts mention read-only review + the verdict marker", () => {
expect(buildReviewSystemPrompt()).toContain(REVIEW_VERDICT_MARKER);
expect(buildReviewSystemPrompt().toLowerCase()).toContain("read-only");
expect(buildMergeSystemPrompt().toLowerCase()).toContain("conflict");
});
});
describe("runAiMerge", () => {
it("merges a clean branch, advances main, and finalizes the task", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store, emitted } = makeStore(dir);
const mainBefore = git(dir, "rev-parse main");
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.merged).toBe(true);
expect(result.commitSha).toBeTruthy();
const mainAfter = git(dir, "rev-parse main");
expect(mainAfter).not.toBe(mainBefore);
// The squash landed the feature file.
expect(existsSync(join(dir, "feature.txt"))).toBe(true);
// Task moved to done + event emitted.
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
});
it("hard-fails (no advance) on a blocking veto past the budget", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir);
const mainBefore = git(dir, "rev-parse main");
await expect(runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: reject\nSEVERITY: blocking\n- dropped a hunk"),
})).rejects.toBeInstanceOf(AiMergeBlockedError);
// Integration branch must NOT have advanced.
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
it("lands an advisory veto past the budget (no human)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir);
const mainBefore = git(dir, "rev-parse main");
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: reject\nSEVERITY: advisory\n- naming nit"),
});
expect(result.merged).toBe(true);
expect(git(dir, "rev-parse main")).not.toBe(mainBefore);
});
it("finalizes as a no-op when the branch has no net changes", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
// Make the branch identical to main (no net change) by merging it into main first.
git(dir, "merge -q fusion/fn-1");
const { store } = makeStore(dir);
const mainBefore = git(dir, "rev-parse main");
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
// Empty merge: --squash reports up-to-date; leave HEAD unchanged.
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.noOp).toBe(true);
expect(result.merged).toBe(false);
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
it("throws a clear error when the task's target branch has no local ref", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { store } = makeStore(dir, { baseBranch: "release/9.9" }); // never created locally
await expect(runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
})).rejects.toThrow(/no local ref/);
});
it("only merges/advances the task's own target branch, leaving a default-branch checkout untouched", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
// Create a separate target branch the task should merge into.
git(dir, "branch release");
const releaseBefore = git(dir, "rev-parse release");
const mainBefore = git(dir, "rev-parse main");
// Stay checked out on main (NOT the task's target) → local sync must skip.
const { store } = makeStore(dir, { baseBranch: "release" });
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent("fusion/fn-1"),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.merged).toBe(true);
// release advanced, main did not.
expect(git(dir, "rev-parse release")).not.toBe(releaseBefore);
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
});
describe("landSquash (advance + local-checkout sync)", () => {
function auditStub() { return { git: vi.fn(async () => {}) } as any; }
/** Build a squash commit that descends from the current main tip, leaving
* main checked out and clean AT the tip. Returns { tipSha, squashSha }. */
function makeDescendantSquash(dir: string, mutate: () => void): { tipSha: string; squashSha: string } {
const tipSha = git(dir, "rev-parse main");
git(dir, "checkout -q -b squash-tmp");
mutate();
git(dir, "add -A");
git(dir, "commit -q -m squash");
const squashSha = git(dir, "rev-parse HEAD");
git(dir, "checkout -q main"); // back on target, clean, at tipSha
return { tipSha, squashSha };
}
it("fast-forwards a clean checkout on the target branch (advances ref + worktree)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "landed.txt"), "landed\n"));
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub() });
expect(res).toEqual({ outcome: "advanced", localSync: "ff" });
expect(git(dir, "rev-parse main")).toBe(squashSha);
expect(existsSync(join(dir, "landed.txt"))).toBe(true);
});
it("advances the ref but does not touch a checkout on a different branch", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const tipSha = git(dir, "rev-parse main");
git(dir, "checkout -q -b squash-tmp");
writeFileSync(join(dir, "landed.txt"), "landed\n");
git(dir, "add -A");
git(dir, "commit -q -m squash");
const squashSha = git(dir, "rev-parse HEAD");
git(dir, "checkout -q -b somewhere-else main"); // NOT the target branch
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub() });
expect(res.outcome).toBe("advanced");
expect(res.localSync).toBe("skipped-other-branch");
expect(git(dir, "rev-parse main")).toBe(squashSha); // ref advanced via update-ref
// The user's checkout (somewhere-else) is untouched.
expect(git(dir, "rev-parse --abbrev-ref HEAD")).toBe("somewhere-else");
});
it("stashes dirty edits, fast-forwards, and restores them", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "landed.txt"), "landed\n"));
writeFileSync(join(dir, "mydraft.txt"), "local draft\n"); // dirty, non-conflicting
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub() });
expect(res.localSync).toBe("stash-ff-restore");
expect(existsSync(join(dir, "landed.txt"))).toBe(true);
expect(readFileSync(join(dir, "mydraft.txt"), "utf-8")).toContain("local draft");
});
it("invokes the AI resolver when restoring the stash conflicts, then lands resolved", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
const { tipSha, squashSha } = makeDescendantSquash(dir, () => writeFileSync(join(dir, "base.txt"), "base\nlanded-upstream\n"));
writeFileSync(join(dir, "base.txt"), "base\nmy-local-edit\n"); // dirty edit on the same line → restore conflict
const resolver = vi.fn(async (cwd: string) => {
writeFileSync(join(cwd, "base.txt"), "base\nmy-local-edit\n");
execSync("git add -A", { cwd, stdio: "pipe" });
});
const res = await landSquash({ projectRootDir: dir, mergeRoot: dir, integrationBranch: "main", tipSha, squashSha, taskId: "FN-1", audit: auditStub(), resolveConflicts: resolver });
expect(resolver).toHaveBeenCalled();
expect(res.localSync).toBe("stash-ff-airesolved");
expect(git(dir, "rev-parse main")).toBe(squashSha);
});
});

View File

@@ -232,6 +232,8 @@ const baseSettings: Record<string, unknown> = {
globalPause: false,
enginePaused: false,
pollIntervalMs: 15_000,
// onMerge tests mock + assert aiMergeTask (legacy path); pin legacy mode.
merger: { mode: "deterministic" },
taskStuckTimeoutMs: undefined,
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50_000,

View File

@@ -61,6 +61,7 @@ function createStore(task: Task, sequence: Task[]) {
globalPause: false,
enginePaused: false,
pollIntervalMs: 15_000,
merger: { mode: "deterministic" },
} as Settings)),
listTasks: vi.fn(async () => [task]),
getTask: vi.fn(async () => {

View File

@@ -55,6 +55,7 @@ function createStore(task: Task, taskSequence?: Task[]) {
globalPause: false,
enginePaused: false,
pollIntervalMs: 15_000,
merger: { mode: "deterministic" },
} as Settings)),
listTasks: vi.fn(async () => [task]),
getTask: vi.fn(async () => {

View File

@@ -0,0 +1,805 @@
/**
* Standalone AI merge path (FN-5633).
*
* This is "AI mode" — a self-contained merge implementation that deliberately
* does NOT share the legacy `aiMergeTask` pipeline (prerebase / conflict-strategy
* ladder / transient self-heal), which is buggy and error-prone. The engine
* dispatches here when `merger.mode === "ai"` (the default).
*
* Shape:
* 1. Clean room — create a throwaway detached worktree at the integration
* branch's current tip. The user's real checkout is never used as the merge
* surface, so dirty files cannot be clobbered and the result is a
* fast-forward of the integration ref BY CONSTRUCTION (no stale-base /
* non-FF class).
* 2. AI merges the task branch into that clean checkout and produces one
* squash commit, resolving conflicts in favor of the task's intent.
* 3. A fresh read-only AI reviewer audits the squash. It drives up to
* `merger.maxReviewPasses` corrective rounds. Advisory concerns then land
* with a warning; a BLOCKING (correctness) concern the AI cannot fix
* hard-fails (never ships wrong code). No human is required for the
* common path.
* 4. CAS fast-forward of `refs/heads/<integration>` to the squash (retry on a
* concurrent advance by rebuilding on the new tip).
* 5. Sync the user's local checkout to the new tip — fast-forward if clean,
* stash → ff → restore if dirty (best-effort, never destroys uncommitted
* work) — then finalize (delete branch, task → done, remove temp worktree).
*
* Pure helpers (prompt builders, verdict parser) are exported for unit testing;
* the orchestrator accepts injectable agent functions for the same reason.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
getTaskMergeBlocker,
resolveTaskMergeTarget,
type MergeResult,
type Settings,
type Task,
type TaskStore,
} from "@fusion/core";
import { canonicalFusionBranchName } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { advanceIntegrationBranchRef } from "./merger-ref-update-advance.js";
import { createResolvedAgentSession, resolveMergerSessionModel } from "./agent-session-helpers.js";
import { promptWithFallback } from "./pi.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { checkSessionError } from "./usage-limit-detector.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
import { createLogger } from "./logger.js";
import type { MergerOptions } from "./merger.js";
const execFileAsync = promisify(execFile);
const aiMergeLog = createLogger("merger-ai");
const MAX_CONCURRENT_ADVANCE_RETRIES = 3;
async function git(args: string[], cwd: string, opts: { timeout?: number } = {}): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
encoding: "utf-8",
timeout: opts.timeout ?? 120_000,
maxBuffer: 16 * 1024 * 1024,
});
return stdout.trim();
}
async function gitOk(args: string[], cwd: string): Promise<boolean> {
try {
await git(args, cwd);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested)
// ---------------------------------------------------------------------------
export type AiMergeReviewSeverity = "blocking" | "advisory";
export interface AiMergeReviewVerdict {
verdict: "approve" | "reject";
reasons: string[];
severity?: AiMergeReviewSeverity;
}
export const REVIEW_VERDICT_MARKER = "REVIEW_VERDICT:";
const VERDICT_LINE_RE = /REVIEW_VERDICT:\s*(approve|reject)\b/i;
const SEVERITY_LINE_RE = /SEVERITY:\s*(blocking|advisory)\b/i;
/**
* Parse the reviewer's free-form output. Fail-safe: no/garbled output, or a
* rejection with no explicit severity, is treated as a BLOCKING reject — an
* ambiguous reviewer can never wave wrong code through, nor silently downgrade
* to advisory.
*/
export function parseReviewVerdict(agentText: string | null | undefined): AiMergeReviewVerdict {
const text = (agentText ?? "").trim();
if (!text) return { verdict: "reject", reasons: ["reviewer produced no output"], severity: "blocking" };
const lines = text.split(/\r?\n/);
let verdictLineIndex = -1;
let decision: "approve" | "reject" | null = null;
for (let i = lines.length - 1; i >= 0; i--) {
const m = lines[i].match(VERDICT_LINE_RE);
if (m) {
decision = m[1].toLowerCase() as "approve" | "reject";
verdictLineIndex = i;
break;
}
}
if (!decision) {
return {
verdict: "reject",
reasons: [`reviewer did not emit a "${REVIEW_VERDICT_MARKER} approve|reject" line`],
severity: "blocking",
};
}
if (decision === "approve") return { verdict: "approve", reasons: [] };
const severity: AiMergeReviewSeverity = SEVERITY_LINE_RE.test(text)
? (text.match(SEVERITY_LINE_RE)![1].toLowerCase() as AiMergeReviewSeverity)
: "blocking";
return { verdict: "reject", reasons: extractRejectReasons(lines, verdictLineIndex), severity };
}
function extractRejectReasons(lines: string[], verdictLineIndex: number): string[] {
const reasons: string[] = [];
const inline = lines[verdictLineIndex].replace(VERDICT_LINE_RE, "").replace(/^[\s:–—-]+/, "").trim();
if (inline) reasons.push(inline);
for (let i = verdictLineIndex + 1; i < lines.length; i++) {
if (SEVERITY_LINE_RE.test(lines[i])) continue;
const cleaned = lines[i].replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "").trim();
if (cleaned) reasons.push(cleaned);
}
if (reasons.length === 0) reasons.push("reviewer rejected the merge without a stated reason");
return reasons;
}
export function buildMergeSystemPrompt(): string {
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.",
"",
"Constraints:",
" - Resolve every conflict in favor of the task branch's intent; never drop",
" the task's changes to make a conflict go away.",
" - Do not make edits unrelated to reconciling the two branches.",
" - 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");
}
export function buildMergePrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
subject: string;
correctiveReasons?: string[];
}): string {
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)}`,
" 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",
"merge), do nothing and leave HEAD unchanged.",
];
if (input.correctiveReasons && input.correctiveReasons.length > 0) {
lines.push(
"",
"A prior attempt was REJECTED by review. Redo the merge from the clean tip",
"and address each of these problems:",
...input.correctiveReasons.map((r) => ` - ${r}`),
);
}
return lines.join("\n");
}
export function buildReviewSystemPrompt(): string {
return [
"You are an adversarial, read-only merge reviewer. Do NOT edit, stage, commit,",
"or run any mutating git command. Audit the squash commit that is about to be",
"merged into the integration branch and decide whether it is safe to land.",
"",
"Investigate with read-only commands (git show, git diff, git log, cat, grep).",
"Judge on three axes:",
" 1. Completeness — does the squash contain ALL of the task branch's intended",
" changes? Flag any hunk silently dropped during conflict resolution.",
" 2. No collateral — does it touch only files within the task's footprint?",
" 3. Conflict soundness — were conflicts resolved coherently (both sides'",
" intent preserved), not by blindly discarding one side?",
"",
"Bias toward rejection when uncertain.",
"",
`End with a single decision line: "${REVIEW_VERDICT_MARKER} approve" or`,
`"${REVIEW_VERDICT_MARKER} reject". When rejecting, add a "SEVERITY:" line:`,
" - SEVERITY: blocking — a correctness problem (dropped/lost task changes,",
" incomplete squash, or a conflict resolution that discards intent). The",
" merge must NOT land if this is unfixable.",
" - SEVERITY: advisory — a quality/style concern that does not risk",
" correctness; acceptable to land if unresolved.",
"Then list each concrete reason as a bullet.",
].join("\n");
}
export function buildReviewPrompt(input: {
taskId: string;
branch: string;
integrationBranch: string;
tipSha: string;
squashSha: string;
diffStat: string;
priorReasons?: string[];
}): string {
const lines = [
`Review the squash merge for task ${input.taskId} (branch ${input.branch}${input.integrationBranch}).`,
"",
`Integration tip: ${short(input.tipSha)}`,
`Squash commit: ${short(input.squashSha)}`,
"",
"Inspect with:",
` git show ${input.squashSha}`,
` git diff ${input.tipSha}..${input.squashSha}`,
"",
"Files changed (git diff --stat):",
input.diffStat.trim() || "(none reported)",
];
if (input.priorReasons && input.priorReasons.length > 0) {
lines.push(
"",
"A prior pass rejected an earlier attempt for these reasons — confirm they",
"are now resolved:",
...input.priorReasons.map((r) => ` - ${r}`),
);
}
return lines.join("\n");
}
export function buildStashResolveSystemPrompt(): string {
return [
"You are resolving a conflict between the user's restored local working-tree",
"edits and the freshly-merged integration branch. The user's uncommitted work",
"was stashed, the checkout fast-forwarded to the new tip, and re-applying the",
"stash produced conflicts.",
"",
"Resolve every conflict marker so BOTH sides are preserved: keep the user's",
"local intent AND the upstream changes that just landed. Stage each resolved",
"file with `git add`.",
"",
"Do NOT commit, stash, reset, checkout a different branch, or run update-ref.",
"Leave the resolved changes in the working tree as the user's uncommitted edits.",
].join("\n");
}
export function buildStashResolvePrompt(conflictedFiles: string[]): string {
return [
"Re-applying your stashed local changes onto the updated branch conflicted.",
"",
"Conflicted files:",
...conflictedFiles.map((f) => ` - ${f}`),
"",
"Resolve each file's conflict markers (preserve both the local edits and the",
"upstream changes), then `git add` it. Do not commit.",
].join("\n");
}
function short(sha: string): string {
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/** Non-transient hard fail: the AI could not produce a correct merge within the
* review budget. The one path that does not land (shipping wrong code is worse). */
export class AiMergeBlockedError extends Error {
readonly taskId: string;
readonly reasons: string[];
constructor(taskId: string, reasons: string[]) {
super(`AI merge blocked ${taskId} (unresolved correctness concern): ${reasons.join("; ") || "no reason given"}`);
this.name = "AiMergeBlockedError";
this.taskId = taskId;
this.reasons = reasons;
}
}
// ---------------------------------------------------------------------------
// Agent runners (injectable for tests)
// ---------------------------------------------------------------------------
interface AgentDeps {
/** Run the mutating merge agent in `cwd`. */
mergeAgent?: (cwd: string, prompt: string) => Promise<void>;
/** Run the read-only reviewer agent in `cwd`; returns its raw text. */
reviewAgent?: (cwd: string, prompt: string) => Promise<string>;
/** Run the mutating stash-conflict resolver in `cwd` (local checkout sync). */
stashResolveAgent?: (cwd: string, prompt: string) => Promise<void>;
}
/** Factory for a mutating AI agent bound to a fixed system prompt. */
function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor, systemPrompt: string) {
return async (cwd: string, prompt: string): Promise<void> => {
const model = resolveMergerSessionModel(settings);
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
cwd,
systemPrompt,
tools: "coding",
onText: options.onAgentText ? (delta: string) => options.onAgentText?.(delta) : undefined,
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
runAuditor: audit,
settings,
taskId,
});
options.onSession?.(session);
try {
await withRateLimitRetry(async () => {
await promptWithFallback(session, prompt);
checkSessionError(session);
}, { signal: options.signal });
await accumulateSessionTokenUsage(store, taskId, session);
} finally {
session.dispose();
}
};
}
function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, options: MergerOptions, audit: RunAuditor) {
return async (cwd: string, prompt: string): Promise<string> => {
const override = settings.merger?.reviewerModel?.trim();
const model = override
? parseModelOverride(override, settings)
: resolveMergerSessionModel(settings);
let captured = "";
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
cwd,
systemPrompt: buildReviewSystemPrompt(),
tools: "coding",
onText: (delta: string) => {
captured += delta;
options.onAgentText?.(delta);
},
defaultProvider: model.provider,
defaultModelId: model.modelId,
fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel,
runAuditor: audit,
settings,
taskId,
});
options.onSession?.(session);
try {
await withRateLimitRetry(async () => {
await promptWithFallback(session, prompt);
checkSessionError(session);
}, { signal: options.signal });
await accumulateSessionTokenUsage(store, taskId, session);
} finally {
session.dispose();
}
return captured;
};
}
function parseModelOverride(value: string, settings: Settings): { provider: string | undefined; modelId: string | undefined } {
const slash = value.indexOf("/");
if (slash > 0 && slash < value.length - 1) {
return { provider: value.slice(0, slash), modelId: value.slice(slash + 1) };
}
return { provider: resolveMergerSessionModel(settings).provider, modelId: value };
}
// ---------------------------------------------------------------------------
// Local checkout sync
// ---------------------------------------------------------------------------
export type LocalSyncOutcome =
| "ff"
| "stash-ff-restore"
| "stash-ff-airesolved"
| "stash-ff-conflict"
| "skipped-dirty-unstashable"
| "skipped-other-branch";
export interface LandResult {
/** "advanced" — the integration ref now points at the squash. "concurrent" —
* the target moved under us; the caller should rebuild on the new tip. */
outcome: "advanced" | "concurrent";
/** How the user's local checkout was reconciled (when on the target branch). */
localSync: LocalSyncOutcome;
}
async function hasUnresolvedConflicts(cwd: string): Promise<boolean> {
return (await git(["ls-files", "-u"], cwd)).length > 0;
}
/**
* Land the squash on the integration branch and bring the user's checkout with
* it. Two cases:
*
* A. The user's checkout IS on the target branch (HEAD === tipSha). We
* advance the ref AND sync the working tree in one safe step from that
* checkout — `git merge --ff-only <squash>` (it moves both the branch ref
* and the working tree). The user's real dirty state is read accurately
* BEFORE the fast-forward (while HEAD === tipSha, so `git status` isn't
* polluted by the ref move). Dirty edits are stashed, fast-forwarded, then
* restored — and if the restore conflicts, the AI merger reconciles them.
* If the checkout HEAD has already moved off tipSha, that's a concurrent
* advance → rebuild.
*
* B. The checkout is on a different branch (or the target isn't checked out
* here). We advance the ref atomically via `update-ref` (CAS) and leave the
* user's checkout alone.
*
* Uncommitted work is never destroyed: an unresolvable restore leaves the user's
* edits in a stash with a warning.
*/
export async function landSquash(input: {
projectRootDir: string;
mergeRoot: string;
integrationBranch: string;
tipSha: string;
squashSha: string;
taskId: string;
audit: RunAuditor;
resolveConflicts?: (cwd: string, prompt: string) => Promise<void>;
}): Promise<LandResult> {
const { projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, resolveConflicts } = input;
const emit = (outcome: LocalSyncOutcome, extra: Record<string, unknown> = {}) =>
audit.git({ type: "merge:ai-local-sync", target: integrationBranch, metadata: { taskId, outcome, squashSha, ...extra } }).catch(() => undefined);
const currentBranch = await git(["rev-parse", "--abbrev-ref", "HEAD"], projectRootDir).catch(() => "");
// Case B — target not checked out here: bare CAS ref advance.
if (currentBranch !== integrationBranch) {
const adv = await advanceIntegrationBranchRef({
rootDir: mergeRoot, projectRootDir, integrationBranch,
newSha: squashSha, expectedCurrentSha: tipSha, taskId, audit,
});
if (!adv.advanced) {
if (adv.reason === "concurrent-advance" || adv.reason === "non-fast-forward-advance") {
return { outcome: "concurrent", localSync: "skipped-other-branch" };
}
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId}: ${adv.reason} (${adv.diagnostic})`);
}
await emit("skipped-other-branch", { currentBranch });
return { outcome: "advanced", localSync: "skipped-other-branch" };
}
// Case A — checkout is on the target branch. Read real dirty state NOW, while
// HEAD === tipSha (accurate; not yet polluted by the ref move).
const head = await git(["rev-parse", "HEAD"], projectRootDir).catch(() => "");
if (head !== tipSha) {
// The checkout already moved off the tip we built on — concurrent advance.
return { outcome: "concurrent", localSync: "skipped-other-branch" };
}
const dirty = (await git(["status", "--porcelain"], projectRootDir)).length > 0;
const stashed = dirty
? await gitOk(["stash", "push", "--include-untracked", "-m", `fusion-ai-merge-sync-${taskId}`], projectRootDir)
: false;
if (dirty && !stashed) {
// The dirty state couldn't be stashed (e.g. untracked/tracked collision or a
// stash hook failure). Don't risk `merge --ff-only` aborting/clobbering:
// advance the ref atomically and leave the user's working tree as-is.
const adv = await advanceIntegrationBranchRef({
rootDir: mergeRoot, projectRootDir, integrationBranch,
newSha: squashSha, expectedCurrentSha: tipSha, taskId, audit,
});
if (!adv.advanced) {
if (adv.reason === "concurrent-advance" || adv.reason === "non-fast-forward-advance") {
return { outcome: "concurrent", localSync: "skipped-dirty-unstashable" };
}
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId}: ${adv.reason} (${adv.diagnostic})`);
}
aiMergeLog.warn(`${taskId}: local checkout has un-stashable dirty state — advanced ${integrationBranch} without syncing your working tree; pull manually.`);
await emit("skipped-dirty-unstashable");
return { outcome: "advanced", localSync: "skipped-dirty-unstashable" };
}
// Fast-forward the checkout (and the branch ref) to the squash.
if (!(await gitOk(["merge", "--ff-only", squashSha], projectRootDir))) {
if (stashed) await gitOk(["stash", "pop"], projectRootDir); // restore the user's edits
return { outcome: "concurrent", localSync: "skipped-other-branch" };
}
if (!stashed) {
await emit("ff");
return { outcome: "advanced", localSync: "ff" };
}
// Re-apply the user's stashed edits onto the new tip.
if (await gitOk(["stash", "pop"], projectRootDir)) {
await emit("stash-ff-restore");
return { outcome: "advanced", localSync: "stash-ff-restore" };
}
// Restore conflicted — let the AI merger reconcile the user's edits with the
// upstream changes in the working tree.
if (resolveConflicts) {
const conflicted = (await git(["diff", "--name-only", "--diff-filter=U"], projectRootDir)).split("\n").map((l) => l.trim()).filter(Boolean);
try {
await resolveConflicts(projectRootDir, buildStashResolvePrompt(conflicted));
} catch (err: unknown) {
aiMergeLog.warn(`${taskId}: AI stash-conflict resolver threw: ${err instanceof Error ? err.message : String(err)}`);
}
if (!(await hasUnresolvedConflicts(projectRootDir))) {
await gitOk(["reset"], projectRootDir); // unstage → reads as the user's uncommitted edits
// Keep the stash as a recovery backup (do NOT drop it): if the AI
// resolution discarded any of the user's intent, their original pre-merge
// edits remain recoverable via `git stash`. Honors "never destroy work".
aiMergeLog.log(`${taskId}: reconciled your local edits with the new tip; original pre-merge edits also kept in a stash as a backup (\`git stash list\`).`);
await emit("stash-ff-airesolved", { conflicted, stashRetained: true });
return { outcome: "advanced", localSync: "stash-ff-airesolved" };
}
}
aiMergeLog.warn(`${taskId}: restoring your local changes onto the new tip conflicted and could not be auto-resolved. Your work is preserved in the stash (\`git stash list\`); re-apply with \`git stash pop\` and resolve manually.`);
await emit("stash-ff-conflict");
return { outcome: "advanced", localSync: "stash-ff-conflict" };
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
function noOpResult(task: Task, branch: string, reason: string): MergeResult {
return {
task,
branch,
merged: false,
noOp: true,
ok: true,
reason,
worktreeRemoved: false,
branchDeleted: false,
};
}
export async function runAiMerge(
store: TaskStore,
projectRootDir: string,
taskId: string,
options: MergerOptions = {},
deps: AgentDeps = {},
): Promise<MergeResult> {
const task = await store.getTask(taskId);
const branch = task.branch || canonicalFusionBranchName(taskId);
if (task.column === "done" || task.column === "archived") {
return noOpResult(task, branch, "already-finalized");
}
const blocker = getTaskMergeBlocker(task, { manual: options.manual === true });
if (blocker) throw new Error(`Cannot merge ${taskId}: ${blocker}`);
const settings = await store.getSettings();
// Honor the task's own target branch when set; otherwise the project default
// integration branch. The local checkout is only synced if it is on this same
// target branch (see syncLocalCheckout).
const projectDefaultBranch = await resolveIntegrationBranch(projectRootDir, settings);
const mergeTarget = resolveTaskMergeTarget(task, { projectDefaultBranch });
const integrationBranch = mergeTarget.branch;
const audit = createRunAuditor(store, {
runId: generateSyntheticRunId("ai-merge", taskId),
agentId: "merger",
taskId,
phase: "merge",
});
// Surface progress on the task detail (status pill) + the task log stream.
const log = async (message: string): Promise<void> => {
await store.logEntry(taskId, message, "AiMerge").catch(() => undefined);
await store.appendAgentLog(taskId, message, "text", undefined, "merger").catch(() => undefined);
};
const setStatus = (status: string | null): Promise<unknown> =>
store.updateTask(taskId, { status }).catch(() => undefined);
// Branch must exist to merge it.
if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${branch}`], projectRootDir))) {
await audit.git({ type: "merge:ai-no-branch", target: branch, metadata: { taskId } });
const done = await finalizeTask(store, taskId, noOpResult(task, branch, "no-branch"));
return done;
}
// The target branch must exist as a LOCAL ref to merge into it — surface a
// clear error rather than a cryptic `fatal: Needed a single revision` if a
// task targets a remote-only / mistyped branch.
if (!(await gitOk(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir))) {
await audit.git({ type: "merge:ai-no-branch", target: integrationBranch, metadata: { taskId, kind: "integration-branch-missing" } });
throw new Error(`AI merge for ${taskId}: target branch "${integrationBranch}" has no local ref (refs/heads/${integrationBranch}). Create or check out the branch locally before merging.`);
}
const maxPasses = Math.max(0, Math.trunc(settings.merger?.maxReviewPasses ?? 3));
const mergeAgent = deps.mergeAgent ?? makeMutatingAgent(store, settings, taskId, options, audit, buildMergeSystemPrompt());
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];
await setStatus("merging");
let advanceRetries = 0;
while (true) {
throwIfAborted(options.signal, taskId);
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], projectRootDir);
// 1. Clean-room worktree at the integration tip.
const mergeRoot = await mkdtemp(join(tmpdir(), `fusion-ai-merge-${taskId.toLowerCase()}-`));
let worktreeAdded = false;
try {
await git(["worktree", "add", "--detach", mergeRoot, tipSha], projectRootDir);
worktreeAdded = true;
await audit.git({ type: "merge:ai-clean-room", target: integrationBranch, metadata: { taskId, tipSha, mergeRoot } });
await log(`AI merge: merging ${branch} into ${integrationBranch} (clean room at ${short(tipSha)})${advanceRetries ? ` — retry ${advanceRetries} after concurrent advance` : ""}`);
// 2 + 3. Merge + review loop (corrective passes).
const squashSha = await mergeAndReview({
mergeRoot, branch, integrationBranch, tipSha, subject, taskId,
maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal: options.signal,
});
if (!squashSha) {
// Branch had no net changes vs the tip — nothing to land.
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true });
}
// 4 + 5. Land the squash on the target branch and sync the user's
// checkout (AI reconciles a conflicting restore).
await setStatus("landing");
const landed = await landSquash({
projectRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit,
resolveConflicts: stashResolveAgent,
});
if (landed.outcome === "concurrent") {
if (advanceRetries < MAX_CONCURRENT_ADVANCE_RETRIES) {
advanceRetries++;
await log(`AI merge: ${integrationBranch} moved during merge — rebuilding on new tip (retry ${advanceRetries})`);
continue; // rebuild the clean room on the new tip
}
throw new Error(`AI merge could not advance ${integrationBranch} for ${taskId} after ${advanceRetries} retries (concurrent advances)`);
}
await log(`AI merge: advanced ${integrationBranch}${short(squashSha)} (local checkout: ${landed.localSync})`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false });
} finally {
if (worktreeAdded) {
await gitOk(["worktree", "remove", "--force", mergeRoot], projectRootDir);
}
await rm(mergeRoot, { recursive: true, force: true }).catch(() => undefined);
}
}
}
async function mergeAndReview(input: {
mergeRoot: string;
branch: string;
integrationBranch: string;
tipSha: string;
subject: string;
taskId: string;
maxPasses: number;
mergeAgent: (cwd: string, prompt: string) => Promise<void>;
reviewAgent: (cwd: string, prompt: string) => Promise<string>;
audit: RunAuditor;
log: (message: string) => Promise<void>;
setStatus: (status: string | null) => Promise<unknown>;
signal?: AbortSignal;
}): Promise<string | null> {
const { mergeRoot, branch, integrationBranch, tipSha, subject, taskId, maxPasses, mergeAgent, reviewAgent, audit, log, setStatus, signal } = input;
let priorReasons: string[] = [];
for (let attempt = 0; ; attempt++) {
throwIfAborted(signal, taskId);
// Reset the clean room to the tip before each (re)merge so corrective passes
// start from a known-good base, not a half-resolved tree.
await git(["reset", "--hard", tipSha], mergeRoot);
await git(["clean", "-fd"], mergeRoot);
if (attempt > 0) {
await setStatus("merging");
await log(`AI merge: corrective re-merge (pass ${attempt}/${maxPasses}) addressing: ${priorReasons.join("; ")}`);
}
await mergeAgent(mergeRoot, buildMergePrompt({
taskId, branch, integrationBranch, tipSha, subject,
correctiveReasons: priorReasons.length ? priorReasons : undefined,
}));
const head = await git(["rev-parse", "HEAD"], mergeRoot);
if (head === tipSha) return null; // empty merge — nothing landed
await setStatus("reviewing");
const diffStat = await git(["diff", "--stat", `${tipSha}..${head}`], mergeRoot);
const verdict = parseReviewVerdict(await reviewAgent(mergeRoot, buildReviewPrompt({
taskId, branch, integrationBranch, tipSha, squashSha: head, diffStat, priorReasons,
})));
await audit.git({
type: "merge:ai-review-verdict",
target: integrationBranch,
metadata: { taskId, attempt, verdict: verdict.verdict, severity: verdict.severity, reasons: verdict.reasons, squashSha: head },
});
if (verdict.verdict === "approve") {
await log(`AI merge review (pass ${attempt + 1}): approved`);
return head;
}
const budgetExhausted = attempt >= maxPasses;
if (budgetExhausted) {
if (verdict.severity === "blocking") {
await audit.git({ type: "merge:ai-review-blocked", target: integrationBranch, metadata: { taskId, attempt, reasons: verdict.reasons } });
await log(`AI merge BLOCKED after ${attempt} corrective pass(es) — unresolved correctness concern: ${verdict.reasons.join("; ")}`);
throw new AiMergeBlockedError(taskId, verdict.reasons);
}
// Advisory: land the squash with the concern logged.
await audit.git({ type: "merge:ai-review-landed-with-concerns", target: integrationBranch, metadata: { taskId, attempt, reasons: verdict.reasons, squashSha: head } });
await log(`AI merge: landing with unresolved advisory concern(s): ${verdict.reasons.join("; ")}`);
return head;
}
priorReasons = verdict.reasons;
await log(`AI merge review (pass ${attempt + 1}): rejected (${verdict.severity}) — ${verdict.reasons.join("; ")}`);
}
}
async function finalizeMerged(
store: TaskStore,
projectRootDir: string,
taskId: string,
task: Task,
branch: string,
integrationBranch: string,
landedSha: string,
audit: RunAuditor,
log: (message: string) => Promise<void>,
opts: { empty: boolean },
): Promise<MergeResult> {
let branchDeleted = false;
// NEVER delete the integration branch itself — a task whose branch name
// coincides with the target (or merges into its own branch) must not have the
// just-advanced integration ref force-deleted out from under it.
if (branch !== integrationBranch && await gitOk(["branch", "-D", branch], projectRootDir)) {
branchDeleted = true;
await audit.git({ type: "branch:delete", target: branch, metadata: { taskId, force: true } }).catch(() => undefined);
}
// Remove the task's own worktree if it still exists.
let worktreeRemoved = false;
if (task.worktree) {
worktreeRemoved = await gitOk(["worktree", "remove", "--force", task.worktree], projectRootDir);
await store.updateTask(taskId, { worktree: null }).catch(() => undefined);
}
const result: MergeResult = {
task,
branch,
merged: !opts.empty,
noOp: opts.empty,
ok: true,
reason: opts.empty ? "no-net-changes" : undefined,
commitSha: opts.empty ? undefined : landedSha,
mergeConfirmed: !opts.empty,
worktreeRemoved,
branchDeleted,
};
await audit.git({ type: "merge:ai-landed", target: integrationBranch, metadata: { taskId, landedSha, empty: opts.empty } }).catch(() => undefined);
await log(opts.empty ? `AI merge: finalized ${taskId} (no-op) → done` : `AI merge: landed ${short(landedSha)}, task → done`);
return await finalizeTask(store, taskId, result);
}
/** Move the task to done and emit, mirroring the legacy completeTask. */
async function finalizeTask(store: TaskStore, taskId: string, result: MergeResult): Promise<MergeResult> {
await store.updateTask(taskId, { status: null }).catch(() => undefined);
const task = await store.moveTask(taskId, "done");
result.task = task;
store.emit("task:merged", result);
return result;
}
function throwIfAborted(signal: AbortSignal | undefined, taskId: string): void {
if (signal?.aborted) {
const err = new Error(`AI merge aborted for ${taskId}`);
err.name = "MergeAbortedError";
throw err;
}
}

View File

@@ -10,7 +10,7 @@ import type {
ScheduledTask,
AutomationRunResult,
} from "@fusion/core";
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -26,6 +26,7 @@ import { createFusionAuthStorage } from "./auth-storage.js";
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { RoutineRunner } from "./routine-runner.js";
import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js";
import { runAiMerge } from "./merger-ai.js";
import { PRIORITY_MERGE } from "./concurrency.js";
import { runtimeLog } from "./logger.js";
import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
@@ -1753,19 +1754,26 @@ export class ProjectEngine {
const usageLimitPauser = (this.runtime as any).usageLimitPauser;
const rawMerge = () => {
const rawMerge = async () => {
this.activeMergeTaskId = taskId;
this.mergeAbortController = new AbortController();
return aiMergeTask(store, cwd, taskId, {
const mergerOptions = {
manual: !!manualResolver,
pool,
usageLimitPauser,
agentStore,
signal: this.mergeAbortController.signal,
onSession: (session) => {
onSession: (session: { dispose: () => void }) => {
this.activeMergeSession = session;
},
});
};
// FN-5633: "ai" mode (default) uses the standalone AI merge path
// (clean-room worktree + AI merge + AI reviewer); "deterministic"
// keeps the legacy aiMergeTask pipeline.
const mergerMode = normalizeMergerMode((await store.getSettings().catch(() => ({}) as Settings)).merger?.mode);
return mergerMode === "ai"
? runAiMerge(store, cwd, taskId, mergerOptions)
: aiMergeTask(store, cwd, taskId, mergerOptions);
};
let result: MergeResult;

View File

@@ -157,6 +157,14 @@ export type GitMutationType =
| "merge:layer3:foreign-file-skipped"
| "merge:layer3:scope-override-bypass"
| "merge:scope:auto-widen"
| "merge:ai-clean-room"
| "merge:ai-no-branch"
| "merge:ai-empty"
| "merge:ai-review-verdict"
| "merge:ai-review-blocked"
| "merge:ai-review-landed-with-concerns"
| "merge:ai-local-sync"
| "merge:ai-landed"
| "merge:reuse-handoff-acquired"
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"