feat(FN-3335): fix worktree project root resolution in createFnAgent and ad

Merges FN-3335 (worktree project resolution) and FN-3333 (spurious version reloads). The engine's `createFnAgent` now resolves project root from the worktree's cwd rather than the parent process, with `resolveProjectRoot` added to skill-resolver for consistency. The dashboard's `versionCheck` was up

Fusion-Task-Id: FN-3335
This commit is contained in:
Fusion
2026-05-03 16:01:17 -07:00
committed by gsxdsm
parent 53ab441a97
commit d7e2a3ec42
6 changed files with 195 additions and 9 deletions

View File

@@ -452,6 +452,40 @@ describe("createFnAgent", () => {
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
});
it("resolves project root from worktree cwd for convenience skills parameter", async () => {
existsSyncMock.mockImplementation((path) => {
const value = String(path);
return value === "/project/.worktrees/task-branch" ||
value === "/project/.worktrees/task-branch/.git";
});
execSyncMock.mockImplementation((cmd) => {
if (cmd === "git rev-parse --show-toplevel") {
return "/project/.worktrees/task-branch\n";
}
return "worktree /project\nHEAD abc123\nbranch refs/heads/main\n\n" +
"worktree /project/.worktrees/task-branch\nHEAD def456\nbranch refs/heads/fusion/fn-001\n";
});
const { createFnAgent } = await import("../pi.js");
// Pass skills parameter with a worktree cwd.
// getProjectRootFromWorktree extracts /project from the .worktrees path,
// which is passed as projectRootDir to resolveSessionSkills.
// resolveSessionSkills then calls resolveProjectRoot which walks up
// looking for .fusion — since existsSync returns false for all paths
// except the worktree itself, it falls back to /project.
// The session should be created successfully.
await createFnAgent({
cwd: "/project/.worktrees/task-branch",
systemPrompt: "test",
tools: "coding",
skills: ["fusion"],
});
// Verify the session was created (no crash)
expect(createAgentSessionMock).toHaveBeenCalledTimes(1);
});
it("registers extension providers before resolving configured models", async () => {
packageManagerResolveMock.mockResolvedValueOnce({
extensions: [{ enabled: true, path: "/extensions/zai-provider" }],

View File

@@ -401,6 +401,26 @@ describe("createFnAgent skills parameter", () => {
);
});
it("resolves project root via resolvePiExtensionProjectRoot for non-worktree paths", async () => {
// When cwd is a regular directory (not a .worktrees/ path),
// resolvePiExtensionProjectRoot is used to walk up to .fusion.
// Since no .fusion exists in test filesystem, it returns cwd as-is.
const options: AgentOptions = {
cwd: "/project/subdirectory",
systemPrompt: "Test",
skills: ["fusion"],
};
await createFnAgent(options);
// resolvePiExtensionProjectRoot walks up from /project/subdirectory.
// No .fusion is found in the test filesystem, so it returns /project/subdirectory.
expect(mockResolveSessionSkills).toHaveBeenCalledTimes(1);
const callArgs = mockResolveSessionSkills.mock.calls[0]![0];
expect(callArgs.projectRootDir).toBe("/project/subdirectory");
expect(callArgs.requestedSkillNames).toEqual(["fusion"]);
});
it("skills without corresponding discovered skills produces diagnostics", async () => {
// Mock to return diagnostics for missing skill
mockResolveSessionSkills.mockReturnValue({

View File

@@ -18,6 +18,7 @@ vi.mock("../logger.js", () => ({
import {
resolveSessionSkills,
resolveProjectRoot,
createSkillsOverrideFromSelection,
type SkillSelectionResult,
} from "../skill-resolver.js";
@@ -26,13 +27,14 @@ import {
// In-memory file system for tests - using a proxy to intercept fs calls
const mockFiles = new Map<string, string>();
const mockDirs = new Set<string>();
let mockDirCounter = 0;
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: (path: unknown) => mockFiles.has(String(path)),
existsSync: (path: unknown) => mockFiles.has(String(path)) || mockDirs.has(String(path)),
readFileSync: (path: unknown) => mockFiles.get(String(path)) ?? "{}",
mkdtempSync: () => `/tmp/skill-resolver-mock-${++mockDirCounter}`,
writeFileSync: (path: unknown, content: unknown) => mockFiles.set(String(path), String(content)),
@@ -50,6 +52,7 @@ vi.mock("node:fs", async () => {
function createMockProjectDir(settings: Record<string, unknown> | null): string {
const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
if (settings !== null) {
mockDirs.add(`${dir}/.fusion`);
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify(settings));
}
return dir;
@@ -57,9 +60,58 @@ function createMockProjectDir(settings: Record<string, unknown> | null): string
// ── Tests ───────────────────────────────────────────────────────────────────
describe("resolveProjectRoot", () => {
beforeEach(() => {
mockFiles.clear();
mockDirs.clear();
mockDirCounter = 0;
});
it("returns cwd directly when cwd contains .fusion", () => {
const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
mockDirs.add(`${dir}/.fusion`);
expect(resolveProjectRoot(dir)).toBe(dir);
});
it("walks up from worktree path to find project root", () => {
const projectDir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
const worktreeDir = `${projectDir}/.worktrees/swift-falcon`;
mockDirs.add(`${projectDir}/.fusion`);
expect(resolveProjectRoot(worktreeDir)).toBe(projectDir);
});
it("walks up from deeply nested path", () => {
const projectDir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
const nestedDir = `${projectDir}/.worktrees/task-branch/src/components`;
mockDirs.add(`${projectDir}/.fusion`);
expect(resolveProjectRoot(nestedDir)).toBe(projectDir);
});
it("returns cwd when no .fusion directory found anywhere", () => {
const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
// No .fusion set up anywhere
expect(resolveProjectRoot(dir)).toBe(dir);
});
it("returns cwd when .fusion is in a sibling directory (not ancestor)", () => {
const parentDir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
const dir = `${parentDir}/my-project`;
const siblingDir = `${parentDir}/other-project`;
mockDirs.add(`${siblingDir}/.fusion`);
// Walking up from dir should not find sibling's .fusion
expect(resolveProjectRoot(dir)).toBe(dir);
});
});
describe("resolveSessionSkills", () => {
beforeEach(() => {
mockFiles.clear();
mockDirs.clear();
mockDirCounter = 0;
});
@@ -322,6 +374,45 @@ describe("resolveSessionSkills", () => {
expect(result.allowedSkillPaths.has("skills/fusion/SKILL.md")).toBe(true);
});
it("resolves project root from worktree path", () => {
const projectDir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
const worktreeDir = `${projectDir}/.worktrees/branch-name`;
// Set up project root with .fusion directory and settings
mockDirs.add(`${projectDir}/.fusion`);
mockFiles.set(`${projectDir}/.fusion/settings.json`, JSON.stringify({
skills: ["+skills/fusion/SKILL.md"],
}));
// Call with the worktree path (not the project root)
const result = resolveSessionSkills({
projectRootDir: worktreeDir,
});
// Should have resolved to the project root and read settings correctly
expect(result.filterActive).toBe(true);
expect(result.allowedSkillPaths.has("skills/fusion/SKILL.md")).toBe(true);
});
it("resolves project root from deeply nested worktree subdirectory", () => {
const projectDir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
const worktreeSubdir = `${projectDir}/.worktrees/task-branch/src/components`;
mockDirs.add(`${projectDir}/.fusion`);
mockFiles.set(`${projectDir}/.fusion/settings.json`, JSON.stringify({
skills: ["+skills/review/SKILL.md", "+skills/lint/SKILL.md"],
}));
const result = resolveSessionSkills({
projectRootDir: worktreeSubdir,
});
expect(result.filterActive).toBe(true);
expect(result.allowedSkillPaths.size).toBe(2);
expect(result.allowedSkillPaths.has("skills/review/SKILL.md")).toBe(true);
expect(result.allowedSkillPaths.has("skills/lint/SKILL.md")).toBe(true);
});
});
describe("handles missing/empty settings files gracefully", () => {

View File

@@ -1103,11 +1103,17 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
// Detect if this is a worktree session and apply path boundaries
const worktreePath = options.cwd;
const projectRoot = getProjectRootFromWorktree(worktreePath);
if (projectRoot) {
await assertValidWorktreeSession(worktreePath, projectRoot);
const worktreeProjectRoot = getProjectRootFromWorktree(worktreePath);
if (worktreeProjectRoot) {
await assertValidWorktreeSession(worktreePath, worktreeProjectRoot);
}
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, worktreeProjectRoot);
// Resolve the project root for resource discovery (skills, settings, extensions).
// When cwd is a worktree (e.g., /project/.worktrees/task-branch) or any other
// subdirectory, we walk up to find the project root containing .fusion/.
// This ensures skill/settings discovery works regardless of session cwd.
const resolvedProjectRoot = worktreeProjectRoot ?? resolvePiExtensionProjectRoot(options.cwd);
// Compaction is explicitly enabled to prevent context-window overflow during
// long-running agent conversations (triage, execution, review, merge).
@@ -1138,7 +1144,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`);
effectiveSkillSelection = {
projectRootDir: options.cwd,
projectRootDir: resolvedProjectRoot,
requestedSkillNames: options.skills,
sessionPurpose: "executor",
};
@@ -1174,7 +1180,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}
const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd,
cwd: resolvedProjectRoot,
agentDir: getFusionAgentDir(),
settingsManager,
systemPromptOverride: () => options.systemPrompt,

View File

@@ -10,10 +10,35 @@
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { dirname, join, resolve } from "node:path";
import type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
import { piLog } from "./logger.js";
// ── Project Root Resolution ──────────────────────────────────────────────────
/**
* Resolve the project root directory by walking up from `cwd` looking for
* a directory containing `.fusion/`. This handles worktree paths (e.g.,
* `/project/.worktrees/task-branch`) and any other subdirectory by walking
* up to the actual project root.
*
* Falls back to `cwd` if no `.fusion/` directory is found (mirrors
* `resolvePiExtensionProjectRoot` from `@fusion/core`).
*/
export function resolveProjectRoot(cwd: string): string {
let current = resolve(cwd);
while (true) {
if (existsSync(join(current, ".fusion"))) {
return current;
}
const parent = dirname(current);
if (parent === current) {
return resolve(cwd);
}
current = parent;
}
}
// ── Types ───────────────────────────────────────────────────────────────────
/**
@@ -161,7 +186,12 @@ function isExclusionPattern(pattern: string): boolean {
* - Requested names not matching any discovered skill (warning)
*/
export function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult {
const { projectRootDir, requestedSkillNames } = context;
const { requestedSkillNames } = context;
// Resolve project root from the given projectRootDir — it may be a
// worktree path (e.g., /project/.worktrees/task-branch) which doesn't
// contain .fusion/settings.json. Walk up to find the real project root.
const projectRootDir = resolveProjectRoot(context.projectRootDir);
// Read project settings
const settings = readProjectSettings(projectRootDir);