diff --git a/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts b/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts index 3f07dfa78..4f302763e 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 5afddf5a1..c00c18f4a 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 5f5ce4636..50c48bd93 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 9b5cfc6a6..bb8d6224f 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 591bd2e70..40cc106fb 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 544cbfd53..5d998d3ac 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 739070b86..64107d2a9 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 ba30aeeb7..3f321883e 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 f174bc7e4..58771d72c 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 a07942d6e..a33cbeda7 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3300,7 +3300,7 @@ "useProjectDefault": "Usar predeterminado del proyecto", "viewOptions": "Opciones de vista", "workflowLabel": "", - "statusReplan": "" + "statusReplan": "Replanificar" }, "mailbox": { "agent": "Agente", @@ -5889,13 +5889,13 @@ "restartNow": "", "restartUnavailable": "", "restarting": "", - "mobileNavPrimaryItems": "", - "mobileNavPrimaryItemsHint": "", - "addNavItem": "", - "selectNavItem": "", - "removeNavItem": "", - "moveNavItemEarlier": "", - "moveNavItemLater": "" + "mobileNavPrimaryItems": "Acciones rápidas del pie móvil", + "mobileNavPrimaryItemsHint": "Predeterminado: Panel, Tareas, Agentes, Misiones, Chat, Buzón. Añade destinos elegibles; los no seleccionados permanecen en Más.", + "addNavItem": "Añadir acción rápida", + "selectNavItem": "Elegir un destino", + "removeNavItem": "Quitar {{item}}", + "moveNavItemEarlier": "Mover {{item}} antes", + "moveNavItemLater": "Mover {{item}} después" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8371,7 +8371,7 @@ "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", "awaitingApprovalTitle": "", - "statusReplan": "" + "statusReplan": "Replanificar" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index f62b83ff5..0db0d0436 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3300,7 +3300,7 @@ "useProjectDefault": "Utiliser le défaut du projet", "viewOptions": "Options d'affichage", "workflowLabel": "", - "statusReplan": "" + "statusReplan": "Replanifier" }, "mailbox": { "agent": "Agent", @@ -5889,13 +5889,13 @@ "restartNow": "", "restartUnavailable": "", "restarting": "", - "mobileNavPrimaryItems": "", - "mobileNavPrimaryItemsHint": "", - "addNavItem": "", - "selectNavItem": "", - "removeNavItem": "", - "moveNavItemEarlier": "", - "moveNavItemLater": "" + "mobileNavPrimaryItems": "Actions rapides du bas mobile", + "mobileNavPrimaryItemsHint": "Par défaut : Tableau de bord, Tâches, Agents, Missions, Chat, Boîte de réception. Ajoutez des destinations éligibles ; les non sélectionnées restent dans Plus.", + "addNavItem": "Ajouter une action rapide", + "selectNavItem": "Choisir une destination", + "removeNavItem": "Retirer {{item}}", + "moveNavItemEarlier": "Déplacer {{item}} plus tôt", + "moveNavItemLater": "Déplacer {{item}} plus tard" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8371,7 +8371,7 @@ "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", "awaitingApprovalTitle": "", - "statusReplan": "" + "statusReplan": "Replanifier" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 73711fa05..eabcc3c07 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3300,7 +3300,7 @@ "useProjectDefault": "프로젝트 기본값 사용", "viewOptions": "보기 옵션", "workflowLabel": "", - "statusReplan": "" + "statusReplan": "재계획" }, "mailbox": { "agent": "에이전트", @@ -5889,13 +5889,13 @@ "restartNow": "", "restartUnavailable": "", "restarting": "", - "mobileNavPrimaryItems": "", - "mobileNavPrimaryItemsHint": "", - "addNavItem": "", - "selectNavItem": "", - "removeNavItem": "", - "moveNavItemEarlier": "", - "moveNavItemLater": "" + "mobileNavPrimaryItems": "모바일 하단 빠른 작업", + "mobileNavPrimaryItemsHint": "기본값: 대시보드, 작업, 에이전트, 미션, 채팅, 메일함. 가능한 대상을 추가하세요. 선택하지 않은 항목은 더보기에 유지됩니다.", + "addNavItem": "빠른 작업 추가", + "selectNavItem": "대상 선택", + "removeNavItem": "{{item}} 제거", + "moveNavItemEarlier": "{{item}} 앞으로 이동", + "moveNavItemLater": "{{item}} 뒤로 이동" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8371,7 +8371,7 @@ "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", "awaitingApprovalTitle": "", - "statusReplan": "" + "statusReplan": "재계획" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index b0109c154..cf46bd59d 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3300,7 +3300,7 @@ "useProjectDefault": "使用项目默认", "viewOptions": "视图选项", "workflowLabel": "", - "statusReplan": "" + "statusReplan": "重新规划" }, "mailbox": { "agent": "代理", @@ -5889,13 +5889,13 @@ "restartNow": "", "restartUnavailable": "", "restarting": "", - "mobileNavPrimaryItems": "", - "mobileNavPrimaryItemsHint": "", - "addNavItem": "", - "selectNavItem": "", - "removeNavItem": "", - "moveNavItemEarlier": "", - "moveNavItemLater": "" + "mobileNavPrimaryItems": "移动端底部快捷操作", + "mobileNavPrimaryItemsHint": "默认:仪表盘、任务、代理、任务组、聊天、邮箱。可添加符合条件的目标;未选中的目标仍在“更多”中。", + "addNavItem": "添加快捷操作", + "selectNavItem": "选择目标", + "removeNavItem": "移除 {{item}}", + "moveNavItemEarlier": "将 {{item}} 前移", + "moveNavItemLater": "将 {{item}} 后移" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8371,7 +8371,7 @@ "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", "awaitingApprovalTitle": "", - "statusReplan": "" + "statusReplan": "重新规划" }, "terminal": { "arrowKeysLabel": "", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 1b8ccdb33..46674c0e8 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3300,7 +3300,7 @@ "useProjectDefault": "使用專案預設", "viewOptions": "檢視選項", "workflowLabel": "", - "statusReplan": "" + "statusReplan": "重新規劃" }, "mailbox": { "agent": "代理", @@ -5889,13 +5889,13 @@ "restartNow": "", "restartUnavailable": "", "restarting": "", - "mobileNavPrimaryItems": "", - "mobileNavPrimaryItemsHint": "", - "addNavItem": "", - "selectNavItem": "", - "removeNavItem": "", - "moveNavItemEarlier": "", - "moveNavItemLater": "" + "mobileNavPrimaryItems": "行動版底部快捷操作", + "mobileNavPrimaryItemsHint": "預設:儀表板、任務、代理、任務組、聊天、信箱。可新增符合條件的目的地;未選取的目的地仍在「更多」中。", + "addNavItem": "新增快捷操作", + "selectNavItem": "選擇目的地", + "removeNavItem": "移除 {{item}}", + "moveNavItemEarlier": "將 {{item}} 前移", + "moveNavItemLater": "將 {{item}} 後移" }, "globalGeneral": { "andShowsUpdateNoticesInTheCLIAnd": "", @@ -8371,7 +8371,7 @@ "awaitingApprovalPlanReviewReplanCapTitle": "", "reviewBudgetExhausted": "", "awaitingApprovalTitle": "", - "statusReplan": "" + "statusReplan": "重新規劃" }, "terminal": { "arrowKeysLabel": "",