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:
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user