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 85d02c8291
commit a1a8d0398f
6 changed files with 195 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix skill and settings discovery when agent cwd is a worktree path. Previously, agents running in worktrees couldn't find skills, load project settings, or discover extensions because path resolution used the worktree directory directly instead of walking up to the project root.

View File

@@ -452,6 +452,40 @@ describe("createFnAgent", () => {
expect(createAgentSessionMock).toHaveBeenCalledTimes(1); 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 () => { it("registers extension providers before resolving configured models", async () => {
packageManagerResolveMock.mockResolvedValueOnce({ packageManagerResolveMock.mockResolvedValueOnce({
extensions: [{ enabled: true, path: "/extensions/zai-provider" }], 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 () => { it("skills without corresponding discovered skills produces diagnostics", async () => {
// Mock to return diagnostics for missing skill // Mock to return diagnostics for missing skill
mockResolveSessionSkills.mockReturnValue({ mockResolveSessionSkills.mockReturnValue({

View File

@@ -18,6 +18,7 @@ vi.mock("../logger.js", () => ({
import { import {
resolveSessionSkills, resolveSessionSkills,
resolveProjectRoot,
createSkillsOverrideFromSelection, createSkillsOverrideFromSelection,
type SkillSelectionResult, type SkillSelectionResult,
} from "../skill-resolver.js"; } from "../skill-resolver.js";
@@ -26,13 +27,14 @@ import {
// In-memory file system for tests - using a proxy to intercept fs calls // In-memory file system for tests - using a proxy to intercept fs calls
const mockFiles = new Map<string, string>(); const mockFiles = new Map<string, string>();
const mockDirs = new Set<string>();
let mockDirCounter = 0; let mockDirCounter = 0;
vi.mock("node:fs", async () => { vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return { return {
...actual, ...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)) ?? "{}", readFileSync: (path: unknown) => mockFiles.get(String(path)) ?? "{}",
mkdtempSync: () => `/tmp/skill-resolver-mock-${++mockDirCounter}`, mkdtempSync: () => `/tmp/skill-resolver-mock-${++mockDirCounter}`,
writeFileSync: (path: unknown, content: unknown) => mockFiles.set(String(path), String(content)), 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 { function createMockProjectDir(settings: Record<string, unknown> | null): string {
const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`; const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
if (settings !== null) { if (settings !== null) {
mockDirs.add(`${dir}/.fusion`);
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify(settings)); mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify(settings));
} }
return dir; return dir;
@@ -57,9 +60,58 @@ function createMockProjectDir(settings: Record<string, unknown> | null): string
// ── Tests ─────────────────────────────────────────────────────────────────── // ── 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", () => { describe("resolveSessionSkills", () => {
beforeEach(() => { beforeEach(() => {
mockFiles.clear(); mockFiles.clear();
mockDirs.clear();
mockDirCounter = 0; mockDirCounter = 0;
}); });
@@ -322,6 +374,45 @@ describe("resolveSessionSkills", () => {
expect(result.allowedSkillPaths.has("skills/fusion/SKILL.md")).toBe(true); 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", () => { 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 // Detect if this is a worktree session and apply path boundaries
const worktreePath = options.cwd; const worktreePath = options.cwd;
const projectRoot = getProjectRootFromWorktree(worktreePath); const worktreeProjectRoot = getProjectRootFromWorktree(worktreePath);
if (projectRoot) { if (worktreeProjectRoot) {
await assertValidWorktreeSession(worktreePath, projectRoot); 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 // Compaction is explicitly enabled to prevent context-window overflow during
// long-running agent conversations (triage, execution, review, merge). // 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) { if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`); piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`);
effectiveSkillSelection = { effectiveSkillSelection = {
projectRootDir: options.cwd, projectRootDir: resolvedProjectRoot,
requestedSkillNames: options.skills, requestedSkillNames: options.skills,
sessionPurpose: "executor", sessionPurpose: "executor",
}; };
@@ -1174,7 +1180,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
} }
const resourceLoader = new DefaultResourceLoader({ const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd, cwd: resolvedProjectRoot,
agentDir: getFusionAgentDir(), agentDir: getFusionAgentDir(),
settingsManager, settingsManager,
systemPromptOverride: () => options.systemPrompt, systemPromptOverride: () => options.systemPrompt,

View File

@@ -10,10 +10,35 @@
*/ */
import { existsSync, readFileSync } from "node:fs"; 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 type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
import { piLog } from "./logger.js"; 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 ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
/** /**
@@ -161,7 +186,12 @@ function isExclusionPattern(pattern: string): boolean {
* - Requested names not matching any discovered skill (warning) * - Requested names not matching any discovered skill (warning)
*/ */
export function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult { 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 // Read project settings
const settings = readProjectSettings(projectRootDir); const settings = readProjectSettings(projectRootDir);