From f3c50de7e746887f42f2870ac8e0706ef654d96d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:24:35 -0700 Subject: [PATCH 01/20] test(engine): stub reconcileSupersededGeneratedFixFeatures on mission-validation-trigger-gap mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recoverActiveMissions (mission-execution-loop.ts:263) calls missionStore.reconcileSupersededGeneratedFixFeatures per slice; the 5 MissionExecutionLoop-backed mocks here omitted it, so recovery threw (TypeError) at the slice loop and aborted before processTaskOutcome / ensureFeatureAssertionLinked / startValidatorRun ran — 4 tests failed. Add a no-op stub (matches mission-execution-loop.test.ts reference) with an FNXC:MissionReconcile note. No-op is correct: supersession is not exercised by these tests. --- docs/signals-connectors.md | 2 ++ .../src/__tests__/chat-routes.rooms.test.ts | 7 +++++-- .../__tests__/register-git-github.backfill.test.ts | 2 ++ .../src/__tests__/routes-agent-import.test.ts | 12 +++++++----- .../src/__tests__/routes-run-cited-goals.test.ts | 4 +++- .../src/__tests__/routes-sandbox-audit.test.ts | 4 +++- .../src/__tests__/session-resume-history.test.ts | 2 ++ .../__tests__/task-create-workflow-route.test.ts | 2 +- .../mission-validation-trigger-gap.test.ts | 10 ++++++++++ 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/signals-connectors.md b/docs/signals-connectors.md index a65059029b..b02d4788d4 100644 --- a/docs/signals-connectors.md +++ b/docs/signals-connectors.md @@ -142,6 +142,8 @@ Configure a GitLab project or group webhook with: Fusion verifies GitLab's `X-Gitlab-Token` header. GitLab's webhook docs now recommend signing tokens for new webhooks, but this connector intentionally supports the documented secret-token compatibility path required by existing GitLab.com and self-managed GitLab installations. This task introduces no GitLab binary, CLI, download, or checksum-managed artifact. +Broader GitHub-to-GitLab parity (issue import, linked tracking, lifecycle automation, and Command Center analytics) is mapped in [GitLab Parity Inventory](./gitlab-parity-inventory.md). These signal webhooks are the GitLab side of that parity surface. + Supported GitLab events: - Project and group **Issue Hook** payloads with `object_kind`/`event_type` of `issue`. diff --git a/packages/dashboard/src/__tests__/chat-routes.rooms.test.ts b/packages/dashboard/src/__tests__/chat-routes.rooms.test.ts index 32937578aa..0eb0ae1474 100644 --- a/packages/dashboard/src/__tests__/chat-routes.rooms.test.ts +++ b/packages/dashboard/src/__tests__/chat-routes.rooms.test.ts @@ -10,8 +10,11 @@ import type { TaskStore } from "@fusion/core"; import { request } from "../test-request.js"; import { createSSE } from "../sse.js"; -class MockStore { - constructor(private readonly rootDir: string, private readonly db: Database) {} +// FNXC:DashboardTests 2026-07-07-08:10: createServer now subscribes via store.on("task:moved") (TaskStore extends EventEmitter) to purge task-planner chats on archive (FN-7337); back the mock store with a real EventEmitter so server startup wiring works instead of throwing "store.on is not a function". +class MockStore extends EventEmitter { + constructor(private readonly rootDir: string, private readonly db: Database) { + super(); + } getRootDir(): string { return this.rootDir; } getFusionDir(): string { return join(this.rootDir, ".fusion"); } diff --git a/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts b/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts index 391fc5fbc3..154f4a29ed 100644 --- a/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts +++ b/packages/dashboard/src/__tests__/register-git-github.backfill.test.ts @@ -16,6 +16,8 @@ function createStore(name: string): TaskStore { getSettings: vi.fn().mockResolvedValue({}), getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), logEntry: vi.fn().mockResolvedValue(undefined), + // FNXC:DashboardTests 2026-07-07-08:10: createServer subscribes via store.on("task:moved") to purge task-planner chats on archive (FN-7337); provide a no-op EventEmitter "on" so server startup wiring works instead of throwing "store.on is not a function". + on: vi.fn(), updateTask: vi.fn().mockResolvedValue(undefined), getDatabase: vi.fn().mockReturnValue({ exec: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-agent-import.test.ts b/packages/dashboard/src/__tests__/routes-agent-import.test.ts index 9066abd6ec..3d5c874fd9 100644 --- a/packages/dashboard/src/__tests__/routes-agent-import.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-import.test.ts @@ -57,8 +57,14 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + /* + FNXC:DashboardAgentImportTests 2026-07-07-08:05: + Spread the real @fusion/core module and override only the agent-import seams (AgentStore, ChatStore, the company parsers, and the no-op guard/hook stubs). FN-7444 added planning-summary deepening constants (PLANNING_DEEPEN_PROCEED_OPTION_ID etc.) that src/planning.ts imports from core; a fully hand-written mock omitted them and made createServer fail to load with "No export is defined on the @fusion/core mock". Spreading importOriginal keeps every real export (including future additions) resolvable while the explicit keys below retain the focused mock behavior. This also removes the prior duplicate CLI_AGENT_ADAPTER_IDS / sanitizeCliAgentSettings keys (a merge artifact whose second copy silently won). + */ + const actual = await importOriginal() as Record; return { + ...actual, AgentStore: class MockAgentStore { init = mockInit; listAgents = mockListAgents; @@ -71,11 +77,7 @@ vi.mock("@fusion/core", () => { parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args), parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args), prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args), - CLI_AGENT_ADAPTER_IDS: ["claude-code", "codex", "droid", "pi", "generic"], - sanitizeCliAgentSettings: (value: unknown) => value, AgentCompaniesParseError: MockAgentCompaniesParseError, - CLI_AGENT_ADAPTER_IDS: ["claude-code", "codex", "droid", "pi", "generic"], - sanitizeCliAgentSettings: () => undefined, isEphemeralAgent: (agent: { metadata?: Record }) => agent?.metadata?.agentKind === "task-worker", deterministicGuardLocks: new Map(), diff --git a/packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts b/packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts index 7f7f9cbd3d..2c4c50b57f 100644 --- a/packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts +++ b/packages/dashboard/src/__tests__/routes-run-cited-goals.test.ts @@ -2,6 +2,7 @@ FNXC:DashboardTests 2026-06-14-09:58: FN-6444 rescues this server route test from the curated skip-list; the fake SQLite statement returns better-sqlite-style mutation metadata so createServer boot sweeps exercise real startup paths. */ +import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { request } from "../test-request.js"; @@ -23,7 +24,8 @@ vi.mock("@fusion/core", async () => { }; }); -class MockStore { +// FNXC:DashboardTests 2026-07-07-08:10: createServer now subscribes via store.on("task:moved") (TaskStore extends EventEmitter) to purge task-planner chats on archive (FN-7337); back the mock store with a real EventEmitter so server startup wiring works instead of throwing "store.on is not a function". +class MockStore extends EventEmitter { getRunAuditEvents = mockGetRunAuditEvents; getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]); getMutationsForRun = vi.fn().mockResolvedValue([]); diff --git a/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts b/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts index 6cd37b2c77..d76ea498ea 100644 --- a/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts +++ b/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from "node:events"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { request } from "../test-request.js"; @@ -20,7 +21,8 @@ vi.mock("../project-store-resolver.js", () => ({ getOrCreateProjectStore: vi.fn(), })); -class MockStore { +// FNXC:DashboardTests 2026-07-07-08:10: createServer now subscribes via store.on("task:moved") (TaskStore extends EventEmitter) to purge task-planner chats on archive (FN-7337); back the mock store with a real EventEmitter so server startup wiring works instead of throwing "store.on is not a function". +class MockStore extends EventEmitter { getRunAuditEvents = mockGetRunAuditEvents; getAgentLogsByTimeRange = vi.fn().mockResolvedValue([]); getMutationsForRun = vi.fn().mockResolvedValue([]); diff --git a/packages/dashboard/src/__tests__/session-resume-history.test.ts b/packages/dashboard/src/__tests__/session-resume-history.test.ts index afd35e1c28..3b9ade10e7 100644 --- a/packages/dashboard/src/__tests__/session-resume-history.test.ts +++ b/packages/dashboard/src/__tests__/session-resume-history.test.ts @@ -49,6 +49,8 @@ vi.mock("@fusion/engine", () => ({ resolvedSkillNames: [], skillSource: "none" as const, })), + // FNXC:DashboardSessionTests 2026-07-07-08:15: planning/mission-interview sessions now resolve MCP servers via resolveMcpServersForStore before createFnAgent; focused engine mock must export it (returning the real empty-runtime shape) so session generation completes instead of throwing on a missing mock export. + resolveMcpServersForStore: vi.fn(async () => ({ servers: [], errors: [] })), createFnAgent: mockCreateFnAgent, })); diff --git a/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts b/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts index 064e383fe4..8b3eab74cd 100644 --- a/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts +++ b/packages/dashboard/src/routes/__tests__/task-create-workflow-route.test.ts @@ -128,7 +128,7 @@ describe("POST /tasks workflowId (U6/R3)", () => { it.each([ ["default coding", "builtin:coding", ["plan-review", "code-review"]], - ["legacy coding", "builtin:legacy-coding", ["code-review"]], + ["legacy coding", "builtin:legacy-coding", ["plan-review", "code-review"]], ["coding per-step review", "builtin:stepwise-coding", ["plan-review", "code-review"]], ])("%s workflow create/select/resolve works end to end", async (_label, workflowId, defaultSteps) => { const res = await post("/api/tasks", { 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 2cbfdb9ba9..7c029ddbe0 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 @@ -101,6 +101,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]), getFeature: vi.fn(() => feature), transitionLoopState: vi.fn(), + // FNXC:MissionReconcile 2026-07-07-08:21 real MissionStore method (mission-store.ts:3185); recoverActiveMissions calls it per slice and aborts recovery if missing — stub even when supersession isn't exercised. + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 0, featureIds: [] as string[] })), }; const taskStore = { getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })), @@ -132,6 +134,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]), getFeature: vi.fn(() => feature), transitionLoopState: vi.fn(), + // FNXC:MissionReconcile 2026-07-07-08:21 real MissionStore method (mission-store.ts:3185); recoverActiveMissions calls it per slice and aborts recovery if missing — stub even when supersession isn't exercised. + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 0, featureIds: [] as string[] })), }; const taskStore = { getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })), @@ -167,6 +171,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]), getFeature: vi.fn(() => feature), transitionLoopState: vi.fn(), + // FNXC:MissionReconcile 2026-07-07-08:21 real MissionStore method (mission-store.ts:3185); recoverActiveMissions calls it per slice and aborts recovery if missing — stub even when supersession isn't exercised. + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 0, featureIds: [] as string[] })), }; const taskStore = { getTask: vi.fn(async () => ({ id: "FN-001", column: "done" })), @@ -222,6 +228,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { getMission: vi.fn(() => ({ id: "M-001", status: "active" })), logMissionEvent: vi.fn(), transitionLoopState: vi.fn(), + // FNXC:MissionReconcile 2026-07-07-08:21 real MissionStore method (mission-store.ts:3185); recoverActiveMissions calls it per slice and aborts recovery if missing — stub even when supersession isn't exercised. + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 0, featureIds: [] as string[] })), setFeatureCurrentTaskRunId: vi.fn(), getFailuresForRun: vi.fn(() => []), }; @@ -289,6 +297,8 @@ describe("FN-5715 reliability: mission validation trigger gap", () => { getMission: vi.fn(() => ({ id: "M-001", status: "active" })), logMissionEvent: vi.fn(), transitionLoopState: vi.fn(), + // FNXC:MissionReconcile 2026-07-07-08:21 real MissionStore method (mission-store.ts:3185); recoverActiveMissions calls it per slice and aborts recovery if missing — stub even when supersession isn't exercised. + reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 0, featureIds: [] as string[] })), setFeatureCurrentTaskRunId: vi.fn(), getFailuresForRun: vi.fn(() => []), }; From 7e089e188e3d79f189a02c95c2355c68b60bd560 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:27:26 -0700 Subject: [PATCH 02/20] test(dashboard): align app tests with recent product changes (FN-7057/7340/7156/7342/6825/7265) Update five dashboard app test files whose assertions drifted from intentional product changes that landed on main without updating them: - graph-workflow-header: FN-7057 treats stale/missing workflow ids as the default workflow, so FN-unknown now shows under the default selection. - EngineControlMenu.css: FN-7340 added a 768px range-thumb touch-target block; narrow the popover-breakpoint assertion to that selector. - MissionManager.delete-confirm: FN-7156 removed first-mission auto-select; explicitly select the mission before the detail-delete flow. - board-mobile-initial-render: FN-7342 preserves board column scroll during stabilization; FN-6825 renders the workflow toolbar on options, not callbacks. - workflow-auto-layout: FN-7265 removed the stepwise review node (per-step review lives in the foreach); the connected run ends at completion-summary. No assertion was loosened or deleted to force a pass; each change cites the breaking commit via an FNXC comment. packages/dashboard is private (no changeset). --- .../__tests__/graph-workflow-header.test.tsx | 6 +++- .../__tests__/EngineControlMenu.css.test.ts | 28 ++++++++++++++++++- .../MissionManager.delete-confirm.test.tsx | 6 ++++ .../board-mobile-initial-render.test.tsx | 22 +++++++++++---- .../__tests__/workflow-auto-layout.test.ts | 6 +++- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx b/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx index d4eee2430e..3270b8a805 100644 --- a/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx +++ b/packages/dashboard/app/__tests__/graph-workflow-header.test.tsx @@ -89,11 +89,15 @@ describe("Graph workflow header integration", () => { expect(headerSlot.contains(selector)).toBe(true); const contextTasks = screen.getByTestId("graph-plugin-context-tasks"); + /* + FNXC:GraphWorkflowSelection 2026-07-07-08:15: + FN-7057 changed filterTasksByGraphWorkflowSelection to treat stale taskWorkflowIds entries that reference deleted/missing workflows as DEFAULT-workflow assignments (so tasks never vanish from every view). FN-unknown carries wf-missing, so under the default Coding selection it now resolves to the default workflow and is SHOWN, not filtered out. + */ await waitFor(() => { expect(within(contextTasks).getByTestId("graph-context-task-FN-default")).toBeInTheDocument(); expect(within(contextTasks).getByTestId("graph-context-task-FN-unassigned")).toBeInTheDocument(); expect(within(contextTasks).queryByTestId("graph-context-task-FN-review")).toBeNull(); - expect(within(contextTasks).queryByTestId("graph-context-task-FN-unknown")).toBeNull(); + expect(within(contextTasks).getByTestId("graph-context-task-FN-unknown")).toBeInTheDocument(); }); fireEvent.click(selector); diff --git a/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts b/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts index 8e148a0588..7581933b57 100644 --- a/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts +++ b/packages/dashboard/app/components/__tests__/EngineControlMenu.css.test.ts @@ -90,6 +90,28 @@ function extractMobileMediaBlocks(css: string): string { return blocks.join("\n"); } +function extractMediaBlocksForWidth(css: string, width: string): string { + const blocks: string[] = []; + const regex = new RegExp(`@media[^{}]*\\(max-width:\\s*${width}\\)[^{]*\\{`, "g"); + let match: RegExpExecArray | null; + + while ((match = regex.exec(css)) !== null) { + const startIdx = match.index + match[0].length; + let braceCount = 1; + let endIdx = startIdx; + while (braceCount > 0 && endIdx < css.length) { + if (css[endIdx] === "{") braceCount += 1; + if (css[endIdx] === "}") braceCount -= 1; + endIdx += 1; + } + if (braceCount === 0) { + blocks.push(css.slice(startIdx, endIdx - 1)); + } + } + + return blocks.join("\n"); +} + function normalizeCss(css: string): string { return css.replace(/\s+/g, " ").trim(); } @@ -132,7 +154,11 @@ describe("EngineControlMenu CSS token validity (FN-6862)", () => { it("renders the mobile and narrow-tablet footer popover as a viewport-safe bottom panel", () => { expect(componentCss).toContain("@media (max-width: 1024px)"); - expect(componentCss).not.toContain("@media (max-width: 768px)"); + /* + FNXC:EngineControls 2026-07-07-08:20: + FN-7340 added a @media (max-width: 768px) block for range-slider thumb touch-target sizing — a separate concern from popover placement, which stays on the 1024px breakpoint (extractMobileMediaBlocks). The earlier whole-file ban on "@media (max-width: 768px)" was too broad; assert instead that the 768px block never repositions the popover. + */ + expect(extractMediaBlocksForWidth(componentCss, "768px")).not.toContain(".engine-control-menu__popover"); const desktopPopoverBlock = normalizeCss(extractRuleBlock(componentCss, ".engine-control-menu__popover")); const footerDesktopPopoverBlock = normalizeCss( diff --git a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx index 942b64aa7f..9caaf8eeb1 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx @@ -227,6 +227,12 @@ describe("MissionManager mission delete confirmation", () => { const addToast = vi.fn(); renderMissionManager(addToast); + /* + FNXC:MissionManager 2026-07-07-08:25: + FN-7156 made inline Missions open on the overview (no first-mission auto-selection), so a selected-detail delete must first click the mission in the list to load its detail before the fetchMission/delete-modal flow runs. + */ + fireEvent.click(await findMissionListItem("Build Auth System")); + await waitFor(() => { expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId); }); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index 976a4dfae5..1489635d8c 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -127,7 +127,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { vi.unstubAllGlobals(); }); - it("normalizes scrollLeft to 0 on initial mobile render and keeps snap style in CSS, not inline", () => { + it("preserves board column scroll during initial mobile stabilization while keeping snap style in CSS, not inline", () => { const viewportSpy = mockViewport(375); const raf = vi.fn<(cb: FrameRequestCallback) => number>((cb) => { setTimeout(() => cb(0), 0); @@ -145,14 +145,18 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { act(() => { vi.runOnlyPendingTimers(); }); - expect(board.scrollLeft).toBe(0); + /* + FNXC:BoardMobile 2026-07-07-08:30: + FN-7342 (preserve board scroll during refresh stabilization) removed the `boardEl.scrollLeft = 0` reset from mobile stabilization — #board is the user's horizontal scroller, so stabilization now only normalizes document-level horizontal drift and must not force the board back to triage. The board's column scroll position is therefore preserved (500) instead of reset to 0; rAF scheduling and the CSS-not-inline snap invariant still hold. + */ + expect(board.scrollLeft).toBe(500); expect(raf).toHaveBeenCalled(); expect(board.style.scrollSnapType).toBe(""); viewportSpy.mockRestore(); }); - it("re-anchors on pageshow persisted restore for mobile", () => { + it("preserves board column scroll on pageshow persisted restore for mobile", () => { const viewportSpy = mockViewport(375); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { setTimeout(() => cb(0), 0); @@ -176,7 +180,11 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { act(() => { vi.runOnlyPendingTimers(); }); - expect(board.scrollLeft).toBe(0); + /* + FNXC:BoardMobile 2026-07-07-08:32: + FN-7342 keeps pageshow/bfcache stabilization scoped to document-level drift, so the board column scroll set before the restore (500) is preserved rather than re-anchored to 0. + */ + expect(board.scrollLeft).toBe(500); viewportSpy.mockRestore(); }); @@ -468,7 +476,11 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull(); }); - expect(document.querySelector(".board-workflow-toolbar")).toBeNull(); + /* + FNXC:BoardMobile 2026-07-07-08:35: + FN-6825 (combine workflow actions in switcher) moved edit/create into the WorkflowSwitcher, so shouldRenderWorkflowControls is now `workflowOptions.length > 0` rather than gated on onCreateWorkflow/onOpenWorkflowEditor. A Board rendered without those callbacks still shows the switcher toolbar whenever workflow options exist, so the no-callbacks toolbar shell no longer disappears. + */ + expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull(); expect(document.querySelectorAll(".board-workflow-columns [data-testid^='column-']")).toHaveLength(6); viewportSpy.mockRestore(); diff --git a/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts b/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts index 32a6bacbbc..641554dd17 100644 --- a/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts @@ -236,11 +236,15 @@ describe("autoLayout — foreach / unreachable / cycles", () => { "code-review", "review", ]); + /* + FNXC:WorkflowAutoLayout 2026-07-07-08:10: + FN-7265 (align per-step review workflow) removed the standalone `review` prompt node from the stepwise IR — per-step review now happens inside the foreach (step-review), so the post-foreach success path is steps → browser-verification → code-review → completion-summary → merge-gate. The connected editor run for the stepwise built-in therefore ends at `completion-summary`, not `review`. + */ assertAutoLayoutRunConnected("stepwise", workflowDef(BUILTIN_STEPWISE_CODING_WORKFLOW_IR), [ "steps", "browser-verification", "code-review", - "review", + "completion-summary", ]); }); From 443005d9b2ff103dd023a70d66dd63ceeac9e5fb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:37:04 -0700 Subject: [PATCH 03/20] test(engine): repair project-engine mocks (OAuthRefreshScheduler + WS fail-closed getTask) --- .../src/__tests__/project-engine.test.ts | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/__tests__/project-engine.test.ts b/packages/engine/src/__tests__/project-engine.test.ts index 796b295c22..ee29b66071 100644 --- a/packages/engine/src/__tests__/project-engine.test.ts +++ b/packages/engine/src/__tests__/project-engine.test.ts @@ -34,6 +34,9 @@ const mocks = vi.hoisted(() => ({ oauthExpiryMonitorStop: vi.fn(), oauthValidityLoggerStart: vi.fn(async () => undefined), oauthValidityLoggerStop: vi.fn(), + // FNXC:EngineOAuth 2026-07-07-08:25: FN-7574 added OAuthRefreshScheduler (proactive access-token refresh) to the notification module; mirror its start/stop seams here alongside the sibling OAuth monitors. + oauthRefreshSchedulerStart: vi.fn(async () => undefined), + oauthRefreshSchedulerStop: vi.fn(), runtimeConfigurePrMonitoring: vi.fn(), prHandlerCreateFollowUpTask: vi.fn(async () => undefined), })); @@ -161,6 +164,13 @@ vi.mock("../notification/index.js", () => ({ stop: mocks.oauthExpiryMonitorStop, }; }), + // FNXC:EngineOAuth 2026-07-07-08:25: FN-7574 constructs `new OAuthRefreshScheduler({ authStorage })` then awaits `.start()`/`.stop()` in ProjectEngine.start/stop. Export a constructable mock (function impl returning {start,stop}) so `new` works and the canonical-listener wiring tests run instead of failing on a missing mock export. + OAuthRefreshScheduler: vi.fn().mockImplementation(function () { + return { + start: mocks.oauthRefreshSchedulerStart, + stop: mocks.oauthRefreshSchedulerStop, + }; + }), OAuthValidityLogger: vi.fn().mockImplementation(function () { return { start: mocks.oauthValidityLoggerStart, @@ -345,6 +355,8 @@ beforeEach(() => { mocks.oauthExpiryMonitorStop.mockClear(); mocks.oauthValidityLoggerStart.mockClear(); mocks.oauthValidityLoggerStop.mockClear(); + mocks.oauthRefreshSchedulerStart.mockClear(); + mocks.oauthRefreshSchedulerStop.mockClear(); mocks.execFile.mockImplementation(( _file: string, @@ -1547,15 +1559,26 @@ describe("ProjectEngine workspace merge dispatch hardening (Phase C review)", () vi.useFakeTimers(); try { const mockStore = createMockStore({ ...baseSettings, autoMerge: true }); - // First getTask (dispatch routing) returns the workspace task; the catch's getTask - // (after the throw) returns null to simulate a DB outage. - mockStore.store.getTask - .mockResolvedValueOnce(workspaceTask() as any) // dispatch routing read - .mockResolvedValueOnce(workspaceTask() as any) // canMergeTask sweep read (if any) - .mockResolvedValue(null as any); // catch-block read → DB outage + // FNXC:Workspace 2026-07-07-08:30 (FN-7610 regression): + // FN-7610 hoisted an isWorkspaceTask getTask read (mergeCandidate) into the + // dispatch ahead of landWorkspaceTask. A fixed mockResolvedValueOnce(2x)+null + // sequence no longer lands the null on the catch read — an earlier routing read + // consumes it and the workspace task never reaches landWorkspaceTask, so the + // WorkspacePartialLandError catch (and its fail-closed updateTask) never runs. + // Flip a flag inside the landWorkspaceTask mock and return the workspace task from + // getTask until that flag is set, so the null lands deterministically on the + // catch-block read (DB outage) regardless of how many routing reads precede the throw. + let landInvoked = false; + mocks.landWorkspaceTask.mockImplementation(async () => { + landInvoked = true; + throw new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed"); + }); mocks.currentStore = mockStore.store; - mocks.landWorkspaceTask.mockRejectedValue( - new WorkspacePartialLandError(0, ["repo-a"], "Workspace partial land for FN-WSH: 0 landed, 1 failed"), + // getTask is typed to return a workspace task Record, but the real TaskStore + // signature yields Task | null; cast through unknown so the DB-outage null is + // expressible without `any`. + mockStore.store.getTask.mockImplementation(async () => + (landInvoked ? null : workspaceTask()) as unknown as Record, ); const engine = createEngine(); From 4ae71b2a15acb440bd4606d7911527ae1c0c8f34 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:37:04 -0700 Subject: [PATCH 04/20] test(engine): pin FN-4944 already-on-main fast-path noop log in post-finalize test --- ...ost-finalize-verification-noop-status-write.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts index 1608dfa451..1ed5948c1f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-finalize-verification-noop-status-write.test.ts @@ -162,8 +162,16 @@ describe("post-finalize verification noop status-write guard", () => { expect.objectContaining({ source: expect.objectContaining({ sourceType: "recovery" }) }), ); + // FNXC:MergerUnification 2026-07-07-08:35: + // FN-4944's post-finalize guard added an earlier "already-on-main fast-path" + // no-op that fires whenever a done + merge-confirmed task hits a verification + // error, BEFORE the bounce-cap logic. This scenario (done task, VerificationError) + // now resolves through that fast-path, whose log message differs from the older + // cap-reached "already-done task" wording. Pin the fast-path message text here; + // the no-op count (1) and the task:post-finalize-verification-no-op audit are + // unchanged across both paths. const noopLogs = logs.filter((entry) => - entry.includes("[verification] post-finalize VerificationError on already-done task — no action"), + entry.includes("[verification] post-finalize verification failed for already-on-main fast-path; no action"), ); expect(noopLogs).toHaveLength(1); From 76014898e807af90baef0e398b48bb6cdafe7589 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:39:25 -0700 Subject: [PATCH 05/20] test(engine): add shared appendAgentLog timing-tolerant helper; fix merger-merge-details assertions (FN-7503) --- .../src/__tests__/agent-log-assertions.ts | 37 +++++++++++++++++++ .../__tests__/merger-merge-details.test.ts | 8 +++- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 packages/engine/src/__tests__/agent-log-assertions.ts diff --git a/packages/engine/src/__tests__/agent-log-assertions.ts b/packages/engine/src/__tests__/agent-log-assertions.ts new file mode 100644 index 0000000000..4f6319354a --- /dev/null +++ b/packages/engine/src/__tests__/agent-log-assertions.ts @@ -0,0 +1,37 @@ +import { expect, type Mock } from "vitest"; + +/** + * FNXC:AgentLogging 2026-07-07-08:10: + * FN-7503 added optional timing telemetry (`durationMs`/`timeToFirstTokenMs`) as a + * 6th positional argument to `TaskStore.appendAgentLog` for entries that carry + * timing (text/tool_result/tool_error with measured metrics). Calls without timing + * still pass only five args, so the 6th is genuinely optional. + * + * Assertions on agent-log writes must pin the meaningful first five positional args + * (taskId/text/type/detail/agent) and stay tolerant of the optional timing object, + * otherwise every executor/heartbeat/merger test re-breaks whenever a new timing + * field is added. Use this helper instead of `toHaveBeenCalledWith(...)` for those + * five-arg assertions. See `packages/engine/src/agent-logger.ts` flushPendingEntries. + */ +export function expectAppendAgentLog( + mock: Mock, + taskId: string, + text: string, + type: string, + detail: unknown, + agent: string, +): void { + const calls = mock.mock.calls as unknown[][]; + const found = calls.some( + (call) => + call[0] === taskId && + call[1] === text && + call[2] === type && + call[3] === detail && + call[4] === agent, + ); + expect( + found, + `expected appendAgentLog to have been called with (${JSON.stringify(taskId)}, ${JSON.stringify(text)}, ${JSON.stringify(type)}, ${JSON.stringify(detail)}, ${JSON.stringify(agent)}) as its first five args (ignoring any optional timing object); actual calls were:\n${calls.map((c) => JSON.stringify(c)).join("\n")}`, + ).toBe(true); +} diff --git a/packages/engine/src/__tests__/merger-merge-details.test.ts b/packages/engine/src/__tests__/merger-merge-details.test.ts index d241e25d0f..48f8c6703f 100644 --- a/packages/engine/src/__tests__/merger-merge-details.test.ts +++ b/packages/engine/src/__tests__/merger-merge-details.test.ts @@ -153,6 +153,10 @@ import { createFnAgent } from "../pi.js"; import { execSync, exec } from "node:child_process"; import * as core from "@fusion/core"; import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core"; +// FNXC:AgentLogging 2026-07-07-08:40: FN-7503 added optional 6th timing arg to +// appendAgentLog. Use the shared 5-arg-tolerant helper for text-delta assertions +// instead of toHaveBeenCalledWith so the timing object doesn't re-break them. +import { expectAppendAgentLog } from "./agent-log-assertions.js"; const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedExecSync = vi.mocked(execSync); @@ -496,7 +500,7 @@ describe("aiMergeTask — agent log persistence", () => { await aiMergeTask(store, "/tmp/root", "FN-050"); - expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "Hello merge", "text", undefined, "merger"); + expectAppendAgentLog(store.appendAgentLog, "FN-050", "Hello merge", "text", undefined, "merger"); }); it("logs tool invocations to store.appendAgentLog", async () => { @@ -550,7 +554,7 @@ describe("aiMergeTask — agent log persistence", () => { await aiMergeTask(store, "/tmp/root", "FN-050", { onAgentText }); expect(onAgentText).toHaveBeenCalledWith("hi"); - expect(store.appendAgentLog).toHaveBeenCalledWith("FN-050", "hi", "text", undefined, "merger"); + expectAppendAgentLog(store.appendAgentLog, "FN-050", "hi", "text", undefined, "merger"); }); }); From dacbead01267328455cfcc0d2c36ab1cd3e83356 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:40:44 -0700 Subject: [PATCH 06/20] test(engine): stub readCommitTaskOwnership in worktrunk-self-healing to isolate git worktree prune plumbing --- .../worktrunk-self-healing.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/engine/src/__tests__/reliability-interactions/worktrunk-self-healing.test.ts b/packages/engine/src/__tests__/reliability-interactions/worktrunk-self-healing.test.ts index 4d38576417..39d4f5fa92 100644 --- a/packages/engine/src/__tests__/reliability-interactions/worktrunk-self-healing.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/worktrunk-self-healing.test.ts @@ -161,6 +161,25 @@ describe("reliability interactions: worktrunk x self-healing", () => { const store = makeStore({ maintenanceIntervalMs: 0, worktrunk: { enabled: true, onFailure: "fail" } } as Settings); const manager = new SelfHealingManager(store, { rootDir: "/tmp/project" }); + // FNXC:SelfHealingReclaim 2026-07-07-08:45: + // FN-7486 (commit 138d6447f) hardened tip-already-merged reclaim so an + // unverifiable commit tip short-circuits BEFORE native `git worktree prune` + // (previously a null ownership fell through to the prune). Here `exec` is + // mocked, so `promisify(exec)` loses Node's custom promisify symbol and + // resolves to the raw stdout string, which makes `readCommitTaskOwnership` + // throw on its `{ stdout }` destructure — ownership is unverifiable, so the + // reclaim now skips the prune. This test's invariant is the prune PLUMBING + // (branch-level reclaim stays native in worktrunk mode), not ownership + // verification, so attribute the tip to this task and let the reclaim reach + // the native prune (mirrors how inspectBranchConflict is stubbed above). + const reclaimInternals = manager as unknown as { + readCommitTaskOwnership: (sha: string, taskId: string, lineageId?: string) => Promise; + }; + vi.spyOn(reclaimInternals, "readCommitTaskOwnership").mockResolvedValue({ + owned: true, + proof: "task-trailer", + ownerTaskId: "FN-4628", + }); await manager.reclaimSelfOwnedBranchConflicts(); From f2202619e0581614ef9fa1d7c41ddc962037de58 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:42:47 -0700 Subject: [PATCH 07/20] fix(engine): recover workspace landedSha + strip shared branch overrides for sub-repo worktrees (FN-7360) --- packages/engine/src/merger-ai.ts | 22 +++++++++++++++++++-- packages/engine/src/worktree-acquisition.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 306a426d87..fa33c041d3 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -1222,10 +1222,28 @@ export async function landWorkspaceTask( // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { - await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`); + /* + FNXC:Workspace 2026-07-07-08:35 (Phase C A1 recovery — recover landedSha for finalize proof): + isRepoLanded's A1 trailer-fallback can prove a sub-repo is landed even when its landedSha + was never persisted (the persist-after-advance window in persistRepoLandedSha threw). That + left the in-memory result with landedSha: undefined, so finalizeWorkspaceTask's + `status === "landed" && landedSha` filter dropped the recovered repo, `anyLanded` stayed + false, and the proven repo's retry STRANDED the task in-review with missing-merge-confirmation + (finalizeTask's hasDurableMergeProof needs mergeConfirmed). Recover the CURRENT integration + tip as landedSha — the trailer-fallback already proved the task branch is an ancestor of this + tip — so the finalize builds durable mergeConfirmed proof and the A1 retry completes to done. + */ + let recoveredLandedSha = entry.landedSha; + if (!recoveredLandedSha) { + recoveredLandedSha = await git( + ["rev-parse", "--verify", `refs/heads/${integrationBranch}`], + repoRootDir, + ).catch(() => undefined); + } + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(recoveredLandedSha ?? "?")} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, - status: "landed", landedSha: entry.landedSha, alreadyLanded: true, + status: "landed", landedSha: recoveredLandedSha, alreadyLanded: true, }); continue; } diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index e0bda12776..dd4ed98775 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -862,7 +862,15 @@ export async function acquireWorkspaceRepoWorktree( task: { ...task, worktree: undefined, branch: undefined }, rootDir: repoAbsPath, store, - settings, + // FNXC:Workspace 2026-07-07-08:40 (FN-7360 regression — strip shared branch overrides for per-repo start-point): + // FN-7360 pinned fresh task worktree creation to `resolveIntegrationBranch(rootDir, settings)` + // when no executionStartBranch is present, so new branches never inherit an ambient root HEAD. + // For a workspace sub-repo, `settings` carries the SHARED project integrationBranch/baseBranch; + // honoring it resolves a branch absent from this sub-repo and fails `git worktree add` with + // "invalid reference". Strip both overrides here so freshStartPoint falls through to this + // sub-repo's own origin/HEAD — matching the per-repo base-SHA capture below, which already + // resolves against stripped settings (F4/KTD3). + settings: { ...settings, integrationBranch: undefined, baseBranch: undefined }, logger, secretsStore, audit, From 90a6b569c458cf051433358d13b7d335ecc252c9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:42:47 -0700 Subject: [PATCH 08/20] test(engine): wire planner-overseer intervention denial loop to failed-signal snapshot (FN-7577) --- ...lanner-overseer-intervention-wiring.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/__tests__/planner-overseer-intervention-wiring.test.ts b/packages/engine/src/__tests__/planner-overseer-intervention-wiring.test.ts index 364057cc77..357b516cfd 100644 --- a/packages/engine/src/__tests__/planner-overseer-intervention-wiring.test.ts +++ b/packages/engine/src/__tests__/planner-overseer-intervention-wiring.test.ts @@ -262,8 +262,22 @@ describe("FN-7551 — overseer decision points populate the intervention timelin it("exhaustion actually reached through real tick()s (three denials) emits escalate exactly once thereafter", async () => { const task = await seedTask("in-review"); - const { monitor, controllerFromMonitor: controller, emitEscalation } = wireRealEngineOverseer(store); - await monitor.observeTask(task, "autonomous"); + // FNXC:PlannerOversight 2026-07-07-08:50: + // FN-7577 (2026-07-05) made PlannerRecoveryController.tick() drop the + // bounded-recovery attempt budget whenever a watched stage reports a + // HEALTHY/human-wait signal (progressing/complete/awaiting-human). A plain + // in-review task derives a "progressing" merger signal, so denials never + // accumulate through the real monitor wiring and exhaustion can't be + // reached — the 4th tick kept returning await_confirmation instead of the + // exhausted "none". This test's invariant is escalation DEDUP after + // exhaustion reached via real tick()s, so wire the controller to a PROBLEM + // (failed) merger snapshot (controllerWithSnapshot — the documented seam for + // branches the monitor's own signal-derivation cannot produce) whose signal + // holds the attempt budget, then drive three real denials to reach genuine + // exhaustion. The merger stage still surfaces await_confirmation regardless + // of signal (decidePlannerRecovery), so requiresConfirmation stays asserted. + const { controllerWithSnapshot, emitEscalation } = wireRealEngineOverseer(store); + const controller = controllerWithSnapshot(observation({ taskId: task.id, stage: "merger", signal: "failed" })); for (let i = 0; i < 3; i += 1) { const decision = await controller.tick(task); From 82e06e37c805f70b8f3a6b7d2a6fcd7665faa7f7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:43:58 -0700 Subject: [PATCH 09/20] test(engine): fix executor step-session/liveness-gate/checkout/ce-workflow mocks (FN-7229 retry-cap + workflow verdict wiring) --- .../ce-workflow-step-executor.test.ts | 9 ++++- .../__tests__/executor-step-session.test.ts | 37 +++++++++---------- ...nvariant-wrong-checkout-completion.test.ts | 7 +++- .../executor-liveness-gate.test.ts | 6 ++- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts index 7c2d012ef8..07cef58f7d 100644 --- a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts +++ b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts @@ -462,9 +462,16 @@ describe("CE workflow-step executor integration", () => { value: "implementation-incomplete", })); expect(mergeRequester).not.toHaveBeenCalled(); + // FNXC:WorkflowMerge 2026-07-07-08:38: The merge boundary (executor.ts:6305, 6fc50d8d9e) now moves the task to in-review and logs the boundary move BEFORE the implementation-proof gate runs, then the proof failure is logged separately. The proof-failure text (executor.ts:6345) changed from the static "implementation steps are incomplete" to the parse-step-aware "implementation did not run: parsed coding steps are missing or incomplete". Assert both log entries so the new two-stage merge-boundary behavior is pinned. expect(store.logEntry).toHaveBeenCalledWith( "FN-CE-1", - "Workflow merge blocked before requester: implementation steps are incomplete", + "Workflow merge boundary moved task to in-review before requesting merge", + undefined, + undefined, + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-CE-1", + "Workflow merge blocked before requester: implementation did not run: parsed coding steps are missing or incomplete", undefined, undefined, ); diff --git a/packages/engine/src/__tests__/executor-step-session.test.ts b/packages/engine/src/__tests__/executor-step-session.test.ts index 4b9241bfdf..45fc39ccf5 100644 --- a/packages/engine/src/__tests__/executor-step-session.test.ts +++ b/packages/engine/src/__tests__/executor-step-session.test.ts @@ -187,7 +187,7 @@ describe("Workflow Steps Execution", () => { expect(onComplete).not.toHaveBeenCalled(); }); - it("moves task to in-review once fn_task_done requeue budget is exhausted", async () => { + it("marks task failed in-place once fn_task_done requeue budget is exhausted (FN-7229)", async () => { const store = createMockStore(); store.getTask.mockResolvedValue({ id: "FN-001", @@ -237,7 +237,8 @@ describe("Workflow Steps Execution", () => { status: "failed", error: "Agent finished without calling fn_task_done (after 3 retries)", }); - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review"); + // FNXC:ExecutorMoveTask 2026-07-07-08:38: FN-7229 (984e36255d) stopped parking execution errors in review — an exhausted fn_task_done budget now marks the task failed in-place (executor.ts:11179) instead of moveTask→in-review. `in-review` is reserved for clean completion handoffs, so the task must NOT be moved there. (Line 236 already asserts status=failed.) + expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "todo"); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ id: "FN-001" }), @@ -557,18 +558,20 @@ describe("Workflow Steps Execution", () => { // non-deterministic; calling performWorkflowRerunBounce directly is // exactly what the timer would have done after the next event-loop // tick and removes the timing dependency entirely. + // Cast once to a named handle: these are private executor methods the + // compiler cannot see; assign to a typed const rather than inlining the + // cast into each member access. + const executorInternals = executor as unknown as { + scheduleWorkflowRerun: (taskId: string, worktreePath: string, successMessage: string) => void; + performWorkflowRerunBounce: (taskId: string, worktreePath: string) => Promise; + }; + let bouncePromise: Promise | undefined; const scheduleSpy = vi - .spyOn(executor as unknown as { - scheduleWorkflowRerun: ( - taskId: string, - worktreePath: string, - successMessage: string, - ) => void; - }, "scheduleWorkflowRerun") + .spyOn(executorInternals, "scheduleWorkflowRerun") .mockImplementation((taskId, worktreePath) => { - void (executor as unknown as { - performWorkflowRerunBounce: (taskId: string, worktreePath: string) => Promise; - }).performWorkflowRerunBounce(taskId, worktreePath); + // Capture the bounce promise so the test can await it to completion + // (see FNXC below) instead of flushing a fixed microtask count. + bouncePromise = executorInternals.performWorkflowRerunBounce(taskId, worktreePath); }); const stepName = "Frontend UX Design"; @@ -604,15 +607,11 @@ describe("Workflow Steps Execution", () => { .map((call: any[]) => call[1]); expect(reopenedStepIndexes).toEqual([0, 1]); - // performWorkflowRerunBounce was invoked synchronously by the spy - // above; flush microtasks so its awaited store calls settle before - // we assert. - await new Promise((resolve) => queueMicrotask(resolve)); - await new Promise((resolve) => queueMicrotask(resolve)); - await new Promise((resolve) => queueMicrotask(resolve)); + // FNXC:ExecutorMoveTask 2026-07-07-08:38: Await the captured rerun-bounce promise instead of flushing a fixed number of microtasks. 3167dbc83 inserted clearTerminalStepFailuresForRetry (an extra awaited hop) between the todo and in-progress moves inside performWorkflowRerunBounce, so a fixed microtask count no longer deterministically drains the bounce to the final in-progress moveTask. Awaiting the promise is exact and survives future awaited hops; the bounce still performs the todo→in-progress hop (executor.ts:3650 then 3674). + await bouncePromise; // (2) bounce uses preserveResumeState so step progress + worktree survive - expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveResumeState: true, preserveWorktree: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", expect.objectContaining({ preserveResumeState: true, preserveWorktree: true })); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress"); expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review"); expect(onError).not.toHaveBeenCalled(); diff --git a/packages/engine/src/__tests__/invariant-wrong-checkout-completion.test.ts b/packages/engine/src/__tests__/invariant-wrong-checkout-completion.test.ts index 80545edd8c..7b4d48027d 100644 --- a/packages/engine/src/__tests__/invariant-wrong-checkout-completion.test.ts +++ b/packages/engine/src/__tests__/invariant-wrong-checkout-completion.test.ts @@ -114,7 +114,12 @@ describe("FN-4115 wrong-checkout completion rejection", () => { const result = await tool.execute("id", {}); expect(result.content[0].text).toContain("Task marked complete"); expect(store.updateStep).toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalledWith("FN-4115", "todo", { preserveProgress: true }); + // FNXC:ExecutorMoveTask 2026-07-07-08:38: A valid fn_task_done completion is distinguished from a wrong-checkout REFUSAL by its success log, not by the absence of a todo moveTask. setup() runs execute() with a mocked agent that never calls fn_task_done, so the FN-4806 silent worktree-reclaim path (executor.ts:11149, 3f8a5e6839) legitimately requeues to todo with { preserveProgress: true } — the same signature the refusal path (handleImplicitTaskDoneRefusal, executor.ts:13030) emits — so a moveTask-shape assertion cannot tell a valid completion from a refusal. Pin the positive success marker instead: a valid completion logs "Task marked done by agent" (executor.ts:13306), which the refusal test at line 82 proves a wrong-checkout rejection never emits. (Filter on id+message so the runContext arg / arity don't make this brittle.) + expect( + store.logEntry.mock.calls.some( + ([id, msg]) => id === "FN-4115" && typeof msg === "string" && msg === "Task marked done by agent", + ), + ).toBe(true); }); it("FN-4115: pre-session liveness rejects missing worktree before createFnAgent", async () => { diff --git a/packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts b/packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts index 9d7cffc33c..ff54c535f7 100644 --- a/packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts @@ -163,7 +163,7 @@ describe("reliability interactions: FN-4935 executor liveness gate", () => { ); }); - it("parks in-review at retry cap", async () => { + it("fails in-place at retry cap (FN-7229)", async () => { vi.spyOn(worktreeAcquisition, "acquireTaskWorktree").mockResolvedValue({ worktreePath: "/repo/.worktrees/new-path", branch: "fusion/fn-4935-t", @@ -180,7 +180,9 @@ describe("reliability interactions: FN-4935 executor liveness gate", () => { const executor = new TaskExecutor(store as any, "/repo"); await executor.execute(makeTask({ taskDoneRetryCount: 999, sessionFile: null })); - expect(store.moveTask).toHaveBeenCalledWith("FN-4935-T", "in-review"); + // FNXC:ExecutorMoveTask 2026-07-07-08:38: FN-7229 (984e36255d) stopped parking worktree-liveness failures in review — at the retry cap the task is now marked failed in-place via updateTask(status=failed) (executor.ts:9608) instead of moveTask→in-review. `in-review` is reserved for clean completion handoffs, so assert the task is NOT moved there and IS marked failed. The worktree:incomplete-detected audit event below still carries the forensic `terminalAction: "park-in-review"` label (executor.ts:9554), which records what the gate detected, not the (changed) terminal action. + expect(store.moveTask).not.toHaveBeenCalledWith("FN-4935-T", "in-review"); + expect(store.updateTask).toHaveBeenCalledWith("FN-4935-T", expect.objectContaining({ status: "failed", error: expect.any(String) })); expect(events.some((event) => (event.type === "worktree:incomplete-detected" || event.mutationType === "worktree:incomplete-detected") && event.metadata?.terminalAction === "park-in-review")).toBe(true); }); From a017b53e85ba482c5f7c257bc05e312965e7bf58 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:49:52 -0700 Subject: [PATCH 10/20] test(dashboard): align app tests with FN-7352/7261/7234 + add fetchWorkflowOptionalSteps mock --- .../app/components/__tests__/AppModals.test.tsx | 6 +++++- .../__tests__/ChangesDiffModal.test.tsx | 9 ++++++++- .../TaskDetailModal.summary-tab.test.tsx | 6 +++++- .../__tests__/board-no-legacy-flash.test.tsx | 17 ++++++++++++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index ddca365b2a..fc3609a920 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -590,7 +590,11 @@ describe("AppModals", () => { fireEvent.click(screen.getByTestId("task-detail-open-detail")); expect(pushStateSpy).toHaveBeenCalledTimes(1); - expect(mockModalManager.openDetailTask).toHaveBeenCalledWith({ id: "FN-2", title: "Nested" }, undefined); + /* + FNXC:TaskDetailNav 2026-07-07-09:15: + FN-7352 (route completed-task refine menus to detail) added a third `opts?: { origin?: DetailTaskOrigin }` argument to openDetailTask / openDetailTaskWithNav, so the modalManager call now carries three args (task, tab, opts). A task-to-task open with no explicit tab/origin passes (task, undefined, undefined). + */ + expect(mockModalManager.openDetailTask).toHaveBeenCalledWith({ id: "FN-2", title: "Nested" }, undefined, undefined); }); }); }); diff --git a/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx b/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx index 6f004595eb..a664b5a46e 100644 --- a/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChangesDiffModal.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { loadAllAppCss } from "../../test/cssFixture"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { ChangesDiffModal, type NormalizedFile } from "../ChangesDiffModal"; +import { ModalDismissPreferenceProvider } from "../../hooks/useOverlayDismiss"; import type { MergeDetails } from "@fusion/core"; vi.mock("lucide-react", () => ({ @@ -381,8 +382,14 @@ describe("ChangesDiffModal", () => { it("calls onClose when clicking the modal overlay", () => { const onClose = vi.fn(); + /* + FNXC:ChangesDiffModal 2026-07-07-09:20: + FN-7261 (global modal dismissal setting) made backdrop dismissal default-off: useOverlayDismiss only closes when ModalDismissPreferenceProvider enables it. Wrap the render in the provider so the overlay-click dismiss path is exercised (matches AgentErrorDetailsModal.test.tsx). + */ const { container } = render( - , + + + , ); const overlay = container.querySelector(".modal-overlay"); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx index df77346e0d..8e059b84f4 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx @@ -92,7 +92,11 @@ describe("TaskDetailModal Summary tab", () => { expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Activity"); const summaryButton = screen.getByRole("button", { name: "Summary" }); expectButtonActive(summaryButton); - expect(screen.queryByRole("button", { name: "Chat" })).toBeNull(); + /* + FNXC:TaskDetailTabs 2026-07-07-09:25: + The planner-chat ("Chat") tab now renders unconditionally in the task-detail tab strip (both taskDetailChatFirst branches), so done tasks expose Activity, Chat, Summary, ... (see TaskDetailModal.definition-actions.test.tsx). Done tasks still land on Summary by default; Chat is present but not active. + */ + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); expect(screen.getByText("Completion summary")).toBeTruthy(); expect(screen.getByText("summary")).toBeTruthy(); expect(screen.getByText("What changed")).toBeTruthy(); diff --git a/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx b/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx index 814684c633..7dbfefd039 100644 --- a/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-no-legacy-flash.test.tsx @@ -10,6 +10,11 @@ import type { Task } from "@fusion/core"; const apiMocks = vi.hoisted(() => ({ fetchBoardWorkflows: vi.fn(), fetchWorkflowSteps: vi.fn(), + /* + FNXC:BoardNoLegacyFlash 2026-07-07-09:05: + ListView renders QuickEntryBox, whose quick-create path calls fetchWorkflowOptionalSteps (added FN-6304). The partial api mock must expose it or vitest throws "No fetchWorkflowOptionalSteps export" during render and the skeleton/legacy assertions never settle. + */ + fetchWorkflowOptionalSteps: vi.fn(), fetchNodes: vi.fn(), fetchTaskDetail: vi.fn(), batchUpdateTaskModels: vi.fn(), @@ -23,7 +28,7 @@ const apiMocks = vi.hoisted(() => ({ vi.mock("../../api", () => ({ fetchBoardWorkflows: apiMocks.fetchBoardWorkflows, fetchWorkflowSteps: apiMocks.fetchWorkflowSteps, - fetchNodes: apiMocks.fetchNodes, + fetchWorkflowOptionalSteps: apiMocks.fetchWorkflowOptionalSteps, fetchTaskDetail: apiMocks.fetchTaskDetail, batchUpdateTaskModels: apiMocks.batchUpdateTaskModels, promoteTask: apiMocks.promoteTask, @@ -187,6 +192,7 @@ describe("no legacy-board flash before workflow lanes load (FN-6776)", () => { beforeEach(() => { vi.clearAllMocks(); apiMocks.fetchWorkflowSteps.mockResolvedValue([]); + apiMocks.fetchWorkflowOptionalSteps.mockResolvedValue([]); apiMocks.fetchNodes.mockResolvedValue([]); apiMocks.fetchTaskDetail.mockResolvedValue(null); apiMocks.promoteTask.mockResolvedValue({}); @@ -276,14 +282,19 @@ describe("no legacy-board flash before workflow lanes load (FN-6776)", () => { expectSkeleton(surface); }); - it.each(["Board", "ListView"])("%s fetch error exits the skeleton to a terminal legacy layout", async (surface) => { + it.each(["Board", "ListView"])("%s keeps the skeleton on a failed first fetch instead of flashing legacy (non-authoritative failure)", async (surface) => { mockViewport(1024); apiMocks.fetchBoardWorkflows.mockRejectedValue(new Error("network")); renderSurface(surface); expectSkeleton(surface); - await waitFor(() => expectLegacyLayout(surface)); + /* + FNXC:BoardNoLegacyFlash 2026-07-07-09:30: + FN-7234 (preserve board workflow selections) made fetch failures non-authoritative: useBoardWorkflows keeps the current/cache-hydrated payload on a rejected fetch (empty .catch) rather than falling back to legacy. With no cached payload, boardWorkflows stays null so the skeleton persists — the board never flashes legacy on a failed first fetch. Recovery happens on the next visibility/focus/switcher-open re-fetch, not by dropping to legacy. + */ + await waitFor(() => expect(apiMocks.fetchBoardWorkflows).toHaveBeenCalled()); + expectSkeleton(surface); }); it.each(["Board", "ListView"])("%s does not leak cached workflow layouts across project switches", async (surface) => { From 58d085efab90d74b7bf2ac28068d7dc1b2d707b6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:54:49 -0700 Subject: [PATCH 11/20] test(engine): spread real reviewer exports in executor-test-helpers mock (3167dbc83); align stepwise/graph/prompt-override tests (FN-7265/7335) --- .../src/__tests__/executor-test-helpers.ts | 12 +++++++++--- .../stepwise-workflow-parity.test.ts | 19 +++++++++++++------ .../workflow-graph-optional-step-fix.test.ts | 10 +++++++++- ...rkflow-prompt-overrides-resolution.test.ts | 8 +++++--- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 0e1a4a7bfe..0dcdc399f8 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -1,6 +1,7 @@ import { vi } from "vitest"; import type { Mock } from "vitest"; import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js"; +import type * as ReviewerModule from "../reviewer.js"; // Mock external dependencies vi.mock("../pi.js", () => ({ @@ -24,9 +25,14 @@ vi.mock("../pi.js", () => ({ } }), })); -vi.mock("../reviewer.js", () => ({ - reviewStep: vi.fn(), -})); +/* + * FNXC:WorkflowReviewers 2026-07-07-08:40: + * Commit 3167dbc83 wired `proseSignalsClearApproval` + `extractJsonObjectCandidates` from reviewer.js into the workflow-step verdict parser (parseWorkflowStepVerdict). A mock that returns only `reviewStep` makes every executeWorkflowStep verdict parse throw `[vitest] No "extractJsonObjectCandidates" export`. Surface the real exports via importOriginal and stub only `reviewStep` (the agent-invoking seam these tests avoid); the verdict-parsing helpers then run for real. + */ +vi.mock("../reviewer.js", async (importOriginal) => { + const actual = (await importOriginal()) as ReviewerModule; + return { ...actual, reviewStep: vi.fn() }; +}); vi.mock("../logger.js", () => { const createMockLogger = () => ({ log: vi.fn(), diff --git a/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts b/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts index 5dd23ea289..0594f7f3ed 100644 --- a/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts +++ b/packages/engine/src/__tests__/stepwise-workflow-parity.test.ts @@ -425,6 +425,8 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { readArtifact: async () => "### Step 1: do it\n", writeSteps: async () => {}, }, + // FNXC:WorkflowGraphCutover 2026-07-07-09:05: stepwise-coding gained a default-on plan-review optional-group (and always-on completion-summary) before the foreach; wire the custom-node runner (success) so those auxiliary nodes pass through and the foreach/step invariant under test is actually reached (mirrors production executor.ts runCustomNode + runStepwiseGraph). + runCustomNode: async () => ({ outcome: "success" }), }); const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); @@ -464,6 +466,7 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { readArtifact: async () => "### Step 1: a\n### Step 2: b\n### Step 3: c\n", writeSteps: async () => {}, }, + runCustomNode: async () => ({ outcome: "success" }), }); const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); @@ -501,6 +504,7 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { readArtifact: async () => "### Step 1: a\n### Step 2: b\n", writeSteps: async () => {}, }, + runCustomNode: async () => ({ outcome: "success" }), }); const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); @@ -581,6 +585,7 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { readArtifact: async () => "no steps here, just prose", writeSteps: async () => {}, }, + runCustomNode: async () => ({ outcome: "success" }), }); const result = await executor.run(task, settingsOn(), BUILTIN_STEPWISE_CODING_WORKFLOW_IR); @@ -620,19 +625,21 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { expect(browserVerificationCalls).toBe(1); expect(result.visitedNodeIds).toContain("browser-verification"); expect(result.visitedNodeIds).toContain(BROWSER_VERIFICATION_STEP_VISITED_ID); - // Ordering: all step instances complete before the group's inner step, which - // precedes review. + // FNXC:WorkflowGraphCutover 2026-07-07-09:10: + // FN-7265 removed the post-foreach `review` node; the pre-merge gate is now the default-on `code-review` optional-group (browser-verification → code-review → completion-summary → merge-gate). The R-3 run-once ordering invariant is therefore: all step instances finish before the browser-verification inner step, which precedes the code-review gate. const groupStepIdx = result.visitedNodeIds.indexOf(BROWSER_VERIFICATION_STEP_VISITED_ID); - const reviewIdx = result.visitedNodeIds.indexOf("review"); + const codeReviewIdx = result.visitedNodeIds.indexOf("code-review"); const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true); expect(lastStepIdx).toBeLessThan(groupStepIdx); - expect(groupStepIdx).toBeLessThan(reviewIdx); + expect(groupStepIdx).toBeLessThan(codeReviewIdx); }); it("bypasses the browser-verification optional-group (inert) when it is not enabled", async () => { // Disabled (no enabledWorkflowSteps): the group node is traversed but its // template body never runs — the inner prompt node is not visited and the - // custom-node runner is never invoked for it. Routes straight to review. + // custom-node runner is never invoked for it. Routes straight to the + // code-review gate. + // FNXC:WorkflowGraphCutover 2026-07-07-09:10: FN-7265 removed the `review` node; the post-foreach gate this inert path reaches is now `code-review`. let browserVerificationCalls = 0; const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]], { runCustomNode: async (nodeId) => { @@ -644,6 +651,6 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => { expect(browserVerificationCalls).toBe(0); expect(result.visitedNodeIds).toContain("browser-verification"); expect(result.visitedNodeIds).not.toContain(BROWSER_VERIFICATION_STEP_VISITED_ID); - expect(result.visitedNodeIds).toContain("review"); + expect(result.visitedNodeIds).toContain("code-review"); }); }); diff --git a/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts b/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts index 457db672e3..4db53bfbea 100644 --- a/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-optional-step-fix.test.ts @@ -313,7 +313,15 @@ describe("TaskExecutor pre-merge optional-step fix seam", () => { await (executor as any).clearStalePauseAbortBeforeDispatch(liveTask); expect((executor as any).pausedAborted.has("FN-7066")).toBe(false); - expect(store.logEntry).not.toHaveBeenCalled(); + /* + * FNXC:WorkflowLifecycle 2026-07-07-08:35: + * FN-7335 wired a best-effort "Pause abort marked: provenance=… source=…" breadcrumb into markPausedAborted() itself (via safeLogEntry), so the setup markPausedAborted() call above now produces one store.logEntry. clearStalePauseAbortBeforeDispatch() must still clear SILENTLY: it logs via executorLog only and must NOT emit its own store.logEntry (the marker is volatile engine state, not a task event). Assert no "cleared stale pause-abort marker" log reached the store. + */ + expect( + store.logEntry.mock.calls.some(([, message]: [string, string]) => + /cleared stale pause-abort marker/i.test(message), + ), + ).toBe(false); }); it("clears pause-abort provenance for manual retry", () => { diff --git a/packages/engine/src/__tests__/workflow-prompt-overrides-resolution.test.ts b/packages/engine/src/__tests__/workflow-prompt-overrides-resolution.test.ts index 503431f2ca..7cc46b9442 100644 --- a/packages/engine/src/__tests__/workflow-prompt-overrides-resolution.test.ts +++ b/packages/engine/src/__tests__/workflow-prompt-overrides-resolution.test.ts @@ -38,11 +38,13 @@ describe("workflow prompt override resolution", () => { const projectId = store.getWorkflowSettingsProjectId(); const defaultExecutePrompt = resolveSeamPromptFromIr(BUILTIN_CODING_WORKFLOW_IR, "execute"); const beforeStaticIr = JSON.stringify(BUILTIN_CODING_WORKFLOW_IR); - const task = await store.createTask({ description: "uses prompt override", workflowId: "builtin:coding" }); + const task = await store.createTask({ description: "uses prompt override", workflowId: "builtin:legacy-coding" }); // FNXC:CustomWorkflows 2026-06-21-21:04: // Engine seam resolution must consume the same built-in prompt override overlay as dashboard preview and sync store resolution, while reset-to-default must reveal the shipped static prompt again. - store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: "Engine execute override" }); + // FNXC:CustomWorkflows 2026-07-07-08:45: + // builtin:coding became the stepwise final-review workflow (commit 6ce0b4405 "make coding stepwise with final review") and no longer carries a top-level `execute` seam prompt node — per-step work runs inside the `steps` foreach, so resolveSeamPromptFromIr(..., "execute") returns undefined there. The execute-seam override/resolution invariant is therefore pinned against builtin:legacy-coding (= BUILTIN_CODING_WORKFLOW_IR), the monolithic workflow that still owns the execute seam node (id "execute", seam "execute"). The override keys by node id and resolves by seam; legacy-coding is the surface where both still coincide. + store.updateWorkflowPromptOverrides("builtin:legacy-coding", projectId, { execute: "Engine execute override" }); expect(await resolveTaskSeamPrompt(store, task.id, "execute")).toBe("Engine execute override"); const syncIr = (store as StoreWithSyncWorkflowResolution).resolveTaskWorkflowIrSync(task.id); @@ -50,7 +52,7 @@ describe("workflow prompt override resolution", () => { expect(syncIr).not.toBe(BUILTIN_CODING_WORKFLOW_IR); expect(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)).toBe(beforeStaticIr); - store.updateWorkflowPromptOverrides("builtin:coding", projectId, { execute: null }); + store.updateWorkflowPromptOverrides("builtin:legacy-coding", projectId, { execute: null }); expect(await resolveTaskSeamPrompt(store, task.id, "execute")).toBe(defaultExecutePrompt); expect(resolveSeamPromptFromIr((store as StoreWithSyncWorkflowResolution).resolveTaskWorkflowIrSync(task.id), "execute")).toBe( From 38406c344246517d5f131052092e3625cf4accf1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:55:27 -0700 Subject: [PATCH 12/20] test(engine): use shared appendAgentLog timing-tolerant helper in heartbeat/triage tests (FN-7503) --- .../src/__tests__/heartbeat-executor.test.ts | 10 ++++++---- .../__tests__/heartbeat-session-prompt.test.ts | 2 +- .../triage-soft-delete-write-abort.test.ts | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 903a244e23..b5653a1b9a 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -12,6 +12,7 @@ import { getAgentSoulWords, } from "../agent-heartbeat.js"; import { AgentLogger } from "../agent-logger.js"; +import { expectAppendAgentLog } from "./agent-log-assertions.js"; import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message } from "@fusion/core"; import { createMessage, createBudgetStatus } from "./heartbeat-test-helpers.js"; vi.mock("../logger.js", async () => { @@ -3551,9 +3552,10 @@ describe("executeHeartbeat", () => { const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "Heartbeat produced visible output", "text", undefined, "executor"); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", undefined, "executor"); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", undefined, "executor"); + // FN-7503 added an optional 6th timing arg; pin the first five and tolerate timing. + expectAppendAgentLog(appendAgentLog, "FN-001", "Heartbeat produced visible output", "text", undefined, "executor"); + expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool", undefined, "executor"); + expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool_result", undefined, "executor"); expect(result.contextSnapshot?.taskId).toBe("FN-001"); expect(result.stdoutExcerpt).toContain("Heartbeat produced visible output"); }); @@ -3626,10 +3628,10 @@ describe("executeHeartbeat", () => { await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); + // FN-7536+: createTask input no longer carries `column` (defaulted server-side to triage) and now forwards `githubTracking`; objectContaining tolerates the extra key. expect(mockTaskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ description: "Follow-up task", dependencies: undefined, - column: "triage", priority: undefined, summarize: true, source: expect.objectContaining({ diff --git a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts index ab45d7987a..63009d0456 100644 --- a/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts +++ b/packages/engine/src/__tests__/heartbeat-session-prompt.test.ts @@ -294,10 +294,10 @@ describe("createHeartbeatTools", () => { const result = await createTool.execute("call-1", { description: "Follow-up task" }, undefined as any, undefined as any, undefined as any); + // FN-7536+: createTask input no longer carries `column` (defaulted server-side to triage) and now forwards `githubTracking`; objectContaining tolerates the extra key. expect(mockTaskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({ description: "Follow-up task", dependencies: undefined, - column: "triage", priority: undefined, summarize: true, source: expect.objectContaining({ diff --git a/packages/engine/src/__tests__/triage-soft-delete-write-abort.test.ts b/packages/engine/src/__tests__/triage-soft-delete-write-abort.test.ts index 4c8eace8a5..ce8ff347dd 100644 --- a/packages/engine/src/__tests__/triage-soft-delete-write-abort.test.ts +++ b/packages/engine/src/__tests__/triage-soft-delete-write-abort.test.ts @@ -18,10 +18,19 @@ vi.mock("../agent-session-helpers.js", () => ({ resolvePlanningSessionModel: vi.fn().mockReturnValue({ provider: "mock", modelId: "mock-model" }), })); -vi.mock("../pi.js", () => ({ - describeModel: mockDescribeModel, - promptWithFallback: mockPromptWithFallback, -})); +vi.mock("../pi.js", () => { + /* + FNXC:EngineTests 2026-07-07-08:05: + triage.ts specifyTask now (FN-7559) checks `err instanceof ModelFallbackExhaustedError` in its catch and (earlier, agentWork) calls `formatModelMarkerDetails`. The pi mock must expose both so the instanceof guard is callable and agentWork's model-marker formatting resolves, instead of crashing happy-path specifyTask runs. + */ + class ModelFallbackExhaustedError extends Error {} + return { + ModelFallbackExhaustedError, + describeModel: mockDescribeModel, + formatModelMarkerDetails: vi.fn((model: string) => model), + promptWithFallback: mockPromptWithFallback, + }; +}); vi.mock("../reviewer.js", () => ({ reviewStep: vi.fn(), From b6a8f6430f686c0907b22306bef05e071796d3db Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:02:28 -0700 Subject: [PATCH 13/20] test(engine): simulate fallback split-close in single promptWithFallback call (specifyTask no longer loops) --- .../triage-split-into-subtasks-delete.test.ts | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 70612d3742..739070b860 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 @@ -8,11 +8,20 @@ import { TriageProcessor } from "../triage.js"; const { mockCreateFnAgent } = vi.hoisted(() => ({ mockCreateFnAgent: vi.fn() })); -vi.mock("../pi.js", () => ({ - createFnAgent: mockCreateFnAgent, - describeModel: vi.fn().mockReturnValue("mock-model"), - promptWithFallback: vi.fn(), -})); +vi.mock("../pi.js", () => { + /* + FNXC:EngineTests 2026-07-07-08:05: + triage.ts specifyTask now (FN-7559) checks `err instanceof ModelFallbackExhaustedError` in its catch and (earlier, agentWork) calls `formatModelMarkerDetails`. The pi mock must expose both so the instanceof guard is callable and agentWork's model-marker formatting resolves, instead of crashing happy-path specifyTask runs. + */ + class ModelFallbackExhaustedError extends Error {} + return { + ModelFallbackExhaustedError, + createFnAgent: mockCreateFnAgent, + describeModel: vi.fn().mockReturnValue("mock-model"), + formatModelMarkerDetails: vi.fn((model: string) => model), + promptWithFallback: vi.fn(), + }; +}); vi.mock("@fusion/core", async (importOriginal) => { const { createEngineCoreMock } = await import("../test/mockCore.js"); @@ -118,13 +127,13 @@ describe("triage split/delete lineage forwarding", () => { const captured = { current: [] as any[] }; mockSessionFactory(captured); - let promptCallCount = 0; + /* + FNXC:TriageSplitLineage 2026-07-07-09:05: + specifyTask now invokes promptWithFallback exactly once — the multi-call retry loop that this test simulated (creating children on the 4th call) no longer exists, so the fallback never fired and deleteTask(removeLineageReferences) was never reached. Simulate the split-close inside that single promptWithFallback call instead. The removeLineageReferences lineage-forwarding invariant (FN-5129/FN-5131) is what this test guards, regardless of how many prompt calls precede it. + */ const { promptWithFallback } = await import("../pi.js"); (promptWithFallback as ReturnType).mockImplementation(async () => { - promptCallCount += 1; - if (promptCallCount === 4) { - await createChildrenFromTool(captured.current); - } + await createChildrenFromTool(captured.current); }); const processor = new TriageProcessor(store, "/test/root", { pollIntervalMs: 100_000 }); From f93ce5268962cbc43c73519fa45379c340479ef7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:37:18 -0700 Subject: [PATCH 14/20] test(engine): fix ModelFallbackExhaustedError pi mock + FN-7360 worktree exec counts + sync conflict mapping --- ...iage-planning-prompt-single-source.test.ts | 19 ++++++++++++----- .../worktree-acquisition-backend.test.ts | 20 ++++++++++++------ .../worktree-acquisition-worktrunk.test.ts | 21 ++++++++++++------- .../src/__tests__/worktree-backend.test.ts | 5 ++++- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts index 1be76c1fd1..ee4a8a8920 100644 --- a/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts +++ b/packages/engine/src/__tests__/triage-planning-prompt-single-source.test.ts @@ -18,11 +18,20 @@ vi.mock("../reviewer.js", () => ({ reviewStep: mockReviewStep, })); -vi.mock("../pi.js", () => ({ - createFnAgent: mockCreateFnAgent, - describeModel: vi.fn().mockReturnValue("mock-model"), - promptWithFallback: vi.fn().mockResolvedValue(undefined), -})); +vi.mock("../pi.js", () => { + /* + FNXC:EngineTests 2026-07-07-09:10: + triage.ts specifyTask catch checks `err instanceof ModelFallbackExhaustedError` (FN-7559 planner fallback exhaustion handling) and agentWork calls `formatModelMarkerDetails`. The pi mock must expose both so the instanceof guard is callable and model-marker formatting resolves, instead of crashing happy-path specifyTask runs. + */ + class ModelFallbackExhaustedError extends Error {} + return { + ModelFallbackExhaustedError, + createFnAgent: mockCreateFnAgent, + describeModel: vi.fn().mockReturnValue("mock-model"), + formatModelMarkerDetails: vi.fn((model: string) => model), + promptWithFallback: vi.fn().mockResolvedValue(undefined), + }; +}); vi.mock("@fusion/core", async (importOriginal) => { const { createEngineCoreMock } = await import("../test/mockCore.js"); diff --git a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts index 8f326e270d..573ac6df15 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts @@ -134,14 +134,18 @@ describe("acquireTaskWorktree backend wiring", () => { ).rejects.toMatchObject({ name: "WorktrunkOperationError", code: "worktrunk_binary_missing" }); /* - * FNXC:WorktreeIsolation 2026-07-02-07:40: - * The integration-branch resolution (`git symbolic-ref`) runs before the worktrunk binary check, so one exec call is expected. No worktrunk `switch` command should be attempted when the binary is missing. + * FNXC:WorktreeIsolation 2026-07-02-07:40 (updated 2026-07-07-09:15 for FN-7438): + * The integration-branch resolution runs before the worktrunk binary check. With an empty symbolic-ref result, FN-7438 (aa8f1f32e) adds a `git remote` discovery call before the "main" fallback, so two exec calls happen. No worktrunk `switch` command should be attempted when the binary is missing. */ - expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", expect.objectContaining({ cwd: "/repo" }), ); + expect(execMock).toHaveBeenCalledWith( + "git remote", + expect.objectContaining({ cwd: "/repo" }), + ); expect(execMock.mock.calls.some((call) => String(call[0]).includes('"switch"'))).toBe(false); }); @@ -188,13 +192,17 @@ describe("acquireTaskWorktree backend wiring", () => { expect(result.branch).toBe("fusion/fn-backend"); expect(create).toHaveBeenCalledTimes(1); /* - * FNXC:WorktreeIsolation 2026-07-02-07:40: - * The integration-branch resolution runs before the explicit backend's create is invoked, so the only exec call is the `git symbolic-ref` lookup. The custom backend's create mock performs no exec. + * FNXC:WorktreeIsolation 2026-07-02-07:40 (updated 2026-07-07-09:15 for FN-7438): + * The integration-branch resolution runs before the explicit backend's create. With an empty symbolic-ref result, FN-7438 (aa8f1f32e) adds a `git remote` discovery call before the "main" fallback, so two exec calls happen: symbolic-ref + git remote. The custom backend's create mock performs no exec. */ - expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", expect.objectContaining({ cwd: "/repo" }), ); + expect(execMock).toHaveBeenCalledWith( + "git remote", + expect.objectContaining({ cwd: "/repo" }), + ); }); }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts index f194aa7692..f174bc7e44 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts @@ -66,13 +66,14 @@ describe("acquireTaskWorktree worktrunk wiring", () => { expect(result).toMatchObject({ source: "fresh", branch: "fusion/fn-1" }); /* - * FNXC:WorktreeIsolation 2026-07-02-07:40: - * acquireTaskWorktree now resolves the integration branch via `git symbolic-ref` (returning empty here, so it falls back to "main") and pins the fresh worktree to that start point. Two exec calls happen: the symbolic-ref lookup, then the native `git worktree add -b ... "main"` create. + * FNXC:WorktreeIsolation 2026-07-02-07:40 (updated 2026-07-07-09:15 for FN-7438): + * acquireTaskWorktree resolves the integration branch via `git symbolic-ref`. When that returns empty (as the mock does here), FN-7438 (aa8f1f32e) added a `git remote` discovery call before falling back to "main". So three exec calls happen: symbolic-ref, git remote, then the native `git worktree add -b ... "main"` create. */ - expect(execMock).toHaveBeenCalledTimes(2); + expect(execMock).toHaveBeenCalledTimes(3); expect(execMock.mock.calls[0]?.[0]).toBe("git symbolic-ref --short refs/remotes/origin/HEAD"); - expect(execMock.mock.calls[1]?.[0]).toContain('git worktree add -b "fusion/fn-1"'); - expect(execMock.mock.calls[1]?.[0]).toContain('"main"'); + expect(execMock.mock.calls[1]?.[0]).toBe("git remote"); + expect(execMock.mock.calls[2]?.[0]).toContain('git worktree add -b "fusion/fn-1"'); + expect(execMock.mock.calls[2]?.[0]).toContain('"main"'); }); it("prefers explicit createWorktree override", async () => { @@ -230,13 +231,17 @@ describe("acquireTaskWorktree worktrunk wiring", () => { expect(result.branch).toBe("fusion/fn-1-custom"); expect(create).toHaveBeenCalledTimes(1); /* - * FNXC:WorktreeIsolation 2026-07-02-07:40: - * The integration-branch resolution runs before the custom backend's create, so the only exec call is the `git symbolic-ref` lookup. The backend's create mock performs no exec. + * FNXC:WorktreeIsolation 2026-07-02-07:40 (updated 2026-07-07-09:15 for FN-7438): + * The integration-branch resolution runs before the custom backend's create. With an empty symbolic-ref result, FN-7438 (aa8f1f32e) adds a `git remote` discovery call before the "main" fallback, so two exec calls happen: symbolic-ref + git remote. The backend's create mock performs no exec. */ - expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledTimes(2); expect(execMock).toHaveBeenCalledWith( "git symbolic-ref --short refs/remotes/origin/HEAD", expect.objectContaining({ cwd: "/repo" }), ); + expect(execMock).toHaveBeenCalledWith( + "git remote", + expect.objectContaining({ cwd: "/repo" }), + ); }); }); diff --git a/packages/engine/src/__tests__/worktree-backend.test.ts b/packages/engine/src/__tests__/worktree-backend.test.ts index b1cc8406a3..894e080bca 100644 --- a/packages/engine/src/__tests__/worktree-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-backend.test.ts @@ -821,11 +821,14 @@ describe("WorktrunkWorktreeBackend", () => { }); it("maps rebase conflicts to worktrunk_sync_conflict", async () => { + // FN-7438 (aa8f1f32e): resolveIntegrationBranch now does symbolic-ref + `git remote` + // before fetch+rebase when no trunk is given, which would consume this mock queue. + // Pass an explicit trunk to isolate the rebase-conflict mapping path under test. execMock.mockResolvedValueOnce({ stdout: "", stderr: "" }).mockRejectedValueOnce({ stderr: "CONFLICT" }); const backend = new WorktrunkWorktreeBackend({ binaryPath: "worktrunk" }); await expect( - backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "main" }), + backend.sync({ rootDir: "/repo", worktreePath: "/repo/.worktrees/fn-1", branch: "main", trunk: "main" }), ).rejects.toMatchObject({ code: "worktrunk_sync_conflict", operation: "sync" }); }); From b7aa8b38e4799ad56dcc974ea54f9ca097f395b6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:46:00 -0700 Subject: [PATCH 15/20] test(engine): fix pi-create-fn-agent RTK/pauseForApproval + step-session-executor logging/terminal-activity assertions --- packages/engine/src/__tests__/pi-create-fn-agent.test.ts | 5 ++++- .../engine/src/__tests__/step-session-executor.test.ts | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 2e2ccf674b..70218dc6ee 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1065,7 +1065,10 @@ describe("wrapToolsWithActionGate", () => { expect((first as any).decision.metadata.approvalRequestId).toBe("apr-1"); expect((second as any).decision.metadata.approvalRequestId).toBe("apr-1"); expect(createApprovalRequest).toHaveBeenCalledTimes(1); - expect(pauseForApproval).toHaveBeenCalledTimes(1); + // FN-7608 (9e5c02511): the gate pause (pauseForApproval) now runs for BOTH the + // newly-created-request sub-case AND the reused-pending sub-case, so each gated + // execute while the approval is pending pauses the session (see pi.ts FNXC:ActionGate). + expect(pauseForApproval).toHaveBeenCalledTimes(2); expect(tool.execute).not.toHaveBeenCalled(); }); diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index c98305ff21..dfd754189d 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -8,6 +8,7 @@ import { StepSessionExecutor, } from "../step-session-executor.js"; import { AgentLogger } from "../agent-logger.js"; +import { expectAppendAgentLog } from "./agent-log-assertions.js"; import * as worktreeBackendModule from "../worktree-backend.js"; import type { TaskDetail, Settings, TaskStore } from "@fusion/core"; import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js"; @@ -2753,9 +2754,10 @@ describe("StepSessionExecutor", () => { await executor.executeAll(); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "step output", "text", undefined, "executor"); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool", undefined, "executor"); - expect(appendAgentLog).toHaveBeenCalledWith("FN-001", "read", "tool_result", undefined, "executor"); + // FN-7503 added an optional 6th timing arg; pin the first five and tolerate timing. + expectAppendAgentLog(appendAgentLog, "FN-001", "step output", "text", undefined, "executor"); + expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool", undefined, "executor"); + expectAppendAgentLog(appendAgentLog, "FN-001", "read", "tool_result", undefined, "executor"); }); it("flushes AgentLogger in attempt finally block", async () => { From 93d1f702f094bb473467efac19e341c92a14ba9d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:50:34 -0700 Subject: [PATCH 16/20] test(engine): update MCP forwarding coverage needle for FN-7446 resolvePlanningMcpServers helper --- packages/engine/src/__tests__/mcp-surface-coverage.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/__tests__/mcp-surface-coverage.test.ts b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts index 0fc9b6aa9d..946783b2f4 100644 --- a/packages/engine/src/__tests__/mcp-surface-coverage.test.ts +++ b/packages/engine/src/__tests__/mcp-surface-coverage.test.ts @@ -131,7 +131,8 @@ describe("MCP surface coverage", () => { it("keeps dashboard planning forwarding resolved MCP with the readonly opt-in", () => { const source = readFileSync(join(process.cwd(), "../dashboard/src/planning.ts"), "utf8"); - const forwardingNeedle = "mcpServers: (await resolveMcpServersForStore(store)).servers,"; + // FNXC:McpCoverage 2026-07-07-09:50: FN-7446 wrapped planning MCP resolution in resolvePlanningMcpServers(store), defaulting undefined resolver results to empty servers. Match the new helper call instead of the raw (await resolveMcpServersForStore(store)).servers expression. + const forwardingNeedle = "mcpServers: await resolvePlanningMcpServers(store),"; expect(source.match(new RegExp(forwardingNeedle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length).toBe(2); expect(source.match(/allowMcpToolsInReadonly: true,/g)?.length).toBeGreaterThanOrEqual(2); expect(source).toContain("const agentResult = await createFnAgent({"); From 0c1a20b1bd86beb405b9d6e553044687ed47072d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 09:53:27 -0700 Subject: [PATCH 17/20] chore: add changesets for workspace landedSha + sub-repo worktree branch-strip fixes --- .changeset/fn-workspace-landedsha-recovery.md | 7 +++++++ .changeset/fn-worktree-subrepo-branch-strip.md | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 .changeset/fn-workspace-landedsha-recovery.md create mode 100644 .changeset/fn-worktree-subrepo-branch-strip.md diff --git a/.changeset/fn-workspace-landedsha-recovery.md b/.changeset/fn-workspace-landedsha-recovery.md new file mode 100644 index 0000000000..af5234acf1 --- /dev/null +++ b/.changeset/fn-workspace-landedsha-recovery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix workspace partial-land recovery losing the already-landed sub-repo sha. +category: fix +dev: merger-ai.ts landWorkspaceTask now recovers the integration-tip sha as landedSha when the A1 trailer-fallback proved a sub-repo landed but its sha was never persisted, so finalizeWorkspaceTask can build merge proof instead of stranding the partial-land retry in-review. diff --git a/.changeset/fn-worktree-subrepo-branch-strip.md b/.changeset/fn-worktree-subrepo-branch-strip.md new file mode 100644 index 0000000000..de0ef82743 --- /dev/null +++ b/.changeset/fn-worktree-subrepo-branch-strip.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix workspace sub-repo worktree creation failing on absent shared branch. +category: fix +dev: worktree-acquisition.ts acquireWorkspaceRepoWorktree now strips the shared project integrationBranch/baseBranch overrides before forwarding to acquireTaskWorktree, so FN-7360's freshStartPoint resolution no longer tries to git-worktree-add a branch absent from the sub-repo. From 518c5420f2c7c2f61150029b868b7821b0ff7d5a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 10:09:28 -0700 Subject: [PATCH 18/20] fix(engine): recover exact proven landed commit, not current tip (Greptile P1) findProvenLandedCommit returns the task's own trailer commit (or recorded landedSha when still an ancestor) instead of rev-parse on the integration tip, so an intervening sub-repo land can't attribute a later unrelated commit. Regression: intervening commit after lost persist recovers tipAfterFirst. --- .../workspace-merger-idempotency.test.ts | 21 +++++++-- packages/engine/src/merger-ai.ts | 33 ++++++------- .../engine/src/workspace-land-predicate.ts | 46 ++++++++++++++----- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index c53e10ffb3..12021975a8 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -373,14 +373,29 @@ describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4 // Status was reset off 'merging' before the throw escaped (A3). expect(store.task.status ?? null).toBeNull(); - // Retry: isRepoLanded's trailer ancestor-fallback (A1) recognises the actually-landed - // repo via its Fusion-Task-Id trailer and SKIPS it — the ref must NOT advance a 2nd time. + /* + FNXC:Workspace 2026-07-07-10:30 (Phase C A1 precision regression — Greptile P1): + Simulate an intervening sub-repo land: advance repo-a's integration tip with an UNRELATED + commit AFTER the task's squash landed (tipAfterFirst) but BEFORE the lost-persist retry. The + recovered landedSha must be the task's OWN landing commit (tipAfterFirst), NOT the later + unrelated tip — otherwise finalize would attribute a wrong commit to this repo. + */ + configureIdentity(fx.repoPath("repo-a")); + fx.git("repo-a", 'git commit --allow-empty -m "unrelated intervening land (no Fusion-Task-Id trailer)"'); + const tipAfterIntervening = fx.git("repo-a", "git rev-parse refs/heads/main"); + expect(tipAfterIntervening).not.toBe(tipAfterFirst); + + // Retry: the A1 trailer scan recognises the actually-landed repo via its Fusion-Task-Id + // trailer and SKIPS it — the ref must NOT advance a 2nd time (stays at the intervening tip). const second = await landWorkspaceTask(store, store.task, fx.rootDir, {}, { mergeAgent: squashMergeAgent(BRANCH), reviewAgent: approveReviewAgent, }); - expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAfterFirst); // no double squash + expect(fx.git("repo-a", "git rev-parse refs/heads/main")).toBe(tipAfterIntervening); // no double squash expect(second.repos[0].alreadyLanded).toBe(true); + // Precision invariant: the recovered landedSha is the task's exact landing commit, not the + // later unrelated integration tip. + expect(second.repos[0].landedSha).toBe(tipAfterFirst); expect(second.allLanded).toBe(true); expect(second.finalized).toBe(true); }); diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index fa33c041d3..70a0fb2be6 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -79,7 +79,7 @@ FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved): module so self-healing can import the predicate without re-entering the self-healing ↔ merger-ai import cycle (merger-ai-worktree imports `MIN_TEMP_WORKTREE_REAP_AGE_MS` from self-healing). */ -import { isRepoLanded, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; +import { isRepoLanded, findProvenLandedCommit, FUSION_TASK_ID_TRAILER_KEY } from "./workspace-land-predicate.js"; import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js"; import { cleanupAiMergeWorktree, @@ -1221,29 +1221,30 @@ export async function landWorkspaceTask( // ancestor of (or equals) its CURRENT integration tip is already landed — SKIP // it so a retry never re-advances the ref. This makes a re-run after a partial // land idempotent for the already-landed repos. - if (await isRepoLanded(repoRootDir, integrationBranch, entry.landedSha, taskId, entry.branch)) { + const provenLandedSha = await findProvenLandedCommit( + repoRootDir, + integrationBranch, + entry.landedSha, + taskId, + entry.branch, + ); + if (provenLandedSha) { /* - FNXC:Workspace 2026-07-07-08:35 (Phase C A1 recovery — recover landedSha for finalize proof): + FNXC:Workspace 2026-07-07-10:25 (Phase C A1 recovery — record the EXACT proven commit, not the tip): isRepoLanded's A1 trailer-fallback can prove a sub-repo is landed even when its landedSha was never persisted (the persist-after-advance window in persistRepoLandedSha threw). That left the in-memory result with landedSha: undefined, so finalizeWorkspaceTask's `status === "landed" && landedSha` filter dropped the recovered repo, `anyLanded` stayed - false, and the proven repo's retry STRANDED the task in-review with missing-merge-confirmation - (finalizeTask's hasDurableMergeProof needs mergeConfirmed). Recover the CURRENT integration - tip as landedSha — the trailer-fallback already proved the task branch is an ancestor of this - tip — so the finalize builds durable mergeConfirmed proof and the A1 retry completes to done. + false, and the proven repo's retry STRANDED the task in-review with missing-merge-confirmation. + Recover the EXACT proven commit (the A1 trailer commit, or the recorded landedSha when it is + still an ancestor) — NOT the current integration tip, which may have advanced past the actual + landing commit via an intervening sub-repo land. findProvenLandedCommit returns that exact sha + so finalize builds durable mergeConfirmed proof and the A1 retry completes to done. */ - let recoveredLandedSha = entry.landedSha; - if (!recoveredLandedSha) { - recoveredLandedSha = await git( - ["rev-parse", "--verify", `refs/heads/${integrationBranch}`], - repoRootDir, - ).catch(() => undefined); - } - await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(recoveredLandedSha ?? "?")} ⊑ ${integrationBranch}) — skipping`); + await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(provenLandedSha)} ⊑ ${integrationBranch}) — skipping`); repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, - status: "landed", landedSha: recoveredLandedSha, alreadyLanded: true, + status: "landed", landedSha: provenLandedSha, alreadyLanded: true, }); continue; } diff --git a/packages/engine/src/workspace-land-predicate.ts b/packages/engine/src/workspace-land-predicate.ts index 5f903592b7..64ffcd31cb 100644 --- a/packages/engine/src/workspace-land-predicate.ts +++ b/packages/engine/src/workspace-land-predicate.ts @@ -78,29 +78,36 @@ async function gitCapture(args: string[], cwd: string): Promise { +): Promise { const intRef = `refs/heads/${integrationBranch}`; if (!(await gitOk(["rev-parse", "--verify", intRef], repoRootDir))) { - return false; + return undefined; } - // Primary: recorded landedSha is an ancestor of (or equals) the integration tip. - // `merge-base --is-ancestor X Y` exits 0 iff X is an ancestor of (or equal to) Y. + // Primary: recorded landedSha is an ancestor of (or equals) the integration tip — that SHA + // IS the exact landing commit. if ( landedSha && (await gitOk(["merge-base", "--is-ancestor", landedSha, intRef], repoRootDir)) ) { - return true; + return landedSha; } - // A1 fallback: even without a recorded landedSha, the repo is already landed if the - // integration ref carries a commit with this task's Fusion-Task-Id trailer (the squash - // we lost the persist for). Bound the scan to commits gained since the branch's land base - // so a stale historical trailer of the same id cannot false-positive. + // A1 fallback: the commit carrying this task's Fusion-Task-Id trailer in the bounded range + // is the exact proven landing commit. Bound the scan to commits gained since the branch's + // land base so a stale historical trailer of the same id cannot false-positive. if (taskId) { const branchRef = branch ? `refs/heads/${branch}` : undefined; let range = intRef; @@ -113,7 +120,22 @@ export async function isRepoLanded( ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], repoRootDir, ); - if (found && found.trim().length > 0) return true; + if (found) { + const firstSha = found.trim().split("\n")[0]; + if (firstSha) return firstSha; + } } - return false; + return undefined; +} + +export async function isRepoLanded( + repoRootDir: string, + integrationBranch: string, + landedSha: string | undefined, + taskId?: string, + branch?: string, +): Promise { + return Boolean( + await findProvenLandedCommit(repoRootDir, integrationBranch, landedSha, taskId, branch), + ); } From 88c7c51122c69b5307a9a52d3ee26d0215592052 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 10:10:11 -0700 Subject: [PATCH 19/20] chore: correct workspace landedSha changeset to reflect exact proven-commit recovery --- .changeset/fn-workspace-landedsha-recovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fn-workspace-landedsha-recovery.md b/.changeset/fn-workspace-landedsha-recovery.md index af5234acf1..26d64db36f 100644 --- a/.changeset/fn-workspace-landedsha-recovery.md +++ b/.changeset/fn-workspace-landedsha-recovery.md @@ -4,4 +4,4 @@ summary: Fix workspace partial-land recovery losing the already-landed sub-repo sha. category: fix -dev: merger-ai.ts landWorkspaceTask now recovers the integration-tip sha as landedSha when the A1 trailer-fallback proved a sub-repo landed but its sha was never persisted, so finalizeWorkspaceTask can build merge proof instead of stranding the partial-land retry in-review. +dev: merger-ai.ts landWorkspaceTask now recovers the EXACT proven landed commit (the task's own Fusion-Task-Id trailer commit, or the recorded landedSha when it is still an ancestor) via findProvenLandedCommit, instead of dropping it when the A1 trailer-fallback proved a sub-repo landed but its sha was never persisted. This avoids attributing a later unrelated integration tip to the repo after an intervening sub-repo land, so finalizeWorkspaceTask builds durable merge proof and the partial-land retry completes to done. From 203c734340ffc60be1352f968004440586c5deb6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 10:25:11 -0700 Subject: [PATCH 20/20] fix(engine): require exact trailer line, not substring, for proven landed commit (Greptile P1) findProvenLandedCommit now keeps --grep as a prefilter but verifies each candidate carries an actual 'Fusion-Task-Id: ' trailer line via git show -s --format=%B, so a later commit that merely mentions the trailer text in its body cannot be selected. Regression covers a body-mention intervening commit. --- .../workspace-merger-idempotency.test.ts | 18 ++++++++++----- .../engine/src/workspace-land-predicate.ts | 22 +++++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts index 12021975a8..ee6e066b02 100644 --- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts @@ -374,14 +374,20 @@ describeIfGit("landWorkspaceTask — DB-failure resilience (Phase C review A1/A4 expect(store.task.status ?? null).toBeNull(); /* - FNXC:Workspace 2026-07-07-10:30 (Phase C A1 precision regression — Greptile P1): - Simulate an intervening sub-repo land: advance repo-a's integration tip with an UNRELATED - commit AFTER the task's squash landed (tipAfterFirst) but BEFORE the lost-persist retry. The - recovered landedSha must be the task's OWN landing commit (tipAfterFirst), NOT the later - unrelated tip — otherwise finalize would attribute a wrong commit to this repo. + FNXC:Workspace 2026-07-07-10:55 (Phase C A1 precision regression — Greptile P1, two surfaces): + Advance repo-a's integration tip with an intervening commit whose MESSAGE BODY mentions the + trailer text "Fusion-Task-Id: FN-2002" (a changelog/diagnostic-style mention, NOT a real trailer + line) AFTER the task's squash landed (tipAfterFirst) but BEFORE the lost-persist retry. This + covers both precision surfaces: (1) the recovered landedSha must be the task's OWN landing commit + (tipAfterFirst), not the later tip; (2) the substring --grep prefilter must NOT select the + body-mention commit — findProvenLandedCommit requires an actual trailer line, so it skips the + mention and returns the real squash commit. */ configureIdentity(fx.repoPath("repo-a")); - fx.git("repo-a", 'git commit --allow-empty -m "unrelated intervening land (no Fusion-Task-Id trailer)"'); + fx.git( + "repo-a", + 'git commit --allow-empty -m "unrelated intervening land" -m "changelog: relates to Fusion-Task-Id: FN-2002 (body mention, not a trailer line)"', + ); const tipAfterIntervening = fx.git("repo-a", "git rev-parse refs/heads/main"); expect(tipAfterIntervening).not.toBe(tipAfterFirst); diff --git a/packages/engine/src/workspace-land-predicate.ts b/packages/engine/src/workspace-land-predicate.ts index 64ffcd31cb..769e037c7c 100644 --- a/packages/engine/src/workspace-land-predicate.ts +++ b/packages/engine/src/workspace-land-predicate.ts @@ -116,13 +116,27 @@ export async function findProvenLandedCommit( if (base) range = `${base.trim()}..${intRef}`; } const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`; - const found = await gitCapture( + /* + FNXC:Workspace 2026-07-07-10:50 (Phase C A1 precision — Greptile P1, trailer-line verification): + `git log --grep= --fixed-strings` is a substring search over the WHOLE commit message, + so a later changelog/diagnostic commit that merely mentions the trailer text in its body would + be selected over the actual squash commit. Use --grep only as a prefilter, then require an actual + trailer LINE (a line whose trimmed text is exactly the trailer) via `git show -s --format=%B`. + Candidates are reverse-chronological, so the first one with an exact trailer line is the task's + own landing commit. + */ + const candidates = await gitCapture( ["log", "--format=%H", `--grep=${trailer}`, "--fixed-strings", range], repoRootDir, ); - if (found) { - const firstSha = found.trim().split("\n")[0]; - if (firstSha) return firstSha; + if (candidates) { + for (const sha of candidates.trim().split("\n")) { + if (!sha) continue; + const body = await gitCapture(["show", "-s", "--format=%B", sha], repoRootDir); + if (body && body.split("\n").some((line) => line.trim() === trailer)) { + return sha; + } + } } } return undefined;