diff --git a/.changeset/fn-workspace-landedsha-recovery.md b/.changeset/fn-workspace-landedsha-recovery.md
new file mode 100644
index 0000000000..26d64db36f
--- /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 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.
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.
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/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__/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__/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__/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-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__/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) => {
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",
]);
});
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__/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__/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__/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__/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__/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__/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({");
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");
});
});
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__/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);
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();
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);
});
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(() => []),
};
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);
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();
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 () => {
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__/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__/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(),
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 });
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(
diff --git a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts
index c53e10ffb3..ee6e066b02 100644
--- a/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts
+++ b/packages/engine/src/__tests__/workspace-merger-idempotency.test.ts
@@ -373,14 +373,35 @@ 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: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" -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);
+
+ // 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/__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" });
});
diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts
index 306a426d87..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,11 +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)) {
- await log(`AI merge (workspace): sub-repo ${repoRel} already landed (${short(entry.landedSha!)} ⊑ ${integrationBranch}) — skipping`);
+ const provenLandedSha = await findProvenLandedCommit(
+ repoRootDir,
+ integrationBranch,
+ entry.landedSha,
+ taskId,
+ entry.branch,
+ );
+ if (provenLandedSha) {
+ /*
+ 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.
+ 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.
+ */
+ 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: entry.landedSha, 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..769e037c7c 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;
@@ -109,11 +116,40 @@ export async function isRepoLanded(
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 && found.trim().length > 0) return true;
+ 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 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),
+ );
}
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,