From 25f275dc4f53b2b543dbc23f2326383ddef2748d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 11:45:43 -0700 Subject: [PATCH] fix: green full-suite after main product drift Restore i18n key parity (Replan + mobile nav strings), update engine test mocks for getTask/recordActivity/getAsyncLayer/ModelRuntime, align DUPLICATE marker and mission validator expectations with current product APIs, and make worktree path-reservation tests use real temp roots. --- .../hooks/__tests__/useBlockerFanout.test.ts | 11 ++- ...utor-assigned-agent-runtime-config.test.ts | 7 ++ .../mcp-pr-response-forwarding.test.ts | 8 ++ .../src/__tests__/pi-layers-wiring.test.ts | 8 ++ .../mission-validation-trigger-gap.test.ts | 18 +++- .../triage-finalize-duplicate-lineage.test.ts | 47 ++++++++- .../triage-split-into-subtasks-delete.test.ts | 23 ++++- .../worktree-acquisition-backend.test.ts | 96 +++++++++++++------ .../worktree-acquisition-worktrunk.test.ts | 79 ++++++++++----- packages/i18n/locales/es/app.json | 12 ++- packages/i18n/locales/fr/app.json | 12 ++- packages/i18n/locales/ko/app.json | 12 ++- packages/i18n/locales/zh-CN/app.json | 12 ++- packages/i18n/locales/zh-TW/app.json | 12 ++- 14 files changed, 281 insertions(+), 76 deletions(-) diff --git a/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts b/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts index 3f07dfa78d..4f302763e5 100644 --- a/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts @@ -130,9 +130,16 @@ describe("computeBlockerFanoutMap", () => { it("keeps the dashboard fallback aligned with the documented self-healing default seed", () => { const testDir = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8"); - const match = source.match(/export const MAX_AUTO_MERGE_RETRIES = (\d+);/); + /* + FNXC:DashboardTests 2026-07-17-11:45: + Wave-8 peeled MAX_AUTO_MERGE_RETRIES into self-healing-constants.ts (re-exported + from self-healing.ts). Read the constant definition file so this alignment + guard still pins the dashboard seed to the engine default of 3. + */ + const constantsSource = readFileSync(resolve(testDir, "../../../../engine/src/self-healing-constants.ts"), "utf8"); + const match = constantsSource.match(/export const MAX_AUTO_MERGE_RETRIES = (\d+);/); expect(match?.[1]).toBe(String(MAX_AUTO_MERGE_RETRIES)); + const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8"); expect(source).toContain("SelfHealingManager must call resolveMaxAutoMergeRetries(settings)"); }); }); diff --git a/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts b/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts index 5afddf5a18..c00c18f4a5 100644 --- a/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts +++ b/packages/engine/src/__tests__/executor-assigned-agent-runtime-config.test.ts @@ -10,6 +10,13 @@ function createStore() { return { on: vi.fn(), getFusionDir: vi.fn(() => "/worktree/.fusion"), + /* + FNXC:EngineTests 2026-07-17-11:45: + getAuthoritativeAssignedAgent requires store.getAsyncLayer() so the fallback + AgentStore runs in PostgreSQL backend mode. A missing method throws, is + swallowed, and the lookup returns null — defeating the runtimeConfig spy. + */ + getAsyncLayer: vi.fn(() => ({ kind: "test-async-layer" })), } as any; } diff --git a/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts index 5f5ce4636f..50c48bd93a 100644 --- a/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts +++ b/packages/engine/src/__tests__/mcp-pr-response-forwarding.test.ts @@ -38,6 +38,14 @@ const settings = { function createStore(enabled: boolean): TaskStore { return { + /* + FNXC:EngineTests 2026-07-17-11:45: + pr-response-run-ops now loads the task via store.getTask so merger model resolution + can honor per-task overrides. Stub a minimal task for the MCP-forwarding path. + */ + async getTask(taskId: string) { + return { id: taskId, column: "in-review" } as any; + }, async getSettingsByScope() { return { global: { mcpServers: { enabled: true, servers: [] } }, diff --git a/packages/engine/src/__tests__/pi-layers-wiring.test.ts b/packages/engine/src/__tests__/pi-layers-wiring.test.ts index 9b5cfc6a61..bb8d6224f5 100644 --- a/packages/engine/src/__tests__/pi-layers-wiring.test.ts +++ b/packages/engine/src/__tests__/pi-layers-wiring.test.ts @@ -117,6 +117,14 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ }, discoverAndLoadExtensions: discoverAndLoadExtensionsMock, getAgentDir: () => "/mock-agent-dir", + /* + FNXC:EngineTests 2026-07-17-11:45: + createFusionModelRegistry awaits ModelRuntime.create before ModelRegistry (FN-8142 / pi 0.80.8+). + Without this export the layers wiring suite fails every createFnAgent path. + */ + ModelRuntime: { + create: async () => ({ getAuth: async () => ({ auth: { headers: {} as Record } }) }), + }, ModelRegistry: class { static create(..._args: unknown[]) { return new (this as unknown as new () => unknown)(); diff --git a/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts b/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts index 591bd2e708..40cc106fbc 100644 --- a/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts @@ -250,7 +250,11 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { taskStore: taskStore as any, rootDir: process.cwd(), }); - vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" }); + // FNXC:EngineTests 2026-07-17-11:50: runFeatureValidation destructures { result, inspection }. + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + result: { status: "pass", summary: "ok" }, + inspection: { rootDir: process.cwd() }, + }); loop.start(); const periodicMaintenancePass = async () => loop.recoverActiveMissions(); @@ -258,7 +262,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { await periodicMaintenancePass(); expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001"); - expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion"); + // FNXC:EngineTests 2026-07-17-11:45: startValidatorRun now threads the completing task id. + expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001"); const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter( ([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions", ); @@ -316,13 +321,18 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { }; const loop = new MissionExecutionLoop({ missionStore: missionStore as any, taskStore: taskStore as any, rootDir: process.cwd() }); - vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" }); + // FNXC:EngineTests 2026-07-17-11:50: runFeatureValidation destructures { result, inspection }. + vi.spyOn(loop as any, "runValidation").mockResolvedValue({ + result: { status: "pass", summary: "ok" }, + inspection: { rootDir: process.cwd() }, + }); loop.start(); await loop.recoverActiveMissions(); expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001"); - expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion"); + // FNXC:EngineTests 2026-07-17-11:45: startValidatorRun now threads the completing task id. + expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001"); const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter( ([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions", ); diff --git a/packages/engine/src/__tests__/triage-finalize-duplicate-lineage.test.ts b/packages/engine/src/__tests__/triage-finalize-duplicate-lineage.test.ts index 544cbfd53c..5d998d3ac6 100644 --- a/packages/engine/src/__tests__/triage-finalize-duplicate-lineage.test.ts +++ b/packages/engine/src/__tests__/triage-finalize-duplicate-lineage.test.ts @@ -16,6 +16,8 @@ function createMockStore(overrides: Partial = {}): TaskStore { updateTask: vi.fn(), moveTask: vi.fn(), logEntry: vi.fn(), + // FNXC:EngineTests 2026-07-17-11:45: flagTriageDuplicate records task:auto-archived-duplicate activity. + recordActivity: vi.fn().mockResolvedValue(undefined), deleteTask: vi.fn(), on: vi.fn(), off: vi.fn(), @@ -102,8 +104,21 @@ describe("triage finalize duplicate lineage", () => { expect(vi.mocked(store.updateTask).mock.calls[0]?.[1]).not.toHaveProperty("sourceMetadataPatch"); }); - it("preserves duplicate stub delete path", async () => { - const store = createMockStore(); + it("preserves opt-in duplicate stub delete path", async () => { + /* + FNXC:EngineTests 2026-07-17-11:50: + Issue #2225 default is prompt (flag + pause). Deletion remains opt-in via + settings.triageDuplicateResolution === "delete" and still requires a live + canonical task for the marker short-circuit. + */ + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ requirePlanApproval: false, triageDuplicateResolution: "delete" } as Settings), + getTask: vi.fn().mockImplementation(async (id: string) => ( + id === "FN-4894" + ? createTask({ id: "FN-4894", title: "Canonical", column: "todo", status: null }) + : undefined + )), + }); await runRecovery(createTask(), "DUPLICATE: FN-4894\n", store); expect(store.deleteTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ @@ -113,6 +128,32 @@ describe("triage finalize duplicate lineage", () => { runId: expect.stringMatching(/^triage-delete-FN-001-/), }), })); - expect(store.updateTask).not.toHaveBeenCalled(); + }); + + it("flags and parks DUPLICATE markers under default prompt resolution", async () => { + const store = createMockStore({ + getTask: vi.fn().mockImplementation(async (id: string) => ( + id === "FN-4894" + ? createTask({ id: "FN-4894", title: "Canonical", column: "todo", status: null }) + : undefined + )), + }); + await runRecovery(createTask(), "DUPLICATE: FN-4894\n", store); + + expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ + type: "task:auto-archived-duplicate", + taskId: "FN-001", + })); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ + sourceMetadataPatch: expect.objectContaining({ nearDuplicateOf: "FN-4894", duplicateSource: "triage-marker" }), + }), + ); + expect(store.updateTask).toHaveBeenCalledWith( + "FN-001", + expect.objectContaining({ paused: true, pausedReason: "duplicate-decision-required" }), + ); + expect(store.deleteTask).not.toHaveBeenCalled(); }); }); diff --git a/packages/engine/src/__tests__/triage-split-into-subtasks-delete.test.ts b/packages/engine/src/__tests__/triage-split-into-subtasks-delete.test.ts index 739070b860..64107d2a9d 100644 --- a/packages/engine/src/__tests__/triage-split-into-subtasks-delete.test.ts +++ b/packages/engine/src/__tests__/triage-split-into-subtasks-delete.test.ts @@ -66,6 +66,8 @@ function createStore(overrides: Partial = {}): TaskStore { planningFallbackModelId: "fallback-model", } as Settings), logEntry: vi.fn().mockResolvedValue(undefined), + // FNXC:EngineTests 2026-07-17-11:45: flagTriageDuplicate records task:auto-archived-duplicate activity. + recordActivity: vi.fn().mockResolvedValue(undefined), appendAgentLog: vi.fn().mockResolvedValue(undefined), parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]), parseStepsFromPrompt: vi.fn().mockResolvedValue([]), @@ -148,9 +150,26 @@ describe("triage split/delete lineage forwarding", () => { })); }); - it("passes removeLineageReferences on DUPLICATE close", async () => { + it("passes removeLineageReferences on opt-in DUPLICATE delete resolution", async () => { + /* + FNXC:EngineTests 2026-07-17-11:50: + Default triageDuplicateResolution is prompt (flag, not delete). This suite + still covers the delete path when operators opt in. + */ const store = createStore({ - getTask: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + triageDuplicateResolution: "delete", + } as Settings), + getTask: vi.fn().mockImplementation(async (id: string) => ( + id === "FN-4894" + ? createTask({ id: "FN-4894", title: "Canonical", column: "todo", status: null }) + : undefined + )), deleteTask: vi.fn().mockResolvedValue(undefined), }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts index ba30aeeb75..3f321883e9 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../worktree-hooks.js", () => ({ installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined), @@ -16,15 +19,25 @@ vi.mock("../worktree-db-hydrate.js", () => ({ hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1, artifactsCopied: 0 }), })); -const { execMock, existsSyncMock, accessMock } = vi.hoisted(() => { +const { execMock, existsSyncMock } = vi.hoisted(() => { const mock = vi.fn(); (mock as any)[Symbol.for("nodejs.util.promisify.custom")] = mock; - return { execMock: mock, existsSyncMock: vi.fn(), accessMock: vi.fn().mockResolvedValue(undefined) }; + return { + execMock: mock, + existsSyncMock: vi.fn(), + }; }); vi.mock("node:child_process", () => ({ exec: execMock, execFile: vi.fn() })); -vi.mock("node:fs", () => ({ existsSync: existsSyncMock })); -vi.mock("node:fs/promises", () => ({ access: accessMock })); +/* +FNXC:EngineTests 2026-07-17-11:55: +Path reservation writes lock state under rootDir/.worktrees. Use real fs/promises +against a temp root; only stub existsSync for worktrunk path probes. +*/ +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: existsSyncMock }; +}); describe("acquireTaskWorktree backend wiring", () => { const task = { id: "FN-1", title: "Task", description: "Desc", branch: null, worktree: null } as any; @@ -33,19 +46,29 @@ describe("acquireTaskWorktree backend wiring", () => { pauseTask: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined), } as any; + const roots: string[] = []; + + async function makeRootDir(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-wt-backend-")); + roots.push(rootDir); + return rootDir; + } beforeEach(() => { execMock.mockReset(); existsSyncMock.mockReset(); existsSyncMock.mockReturnValue(true); - accessMock.mockReset(); - accessMock.mockResolvedValue(undefined); store.updateTask.mockClear(); store.logEntry.mockClear(); store.pauseTask.mockClear(); }); + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + it("uses native backend by default and emits no worktrunk audit", async () => { + const rootDir = await makeRootDir(); execMock.mockResolvedValue({ stdout: "", stderr: "" }); const audit = { git: vi.fn().mockResolvedValue(undefined), @@ -56,25 +79,25 @@ describe("acquireTaskWorktree backend wiring", () => { const result = await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store, settings: { worktreeNaming: "task-id" } as any, audit, }); expect(result.branch).toBe("fusion/fn-1"); - expect(result.worktreePath).toBe("/repo/.worktrees/fn-1"); + expect(result.worktreePath).toBe(`${rootDir}/.worktrees/fn-1`); /* * FNXC:WorktreeIsolation 2026-07-02-07:40: * acquireTaskWorktree now resolves the integration branch via `git symbolic-ref` and pins fresh worktree creation to that start point so new task branches never inherit the root checkout's ambient HEAD. With an empty mock stdout the resolver falls back to "main", so the native create command appends "main" as the start point and there are two exec calls (symbolic-ref + worktree add). */ expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); expect(execMock).toHaveBeenCalledWith( - 'git worktree add -b "fusion/fn-1" "/repo/.worktrees/fn-1" "main"', - expect.objectContaining({ cwd: "/repo" }), + `git worktree add -b "fusion/fn-1" "${rootDir}/.worktrees/fn-1" "main"`, + expect.objectContaining({ cwd: rootDir }), ); expect(audit.git).not.toHaveBeenCalledWith( expect.objectContaining({ type: "worktree:worktrunk-create" }), @@ -82,12 +105,13 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("routes through worktrunk backend when enabled and emits audit once", async () => { + const rootDir = await makeRootDir(); execMock.mockImplementation((command: string) => { if (command.includes('"config" "show"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command.includes('"switch" "--create"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command === "git worktree list --porcelain") { return Promise.resolve({ - stdout: "worktree /repo/.worktrees/fusion/fn-1\nbranch refs/heads/fusion/fn-1\n", + stdout: `worktree ${rootDir}/.worktrees/fusion/fn-1\nbranch refs/heads/fusion/fn-1\n`, stderr: "", }); } @@ -102,7 +126,7 @@ describe("acquireTaskWorktree backend wiring", () => { await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store, settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "wt" } } as any, audit, @@ -124,6 +148,7 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("passes audit into worktrunk-to-native fallback collision recovery", async () => { + const rootDir = await makeRootDir(); let nativeAddAttempts = 0; execMock.mockImplementation((command: string) => { if (command.includes('"wt" "switch" "--create"')) { @@ -135,14 +160,14 @@ describe("acquireTaskWorktree backend wiring", () => { ? Promise.reject({ message: "branch collision", stderr: "fatal: a branch named 'fusion/fn-1' already exists" }) : Promise.resolve({ stdout: "", stderr: "" }); } - if (command === "git worktree list --porcelain") return Promise.resolve({ stdout: "worktree /repo\nbranch refs/heads/main\n", stderr: "" }); + if (command === "git worktree list --porcelain") return Promise.resolve({ stdout: `worktree ${rootDir}\nbranch refs/heads/main\n`, stderr: "" }); if (command.startsWith("git cherry")) return Promise.resolve({ stdout: "", stderr: "" }); return Promise.resolve({ stdout: "deadbeef\n", stderr: "" }); }); const audit = { git: vi.fn().mockResolvedValue(undefined) }; await acquireTaskWorktree({ - task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + task: { ...task, executionStartBranch: "release" }, rootDir, store, settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fallback-native" } } as any, audit: audit as any, }); @@ -154,10 +179,11 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("throws worktrunk_binary_missing with no binaryPath", async () => { + const rootDir = await makeRootDir(); await expect( acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store, settings: { worktreeNaming: "task-id", worktrunk: { enabled: true } } as any, }), @@ -170,23 +196,36 @@ describe("acquireTaskWorktree backend wiring", () => { expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); expect(execMock).toHaveBeenCalledWith( "git remote", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); expect(execMock.mock.calls.some((call) => String(call[0]).includes('"switch"'))).toBe(false); }); it("throws worktrunk_operation_failed and preserves stderr", async () => { - execMock.mockRejectedValue({ stderr: "worktrunk exploded", status: 17 }); - const explicitBinaryPath = "/opt/wt"; + const rootDir = await makeRootDir(); + /* + FNXC:EngineTests 2026-07-17-11:55: + WorktrunkWorktreeBackend access()-checks the override path before exec. Use a real + temp file so access succeeds; probe via --version must also succeed; only switch + --create should surface operation_failed. + */ + const explicitBinaryPath = join(rootDir, "fake-wt"); + await writeFile(explicitBinaryPath, "#!/bin/sh\n"); + execMock.mockImplementation((command: string) => { + if (String(command).includes("--version") || String(command).includes("version")) { + return Promise.resolve({ stdout: "wt 0.4.2\n", stderr: "" }); + } + return Promise.reject({ stderr: "worktrunk exploded", status: 17 }); + }); await expect( acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store, settings: { worktreeNaming: "task-id", worktrunk: { enabled: true, binaryPath: explicitBinaryPath } } as any, }), @@ -200,6 +239,7 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("forwards canonical branch and pinned execution start point to an injected backend", async () => { + const rootDir = await makeRootDir(); const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-1" }); const backend: WorktreeBackend = { kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(), @@ -207,7 +247,7 @@ describe("acquireTaskWorktree backend wiring", () => { }; await acquireTaskWorktree({ - task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + task: { ...task, executionStartBranch: "release" }, rootDir, store, settings: { worktreeNaming: "task-id" } as any, backend, }); @@ -217,6 +257,7 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("forwards canonical branch and pinned start point on pool fresh fallback", async () => { + const rootDir = await makeRootDir(); const create = vi.fn().mockResolvedValue({ path: "/tmp/fresh", branch: "fusion/fn-1" }); const backend: WorktreeBackend = { kind: "native", create, remove: vi.fn(), sync: vi.fn().mockResolvedValue({ skipped: true as const }), prune: vi.fn(), @@ -229,7 +270,7 @@ describe("acquireTaskWorktree backend wiring", () => { }; await acquireTaskWorktree({ - task: { ...task, executionStartBranch: "release" }, rootDir: "/repo", store, + task: { ...task, executionStartBranch: "release" }, rootDir, store, settings: { worktreeNaming: "task-id", recycleWorktrees: true } as any, backend, pool: pool as any, }); @@ -239,6 +280,7 @@ describe("acquireTaskWorktree backend wiring", () => { }); it("uses explicit backend override", async () => { + const rootDir = await makeRootDir(); const create = vi.fn().mockResolvedValue({ path: "/tmp/backend", branch: "fusion/fn-backend" }); const backend: WorktreeBackend = { kind: "native", @@ -251,7 +293,7 @@ describe("acquireTaskWorktree backend wiring", () => { const result = await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store, settings: { worktreeNaming: "task-id", worktrunk: { enabled: true } } as any, backend, @@ -267,11 +309,11 @@ describe("acquireTaskWorktree backend wiring", () => { expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); expect(execMock).toHaveBeenCalledWith( "git remote", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); }); }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts index f174bc7e44..58771d72cc 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { execMock, existsSyncMock } = vi.hoisted(() => { const mock = vi.fn(); @@ -7,7 +10,16 @@ const { execMock, existsSyncMock } = vi.hoisted(() => { }); vi.mock("node:child_process", () => ({ exec: execMock, execFile: vi.fn() })); -vi.mock("node:fs", () => ({ existsSync: existsSyncMock })); +/* +FNXC:EngineTests 2026-07-17-11:55: +Path reservation now mkdirs lock state under rootDir/.worktrees before create. +Keep real node:fs/promises so reservation works against a temp root; only stub +existsSync for worktrunk path existence checks. +*/ +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: existsSyncMock }; +}); vi.mock("../worktree-hooks.js", () => ({ installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined), IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD", @@ -46,20 +58,33 @@ const makeAudit = () => { }; }; -beforeEach(() => { - execMock.mockReset(); - existsSyncMock.mockReset(); - existsSyncMock.mockReturnValue(true); -}); - describe("acquireTaskWorktree worktrunk wiring", () => { + const roots: string[] = []; + + async function makeRootDir(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-wt-")); + roots.push(rootDir); + return rootDir; + } + + beforeEach(() => { + execMock.mockReset(); + existsSyncMock.mockReset(); + existsSyncMock.mockReturnValue(true); + }); + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + it("uses native by default when worktrunk settings absent", async () => { execMock.mockResolvedValue({ stdout: "", stderr: "" }); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); + const rootDir = await makeRootDir(); const result = await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: {}, }); @@ -79,10 +104,11 @@ describe("acquireTaskWorktree worktrunk wiring", () => { it("prefers explicit createWorktree override", async () => { const createWorktree = vi.fn().mockResolvedValue({ path: "/tmp/new", branch: "fusion/fn-1" }); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); + const rootDir = await makeRootDir(); await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, binaryPath: "wt" } } as any, createWorktree, @@ -93,12 +119,13 @@ describe("acquireTaskWorktree worktrunk wiring", () => { }); it("emits worktrunk + native create audits when worktrunk succeeds", async () => { + const rootDir = await makeRootDir(); execMock.mockImplementation((command: string) => { if (command.includes('"config" "show"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command.includes('"switch" "--create"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command === "git worktree list --porcelain") { return Promise.resolve({ - stdout: "worktree /repo/.worktrees/fusion/fn-1\nbranch refs/heads/fusion/fn-1\n", + stdout: `worktree ${rootDir}/.worktrees/fusion/fn-1\nbranch refs/heads/fusion/fn-1\n`, stderr: "", }); } @@ -109,7 +136,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => { await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any, audit: audit as any, @@ -122,30 +149,32 @@ describe("acquireTaskWorktree worktrunk wiring", () => { it("propagates resolved worktrunk path into result and task store", async () => { const store = makeStore(); + const rootDir = await makeRootDir(); + const resolvedPath = join(rootDir, ".worktrees/custom/fusion-fn-1"); execMock.mockImplementation((command: string) => { if (command.includes('"config" "show"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command.includes('"switch" "--create"')) return Promise.resolve({ stdout: "", stderr: "" }); if (command === "git worktree list --porcelain") { return Promise.resolve({ - stdout: "worktree /repo/.worktrees/custom/fusion-fn-1\nbranch refs/heads/fusion/fn-1\n", + stdout: `worktree ${resolvedPath}\nbranch refs/heads/fusion/fn-1\n`, stderr: "", }); } return Promise.resolve({ stdout: "", stderr: "" }); }); - existsSyncMock.mockImplementation((path: string) => path === "/repo/.worktrees/custom/fusion-fn-1"); + existsSyncMock.mockImplementation((path: string) => path === resolvedPath); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); const result = await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: store as any, settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any, }); - expect(result.worktreePath).toBe("/repo/.worktrees/custom/fusion-fn-1"); + expect(result.worktreePath).toBe(resolvedPath); expect(store.updateTask).toHaveBeenCalledWith("FN-1", { - worktree: "/repo/.worktrees/custom/fusion-fn-1", + worktree: resolvedPath, branch: "fusion/fn-1", }); }); @@ -154,11 +183,12 @@ describe("acquireTaskWorktree worktrunk wiring", () => { execMock.mockRejectedValue({ stderr: "nope", status: 9 }); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); const { audit, events } = makeAudit(); + const rootDir = await makeRootDir(); await expect( acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fail" } } as any, audit: audit as any, @@ -181,10 +211,11 @@ describe("acquireTaskWorktree worktrunk wiring", () => { }); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); const { audit, events } = makeAudit(); + const rootDir = await makeRootDir(); await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, binaryPath: "wt", onFailure: "fallback-native" } } as any, audit: audit as any, @@ -198,11 +229,12 @@ describe("acquireTaskWorktree worktrunk wiring", () => { it("fails with binary missing when enabled and binaryPath absent", async () => { const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); + const rootDir = await makeRootDir(); await expect( acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, onFailure: "fail" } } as any, }), @@ -212,10 +244,11 @@ describe("acquireTaskWorktree worktrunk wiring", () => { it("uses custom backend when provided", async () => { const create = vi.fn().mockResolvedValue({ path: "/tmp/custom", branch: "fusion/fn-1-custom" }); const { acquireTaskWorktree } = await import("../worktree-acquisition.js"); + const rootDir = await makeRootDir(); const result = await acquireTaskWorktree({ task, - rootDir: "/repo", + rootDir, store: makeStore() as any, settings: { worktrunk: { enabled: true, binaryPath: "wt" } } as any, backend: { @@ -237,11 +270,11 @@ describe("acquireTaskWorktree worktrunk wiring", () => { expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); expect(execMock).toHaveBeenCalledWith( "git remote", - expect.objectContaining({ cwd: "/repo" }), + expect.objectContaining({ cwd: rootDir }), ); }); }); diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index e0fd067fa5..75fbfa038e 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3322,7 +3322,8 @@ "unpauseUnavailable": "La acción de reanudar no está disponible", "useProjectDefault": "Usar predeterminado del proyecto", "viewOptions": "Opciones de vista", - "workflowLabel": "" + "workflowLabel": "", + "statusReplan": "Replanificar" }, "mailbox": { "agent": "Agente", @@ -5906,7 +5907,11 @@ "restartFailed": "", "restartNow": "", "restartUnavailable": "", - "restarting": "" + "restarting": "", + "mobileNavPrimaryItems": "Acciones rápidas del pie móvil", + "mobileNavPrimaryItemsHint": "Predeterminado: Panel, Tareas, Agentes, Misiones, Chat, Buzón. Los destinos no seleccionados permanecen en el menú Más.", + "moveNavItemEarlier": "Mover {{item}} antes", + "moveNavItemLater": "Mover {{item}} después" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8381,7 +8386,8 @@ "costBadgeAriaLabel": "", "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", - "awaitingApprovalTitle": "" + "awaitingApprovalTitle": "", + "statusReplan": "Replanificar" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index c960d174fc..f68941f764 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3322,7 +3322,8 @@ "unpauseUnavailable": "L'action Reprendre est indisponible", "useProjectDefault": "Utiliser le défaut du projet", "viewOptions": "Options d'affichage", - "workflowLabel": "" + "workflowLabel": "", + "statusReplan": "Replanifier" }, "mailbox": { "agent": "Agent", @@ -5906,7 +5907,11 @@ "restartFailed": "", "restartNow": "", "restartUnavailable": "", - "restarting": "" + "restarting": "", + "mobileNavPrimaryItems": "Actions rapides du bas mobile", + "mobileNavPrimaryItemsHint": "Par défaut : Tableau de bord, Tâches, Agents, Missions, Chat, Boîte de réception. Les destinations non sélectionnées restent dans le menu Plus.", + "moveNavItemEarlier": "Déplacer {{item}} plus tôt", + "moveNavItemLater": "Déplacer {{item}} plus tard" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8381,7 +8386,8 @@ "costBadgeAriaLabel": "", "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", - "awaitingApprovalTitle": "" + "awaitingApprovalTitle": "", + "statusReplan": "Replanifier" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index e003d9846b..08059e6e58 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3322,7 +3322,8 @@ "unpauseUnavailable": "재개 작업을 사용할 수 없습니다", "useProjectDefault": "프로젝트 기본값 사용", "viewOptions": "보기 옵션", - "workflowLabel": "" + "workflowLabel": "", + "statusReplan": "재계획" }, "mailbox": { "agent": "에이전트", @@ -5906,7 +5907,11 @@ "restartFailed": "", "restartNow": "", "restartUnavailable": "", - "restarting": "" + "restarting": "", + "mobileNavPrimaryItems": "모바일 하단 빠른 작업", + "mobileNavPrimaryItemsHint": "기본값: 대시보드, 작업, 에이전트, 미션, 채팅, 메일함. 선택하지 않은 목적지는 더보기 메뉴에 유지됩니다.", + "moveNavItemEarlier": "{{item}} 앞으로 이동", + "moveNavItemLater": "{{item}} 뒤로 이동" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8381,7 +8386,8 @@ "costBadgeAriaLabel": "", "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", - "awaitingApprovalTitle": "" + "awaitingApprovalTitle": "", + "statusReplan": "재계획" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 6eaffcbf88..feb1164716 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3322,7 +3322,8 @@ "unpauseUnavailable": "恢复操作不可用", "useProjectDefault": "使用项目默认", "viewOptions": "视图选项", - "workflowLabel": "" + "workflowLabel": "", + "statusReplan": "重新规划" }, "mailbox": { "agent": "代理", @@ -5906,7 +5907,11 @@ "restartFailed": "", "restartNow": "", "restartUnavailable": "", - "restarting": "" + "restarting": "", + "mobileNavPrimaryItems": "移动端底部快捷操作", + "mobileNavPrimaryItemsHint": "默认:仪表盘、任务、代理、任务组、聊天、邮箱。未选中的目标仍保留在“更多”菜单中。", + "moveNavItemEarlier": "将 {{item}} 前移", + "moveNavItemLater": "将 {{item}} 后移" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8381,7 +8386,8 @@ "costBadgeAriaLabel": "", "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", - "awaitingApprovalTitle": "" + "awaitingApprovalTitle": "", + "statusReplan": "重新规划" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 73745b699d..b8ec1d1eb5 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3322,7 +3322,8 @@ "unpauseUnavailable": "恢復操作無法使用", "useProjectDefault": "使用專案預設", "viewOptions": "檢視選項", - "workflowLabel": "" + "workflowLabel": "", + "statusReplan": "重新規劃" }, "mailbox": { "agent": "代理", @@ -5906,7 +5907,11 @@ "restartFailed": "", "restartNow": "", "restartUnavailable": "", - "restarting": "" + "restarting": "", + "mobileNavPrimaryItems": "行動版底部快捷操作", + "mobileNavPrimaryItemsHint": "預設:儀表板、任務、代理、任務組、聊天、信箱。未選取的目的地仍保留在「更多」選單中。", + "moveNavItemEarlier": "將 {{item}} 前移", + "moveNavItemLater": "將 {{item}} 後移" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8381,7 +8386,8 @@ "costBadgeAriaLabel": "", "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", - "awaitingApprovalTitle": "" + "awaitingApprovalTitle": "", + "statusReplan": "重新規劃" }, "terminal": { "arrowKeysLabel": "",