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:
@@ -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" }],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user