fix(review): harden R7 workspace merge guard + deprecation warning (U0)

Applies ce-code-review (autofix) feedback — 5 reviewers, P1s corroborated.

F1 [P1, ×4 reviewers] Guard the merge chokepoint, not just the 4 doors. The
per-caller `getTask().catch(()=>null); if(t) assert` pattern failed open on a
transient read, and runAiMerge re-read the task unguarded — so a workspace
task could reach git work against the non-git root. Added a named
WorkspaceTaskMergeError and call assertNotWorkspaceTaskMerge inside runAiMerge
(the sole merge path) and the deprecated aiMergeTask body; door guards remain
as fast-fail defense-in-depth.

F2 [P1] The dispatch catch treated the guard throw as a merge failure and set
mergeRetries=MAX, permanently blocking manual retry. It now recognizes
WorkspaceTaskMergeError and parks without burning retries.

F3 [P2] Deprecation-warning test asserted toBeLessThanOrEqual(1) — vacuously
true on zero emissions. Now resets the per-project flag and asserts the
warning fires exactly once and not again on a second deterministic merge.

F6 [P2] The once-per-process warning flag suppressed the notice for all other
projects in a multi-project host; now keyed per project (Set by cwd).

F5/F7/F8 [P3] @deprecated propagated to the aiMergeTask barrel re-export; CLI
runTaskMerge guard moved inside the formatted try/catch; FNXC placeholder
timestamps corrected; test .at(-1) -> length index.

Documented as residual (deferred to master-plan U8, not bugs in U0's window):
self-healing auto-finalize + store.mergeTask are additional merge-completing
paths not hardened here — workspace tasks are not end-to-end runnable until
master-plan Phase A, and U8 makes self-healing workspace-aware.

Gate green: typecheck (29 projects), lint, build, test:gate (649+58),
affected tests (206+4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 19:19:55 -07:00
parent a6252e518d
commit 7240b77c67
16 changed files with 141 additions and 55 deletions

View File

@@ -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);

View File

@@ -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),
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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<Task, "id" | "workspaceWorktrees">): 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`,
);
}

View File

@@ -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,

View File

@@ -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<string, unknown> = {
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<string, unknown>, comments: unknown[]) => Promise<void> | 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();

View File

@@ -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).

View File

@@ -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]),

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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<MergeResult> {
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") {

View File

@@ -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);

View File

@@ -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<string>();
/**
* 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

View File

@@ -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