diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 22ceb24624..fed1e64b88 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1300,12 +1300,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // In engine mode: replaced by engine.onMerge() after ProjectEngine starts // (semaphore-gated via the engine's InProcessRuntime). // - // FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 unified all merge + // FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified all merge // entry points onto runAiMerge (the FN-5633 clean-room AI merge path); // aiMergeTask is soft-deprecated. // const onMergeImpl = async (taskId: string) => { - // FNXC:Workspace 2026-06-21-00:00: R7 merge-boundary guard (master-plan U0). + // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). // Reject workspace-mode tasks before any merge work; per-repo merge lands in // master-plan U6, which removes this guard. const mergeTask = await store.getTask(taskId).catch(() => null); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 9e5627a7fd..83d4ca5a3c 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -850,14 +850,16 @@ export async function runTaskMerge(id: string, projectName?: string) { console.log(`\n Merging ${id} with AI...\n`); - // FNXC:Workspace 2026-06-21-00:00: R7 merge-boundary guard (master-plan U0). - // Reject workspace-mode tasks before any merge work; per-repo merge lands in - // master-plan U6, which removes this guard. - // FNXC:MergerUnification 2026-06-21-00:00: unified onto runAiMerge (U0). - const mergeTaskRecord = await store.getTask(id).catch(() => null); - if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord); - try { + // FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). + // Reject workspace-mode tasks before any merge work; per-repo merge lands in + // master-plan U6, which removes this guard. + // FNXC:MergerUnification 2026-06-21-19:05: unified onto runAiMerge (U0). + // The guard lives INSIDE this try so its throw renders via the formatted + // ` ✗ ...` output below instead of the generic top-level bin.ts handler. + const mergeTaskRecord = await store.getTask(id).catch(() => null); + if (mergeTaskRecord) assertNotWorkspaceTaskMerge(mergeTaskRecord); + const result = await runAiMerge(store, projectPath, id, { onAgentText: (delta) => process.stdout.write(delta), }); diff --git a/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts b/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts index ae03250a08..676fdd7671 100644 --- a/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts +++ b/packages/core/src/__tests__/assert-not-workspace-task-merge.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { assertNotWorkspaceTaskMerge } from "../types.js"; -// FNXC:Workspace 2026-06-21-00:00: R7 merge-boundary guard (master-plan U0). +// FNXC:Workspace 2026-06-21-19:05: R7 merge-boundary guard (master-plan U0). // This shared predicate is called at all four merge entry points (engine // dispatch, store.mergeTask, CLI onMergeImpl, CLI runTaskMerge). Workspace-mode // tasks (populated workspaceWorktrees) must be held until per-repo merge support diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 77b535a7a2..d8bb99bb91 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ 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, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, 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, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, 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, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, 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, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, 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, TaskBranchContext, CliAgentSettings } from "./types.js"; -export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge } from "./types.js"; +export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, sanitizeBranchSegment, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index edd75fad1e..759a64eebc 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -11151,7 +11151,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); - // FNXC:Workspace 2026-06-21-00:00: + // FNXC:Workspace 2026-06-21-19:05: // R7 merge-boundary guard (master-plan U0). Reject workspace-mode tasks // BEFORE any git checkout/squash — they need the per-repo merge loop that // lands in master-plan U6, which removes this guard. See the predicate's diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1f74cda774..9425818116 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -508,7 +508,7 @@ export const MERGER_MODES = ["ai", "deterministic"] as const; * avoid a breaking `@runfusion/fusion` type change, and the engine logs a * one-time deprecation warning when it observes a resolved "deterministic". * - * FNXC:MergerUnification 2026-06-21-00:00: `merger.mode` is published surface, so + * FNXC:MergerUnification 2026-06-21-19:05: `merger.mode` is published surface, so * the type and the `MergerSettings.mode` field stay; only the "deterministic" * VALUE is deprecated/inert. Removing the type is a separate breaking change. */ @@ -2601,7 +2601,7 @@ export interface Task { } /* -FNXC:Workspace 2026-06-21-00:00: +FNXC:Workspace 2026-06-21-19:05: R7 workspace merge-boundary guard (master-plan U0). Workspace-mode tasks populate `task.workspaceWorktrees` (one git worktree per sub-repo); their merge must run a per-repo loop that does NOT exist yet — it lands in master-plan U6. Until then, a @@ -2611,18 +2611,34 @@ the NON-GIT workspace root and crash. This single shared predicate is called at top of every merge door, BEFORE any git work, so the task is held with a clear, actionable error instead. It lives in @fusion/core so all four call sites — including store.mergeTask, which cannot import from @fusion/engine — share ONE implementation. -Master-plan U6 REMOVES this guard when the per-repo merge loop becomes the gate. +The guard throws a NAMED `WorkspaceTaskMergeError` so callers (e.g. the engine merge +dispatch catch) can distinguish this permanent config error from a transient merge +failure and avoid burning mergeRetries. Master-plan U6 REMOVES this guard when the +per-repo merge loop becomes the gate. */ /** - * Throws when `task.workspaceWorktrees` has at least one entry (a workspace-mode - * task). No-op for single-repo tasks. See the FNXC:Workspace note above. + * Error thrown by {@link assertNotWorkspaceTaskMerge} when a workspace-mode task + * reaches a merge path. Named so callers can branch on it (e.g. park without + * burning mergeRetries) rather than treating it as a transient merge failure. + */ +export class WorkspaceTaskMergeError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkspaceTaskMergeError"; + } +} + +/** + * Throws {@link WorkspaceTaskMergeError} when `task.workspaceWorktrees` has at least + * one entry (a workspace-mode task). No-op for single-repo tasks. See the + * FNXC:Workspace note above. * @param task the task about to enter a merge path */ export function assertNotWorkspaceTaskMerge(task: Pick): void { const worktrees = task.workspaceWorktrees; if (worktrees && Object.keys(worktrees).length > 0) { - throw new Error( + throw new WorkspaceTaskMergeError( `Workspace task ${task.id} cannot merge until per-repo merge support (master-plan U6) lands`, ); } diff --git a/packages/engine/src/__tests__/merge-error-recovery.test.ts b/packages/engine/src/__tests__/merge-error-recovery.test.ts index 6af313d0e2..57465cf121 100644 --- a/packages/engine/src/__tests__/merge-error-recovery.test.ts +++ b/packages/engine/src/__tests__/merge-error-recovery.test.ts @@ -19,7 +19,7 @@ const testState = vi.hoisted(() => { }; }); -// FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 unified the merge +// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge // dispatch onto runAiMerge (merger-ai.js). These error-recovery tests use the // merge fn as a mockable seam; they now mock/assert runAiMerge. VerificationError // still comes from merger.js (shared, not deprecated). @@ -125,7 +125,7 @@ function makeStore({ globalPause: false, enginePaused: false, pollIntervalMs: 15_000, - // FNXC:MergerUnification 2026-06-21-00:00: U0 unified merges onto runAiMerge; + // FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge; // these tests mock/assert runAiMerge directly. No `merger.mode` pin needed — // the dispatch ignores the value. ...settings, diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 9f08c2fd03..3821a0d3d9 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Task } from "@fusion/core"; -import { ProjectEngine } from "../project-engine.js"; +import { ProjectEngine, __resetDeterministicMergerModeDeprecationWarned } from "../project-engine.js"; import { runtimeLog } from "../logger.js"; import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js"; import { NtfyNotifier } from "../notifier.js"; @@ -61,7 +61,7 @@ vi.mock("../cron-runner.js", () => { }; }); -// FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 unified the merge +// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge // dispatch onto runAiMerge (merger-ai.js). project-engine no longer imports // aiMergeTask; the merge seam these tests mock/assert is now runAiMerge. vi.mock("../merger.js", () => ({ @@ -261,7 +261,7 @@ const baseSettings: Record = { globalPause: false, enginePaused: false, pollIntervalMs: 15_000, - // FNXC:MergerUnification 2026-06-21-00:00: U0 unified merges onto runAiMerge; + // FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge; // the onMerge tests mock/assert runAiMerge. The old `merger.mode` pin is gone // (the dispatch ignores it) — a dedicated test below covers the inert-mode + // one-time deprecation-warning behavior. @@ -421,7 +421,8 @@ describe("ProjectEngine PR monitoring wiring", () => { await engine.start(); expect(mocks.runtimeConfigurePrMonitoring).toHaveBeenCalled(); - const configArg = mocks.runtimeConfigurePrMonitoring.mock.calls.at(-1)?.[0] as { + const calls = mocks.runtimeConfigurePrMonitoring.mock.calls; + const configArg = calls[calls.length - 1]?.[0] as { onClosedPrFeedback?: (taskId: string, prInfo: Record, comments: unknown[]) => Promise | void; }; expect(typeof configArg.onClosedPrFeedback).toBe("function"); @@ -1214,7 +1215,7 @@ describe("ProjectEngine manual merge plumbing", () => { }); }); -// FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 made runAiMerge the +// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made runAiMerge the // sole merge path. These tests pin the unified dispatch: every merger.mode value // routes to runAiMerge, "deterministic" warns exactly once (never errors), and // the R7 workspace guard rejects populated-workspaceWorktrees tasks at the engine @@ -1222,6 +1223,13 @@ describe("ProjectEngine manual merge plumbing", () => { describe("ProjectEngine U0 merge unification dispatch", () => { beforeEach(() => { vi.clearAllMocks(); + // FNXC:MergerUnification 2026-06-21-19:05: the deterministic-mode deprecation + // warning is gated by a per-project module-level ledger. Reset it before each + // test so the once-per-project-per-process assertion is deterministic regardless + // of which sibling test populated the ledger first (createEngine always uses the + // same project root, so without this a prior deterministic merge would suppress + // the warning here and the "fires once" test would see zero emissions). + __resetDeterministicMergerModeDeprecationWarned(); }); async function runOnMergeWithMode(mode: string | undefined) { @@ -1262,18 +1270,25 @@ describe("ProjectEngine U0 merge unification dispatch", () => { ); }); - it('logs the merger.mode "deterministic" deprecation warning exactly once per process (warn, not error)', async () => { + it('logs the merger.mode "deterministic" deprecation warning exactly once per project per process (warn, not error)', async () => { const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => undefined); - try { - // Two deterministic merges; the module-level flag must gate the warning to - // a single emission across the whole process. - await runOnMergeWithMode("deterministic"); - await runOnMergeWithMode("deterministic"); - const deprecationWarnings = warnSpy.mock.calls.filter((call) => + const deprecationWarnings = () => + warnSpy.mock.calls.filter((call) => String(call[0]).includes("merger.mode") && String(call[0]).includes("deprecated"), ); - expect(deprecationWarnings.length).toBeLessThanOrEqual(1); - // The merge still proceeds via runAiMerge despite the deprecated value. + try { + // First deterministic merge: the warning must fire EXACTLY once. + await runOnMergeWithMode("deterministic"); + expect(deprecationWarnings()).toHaveLength(1); + + // A SECOND deterministic merge in the same process (same project root) must + // NOT warn again — the per-project ledger suppresses the repeat. Total stays 1. + await runOnMergeWithMode("deterministic"); + expect(deprecationWarnings()).toHaveLength(1); + + // The warning is a warn (never an error), and the merge still proceeds via + // runAiMerge despite the deprecated value. + expect(deprecationWarnings()).toHaveLength(1); expect(mocks.runAiMerge).toHaveBeenCalled(); } finally { warnSpy.mockRestore(); diff --git a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts index 7e43d08c84..1608dfa451 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts @@ -7,7 +7,7 @@ const testState = vi.hoisted(() => ({ currentStore: null as (TaskStore & EventEmitter) | null, })); -// FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 unified the merge +// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge // dispatch onto runAiMerge (merger-ai.js). This test uses the merge fn as a // mockable seam to inject a verification failure; it now mocks runAiMerge. vi.mock("../../merger-ai.js", async (importOriginal) => { @@ -66,7 +66,7 @@ function createStore(task: Task, sequence: Task[]) { globalPause: false, enginePaused: false, pollIntervalMs: 15_000, - // FNXC:MergerUnification 2026-06-21-00:00: U0 unified merges onto runAiMerge; + // FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge; // no `merger.mode` pin needed (dispatch ignores it). } as Settings)), listTasks: vi.fn(async () => [task]), @@ -132,7 +132,7 @@ describe("post-finalize verification noop status-write guard", () => { mergeDetails: { mergeConfirmed: true, commitSha: "abcdef1234567890" }, }); - // FNXC:MergerUnification 2026-06-21-00:00: the U0 R7 guard adds one + // FNXC:MergerUnification 2026-06-21-19:05: the U0 R7 guard adds one // store.getTask read at the merge dispatch before runAiMerge, so the read // sequence gains one leading in-review entry; the post-failure recovery still // resolves the same done-task tail (the "already-done task" no-op path). diff --git a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop.real-git.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop.real-git.test.ts index bc1ccac6ba..51a30b15d4 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop.real-git.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop.real-git.test.ts @@ -13,7 +13,7 @@ const testState = vi.hoisted(() => ({ currentStore: null as (TaskStore & EventEmitter) | null, })); -// FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 unified the merge +// FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 unified the merge // dispatch onto runAiMerge (merger-ai.js). This test injects a verification // failure through the merge seam, so it now mocks runAiMerge. merger.js stays // real (importOriginal) for commitOrAmendMergeWithFixes used below. @@ -61,7 +61,7 @@ function createStore(task: Task, taskSequence?: Task[]) { globalPause: false, enginePaused: false, pollIntervalMs: 15_000, - // FNXC:MergerUnification 2026-06-21-00:00: U0 unified merges onto runAiMerge; + // FNXC:MergerUnification 2026-06-21-19:05: U0 unified merges onto runAiMerge; // no `merger.mode` pin needed (dispatch ignores it). } as Settings)), listTasks: vi.fn(async () => [task]), diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 9e68b6f143..de96d138d0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -142,7 +142,7 @@ import type { PluginRunner } from "./plugin-runner.js"; import { isContextLimitError } from "./context-limit-detector.js"; import { StepSessionExecutor } from "./step-session-executor.js"; import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js"; -// FNXC:MergerUnification 2026-06-21-00:00: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage. +// FNXC:MergerUnification 2026-06-21-19:05: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage. import { acquireTaskWorktree } from "./worktree-acquisition.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 3a77b9894a..40e65e9b0f 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -167,6 +167,7 @@ export { export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js"; +/** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */ export { aiMergeTask, listAutostashOrphans, @@ -186,7 +187,7 @@ export { getConflictedFiles, type AutostashHandle, } from "./merger.js"; -// FNXC:MergerUnification 2026-06-21-00:00: runAiMerge is the sole merge path +// FNXC:MergerUnification 2026-06-21-19:05: runAiMerge is the sole merge path // (master-plan U0); exported for the CLI callers (fn task merge + UI-only merge). export { runAiMerge } from "./merger-ai.js"; export { diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 9038c2ec5e..8e28aeed60 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -5,7 +5,7 @@ * does NOT share the legacy `aiMergeTask` pipeline (prerebase / conflict-strategy * ladder / transient self-heal), which is buggy and error-prone. * - * FNXC:MergerUnification 2026-06-21-00:00: master-plan U0 made this the SOLE + * FNXC:MergerUnification 2026-06-21-19:05: master-plan U0 made this the SOLE * merge path. Every merge entry point (engine dispatch, `fn task merge`, the * UI-only dashboard merge) routes here; `merger.mode` is inert (a "deterministic" * value only logs a one-time deprecation warning). The legacy `aiMergeTask` @@ -42,6 +42,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative } from "node:path"; import { + assertNotWorkspaceTaskMerge, buildTaskLineageTrailer, evaluateNoCommitsNoOpFinalize, getPrimaryPrInfo, @@ -969,6 +970,13 @@ export async function runAiMerge( deps: AgentDeps = {}, ): Promise { const task = await store.getTask(taskId); + // FNXC:MergerUnification 2026-06-21-19:05: + // Chokepoint R7 guard. runAiMerge is the SOLE merge path (master-plan U0), so it + // self-enforces the workspace merge-boundary here — immediately after the task read + // and BEFORE any git work — even if a door's pre-read was skipped/swallowed or a + // direct importer calls runAiMerge without the door-level guard. Throws the named + // WorkspaceTaskMergeError; the door guards remain as fast-fail defense-in-depth. + assertNotWorkspaceTaskMerge(task); const branch = resolveTaskWorkingBranch(task); if (task.column === "done" || task.column === "archived") { diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index ce553aa1dc..1d5ee20754 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -73,6 +73,7 @@ import { import { isBranchAuthoritativeForTask } from "./branch-conflicts.js"; import { hostname } from "node:os"; import { + assertNotWorkspaceTaskMerge, buildTaskLineageTrailer, evaluateNoCommitsNoOpFinalize, getTaskMergeBlocker, @@ -7644,7 +7645,7 @@ export async function syncGroupPrOnLanding(input: { * deletion pass and direct unit tests, but new callers must use `runAiMerge`. * The `merger.mode === "deterministic"` setting that once routed here is inert. * - * FNXC:MergerUnification 2026-06-21-00:00: legacy deterministic merge pipeline, + * FNXC:MergerUnification 2026-06-21-19:05: legacy deterministic merge pipeline, * superseded by runAiMerge. Helpers it shares with runAiMerge (e.g. * captureSingleCommitLandedMetadata) are NOT deprecated. */ @@ -7658,6 +7659,11 @@ export async function aiMergeTask( // 1. Validate task state const task = await store.getTask(taskId); + // FNXC:MergerUnification 2026-06-21-19:05: defense-in-depth R7 guard on the + // deprecated path — even though no production code calls aiMergeTask, its body is + // reachable via direct unit tests/importers, so enforce the workspace merge-boundary + // here too (throws the named WorkspaceTaskMergeError) before any git work. + assertNotWorkspaceTaskMerge(task); if (task.column === "done" || task.column === "archived") { const message = `merger: skipping squash for ${taskId} — task already finalized (column=${task.column})`; mergerLog.log(message); diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 0e4baf4313..5a343177f4 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -13,7 +13,7 @@ import type { ResearchSynthesisRequest, ResearchSynthesisResult, } from "@fusion/core"; -import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core"; +import { allowsAutoMergeProcessing, assertNotWorkspaceTaskMerge, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId, WorkspaceTaskMergeError } from "@fusion/core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { InProcessRuntime } from "./runtimes/in-process-runtime.js"; @@ -81,14 +81,23 @@ const execFileAsync = promisify(execFile); const MERGE_HANDOFF_GRACE_MS = 300; /* -FNXC:MergerUnification 2026-06-21-00:00: +FNXC:MergerUnification 2026-06-21-19:05: Master-plan U0 made `runAiMerge` the SOLE merge path; `merger.mode` is now inert (the type/field are retained as published surface — see types.ts MergerMode). When a project still resolves `merger.mode === "deterministic"` we WARN (never error) once -per process and proceed via `runAiMerge` anyway. This module-level flag gates the -warning to exactly one emission per engine process. +per project per process and proceed via `runAiMerge` anyway. The warning is keyed by +project root so EACH project with the stale setting warns once — a single module-level +boolean would suppress the warning for all other projects after the first emission. */ -let deterministicMergerModeDeprecationWarned = false; +const deterministicMergerModeDeprecationWarnedProjects = new Set(); + +/** + * Test-only: clears the per-project deprecation-warning ledger so a test can assert + * the warning fires exactly once per project per process. Not used by production code. + */ +export function __resetDeterministicMergerModeDeprecationWarned(): void { + deterministicMergerModeDeprecationWarnedProjects.clear(); +} interface RemoteLifecycleEvaluation { provider: TunnelProvider; @@ -2278,7 +2287,7 @@ export class ProjectEngine { this.activeMergeSession = session; }, }; - // FNXC:Workspace 2026-06-21-00:00: + // FNXC:Workspace 2026-06-21-19:05: // R7 merge-boundary guard (master-plan U0). Reject workspace-mode // tasks BEFORE any git work — they need the per-repo merge loop that // lands in master-plan U6 (which removes this guard). Load the task @@ -2286,18 +2295,20 @@ export class ProjectEngine { const mergeTask = await store.getTask(taskId).catch(() => null); if (mergeTask) assertNotWorkspaceTaskMerge(mergeTask); - // FNXC:MergerUnification 2026-06-21-00:00: + // FNXC:MergerUnification 2026-06-21-19:05: // Master-plan U0 collapsed the merge dispatch: `runAiMerge` (the // FN-5633 clean-room AI merge path) is the SOLE merge path. The // `merger.mode` setting is inert — we no longer branch on it. A - // resolved "deterministic" value only triggers a one-time deprecation - // warning (warn, never error) before proceeding via `runAiMerge`. + // resolved "deterministic" value only triggers a once-per-project + // deprecation warning (warn, never error) before proceeding via + // `runAiMerge`; the warning is keyed by project root (cwd) so each + // stale project warns once rather than just the first project seen. const settings = await store.getSettings().catch(() => ({}) as Settings); if ( normalizeMergerMode(settings.merger?.mode) === "deterministic" - && !deterministicMergerModeDeprecationWarned + && !deterministicMergerModeDeprecationWarnedProjects.has(cwd) ) { - deterministicMergerModeDeprecationWarned = true; + deterministicMergerModeDeprecationWarnedProjects.add(cwd); runtimeLog.warn( 'merger.mode "deterministic" is deprecated and inert: all merges now use the unified AI merge path (runAiMerge). Remove the setting; the legacy aiMergeTask pipeline is soft-deprecated.', ); @@ -2347,6 +2358,33 @@ export class ProjectEngine { continue; } + // FNXC:Workspace 2026-06-21-19:05: + // R7 workspace merge-boundary park (master-plan U0). A WorkspaceTaskMergeError + // is a PERMANENT config error (workspace task hit a merge door before the + // per-repo merge loop exists — master-plan U6), NOT a transient merge failure. + // Park the task WITHOUT burning mergeRetries (set to 0) so a human can manually + // retry after addressing the config; the default failed-path below would + // otherwise pin mergeRetries to the cap and permanently block manual retry. + const isWorkspaceMergeError = + err instanceof WorkspaceTaskMergeError + || (err as { name?: string } | null)?.name === "WorkspaceTaskMergeError"; + if (isWorkspaceMergeError) { + runtimeLog.error( + `${hasManualResolver ? "Manual" : "Auto"}-merge blocked for ${taskId}: workspace-mode tasks cannot merge until per-repo merge support (master-plan U6) lands; parking without burning mergeRetries so a human can retry after the config is addressed: ${errorMsg}`, + ); + await store + .logEntry(taskId, `Merge blocked: ${errorMsg}`, "WorkspaceTaskMergeError") + .catch(() => undefined); + if (hasManualResolver) { + this.rejectMergeResolvers(taskId, err instanceof Error ? err : new Error(errorMsg)); + } else { + await store + .updateTask(taskId, { status: null, mergeRetries: 0, error: errorMsg }) + .catch(() => undefined); + } + continue; + } + runtimeLog.error(`${hasManualResolver ? "Manual" : "Auto"}-merge failed for ${taskId}: ${errorMsg}`); // Surface every merge failure on the task log so the dashboard shows diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 6ce4ca696b..794413ca5d 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -620,7 +620,7 @@ export async function acquireWorkspaceRepoWorktree( const repoAbsPath = join(workspaceRootDir, repoRelPath); /* - FNXC:WorkspaceWorktree 2026-06-21-00:00: + FNXC:WorkspaceWorktree 2026-06-21-19:05: Workspace mode acquires one worktree per sub-repo for a single task. `acquireTaskWorktree` is single-repo: it reads `task.worktree`/`task.branch` to decide resume-vs-fresh and rewrites those singular fields on the task row after each acquisition. Passing the live task straight