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:
21
.changeset/fn-5633-standalone-ai-merge.md
Normal file
21
.changeset/fn-5633-standalone-ai-merge.md
Normal 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"`.
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'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't silently drop edits.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="pushAfterMerge" className="checkbox-label">
|
||||
<input
|
||||
|
||||
@@ -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))),
|
||||
|
||||
289
packages/engine/src/__tests__/merger-ai.test.ts
Normal file
289
packages/engine/src/__tests__/merger-ai.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
805
packages/engine/src/merger-ai.ts
Normal file
805
packages/engine/src/merger-ai.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user