FN-7730: fix linked-worktree project-root resolution so board writes don't silently land in the wrong .fusion.db

Board mutations (fn_task_update, CEO override, direct SQL) issued from a pi-extension tool session could silently write into a task's throwaway, never-synced worktree-local .fusion/fusion.db instead of the true project root when git CLI resolution failed (missing git binary, Docker "dubious ownership" refusal, or a non-default settings.worktreesDir). This fixes root-cause resolution and adds regression coverage plus a docs writeup.

- getProjectRootFromGitLinkedWorktree now resolves a linked worktree's project root from git's own on-disk .git/commondir metadata via pure filesystem reads before falling back to the git rev-parse CLI, so writes no longer fall through to a local hydrated copy on git-invocation failure.
- Added getMainRepoRootFromGitFile and resolveCommonGitDirFromWorktreeGitFile helpers with FNXC:Storage comments documenting the FN-7730 root cause and fix rationale.
- Added packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts regression coverage for the write-path durability invariant.
- Extended packages/core/src/__tests__/pi-extensions.test.ts with additional resolution-path assertions.
- Documented the failure mode and fix in docs/storage.md ("Silent board-mutation write loss (FN-7730)").
- Added a patch changeset for @runfusion/fusion describing the user-facing fix.

Files changed:
 .changeset/fn-7730-worktree-project-root-resolution.md              |   7 ++
 docs/storage.md                                                     |  54 ++++++++++
 packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts | 98 ++++++++++++++++++
 packages/core/src/__tests__/pi-extensions.test.ts                   |  87 +++++++++++++++-
 packages/core/src/pi-extensions.ts                                  | 114 +++++++++++++++++++++
 5 files changed, 359 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7730
Fusion-Task-Lineage: 00753a2d-a934-42cf-8fde-0f9b8ad98142
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 09:23:03 -07:00
parent d44dbaab15
commit 5a9f354e9a
5 changed files with 359 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Board mutations from a tool session no longer silently land in the wrong project database.
category: fix
dev: FN-7730. `packages/core/src/pi-extensions.ts`'s `getProjectRootFromGitLinkedWorktree` now resolves a linked worktree's project root from git's own on-disk `.git`/`commondir` metadata (pure filesystem reads) before falling back to the `git rev-parse` CLI. Previously, for a non-standard `settings.worktreesDir` location combined with a failing `git` invocation (missing binary, Docker "dubious ownership" `safe.directory` refusal, etc.), resolution silently fell through to the task's own locally-hydrated `.fusion/fusion.db` instead of the true project root, so `fn_task_update` and other pi-extension write tools wrote to a throwaway, never-synced-back copy with no error surfaced. See docs/storage.md "Silent board-mutation write loss (FN-7730)" for the full root-cause writeup.

View File

@@ -667,3 +667,57 @@ Hydrated worktree DB: 4 tasks, 12 task_documents, 3 artifacts
A concrete recovered failure mode now covered by tests: when a worktree directory exists but its local `.fusion/` scratch state is missing, opening `DatabaseSync(<worktree>/.fusion/fusion.db)` can fail with `unable to open database file`. Hydration now performs destination bootstrap (`mkdir -p .fusion` + schema init) and retries the destination open once before degrading.
Failure policy remains strict non-blocking for genuinely unrecoverable cases: hydration warnings are logged, but worktree creation/execution continues. Examples that still intentionally degrade include source DB missing, destination write-permission failures, and irreconcilable schema/open errors after bootstrap retry. Canonical task data remains the root project TaskStore DB; if an agent needs non-hydrated rows immediately, `fn_task_show` remains the canonical fallback path.
## Silent board-mutation write loss (FN-7730)
**Symptom:** board mutations issued through `fn_task_update` (and other pi-extension write
tools invoked from an executor agent session) appeared to succeed, but the change was never
visible on the project-root `.fusion/fusion.db` that the engine and dashboard read from — with
no error surfaced on any write path.
**Root cause — a DB-path resolution mismatch, not a WAL/durability defect.** Investigation
(see task FN-7730's `research` document for the full trace) disproved the WAL `-shm`-unavailable
and uncheckpointed-WAL/`.recover` hypotheses on a normally-writable POSIX filesystem: both
failure conditions were reproduced directly and confirmed to throw a loud SQLite error rather
than silently drop data with the `node:sqlite` bindings and `sqlite3` CLI version this repo
requires. The real defect was in **project-root resolution** for pi-extension tool calls
(`packages/cli/src/extension.ts`'s `resolveProjectRoot(cwd)` → `getProjectRootFromWorktree`
in `packages/core/src/pi-extensions.ts`):
1. `getProjectRootFromWorktree` matches the standard `.worktrees/<id>` and
`.fusion/worktrees/<id>` path shapes via hardcoded regex. A project with a non-default
`settings.worktreesDir` (an arbitrary relative/absolute location — common in containerized
deployments) doesn't match either pattern.
2. The only remaining resolution path, `getProjectRootFromGitLinkedWorktree`, shelled out to
`git rev-parse --git-common-dir`/`--git-dir` via `spawnSync`. A failing `git` invocation —
missing binary in a minimal container image, Docker's "detected dubious ownership"
`safe.directory` refusal on a bind-mounted repo owned by a different UID, or any other
non-zero exit — returned `null` with **no thrown error** (by design, so a non-worktree `cwd`
doesn't explode), but with no non-git fallback.
3. `resolveProjectRoot`'s caller then fell back to a naive upward walk for the nearest ancestor
with a `.fusion` directory. Because the task's own worktree already has a locally-hydrated
`.fusion/fusion.db` (see "Per-Worktree DB Hydration" above), the walk matched **immediately**
at the worktree itself, never reaching the true project root.
4. Every subsequent write tool call for that agent session then silently landed in the
throwaway, one-way-hydrated worktree-local `fusion.db` — never synced back to the project
root — with zero error surfaced.
**Fix:** `getProjectRootFromGitLinkedWorktree` now resolves the linked-worktree relationship
directly from git's own on-disk metadata first — the worktree's `.git` file (`gitdir: <path>`)
plus its `commondir` sidecar, the same contract `git worktree add` writes — via plain
filesystem reads. This has **no subprocess dependency and is unaffected by the `git` binary's
availability or Docker UID/`safe.directory` permission checks**. The `git rev-parse` CLI path is
kept as a secondary fallback for any layout the filesystem parser can't resolve, preserving
prior behavior for those edge cases.
**Operator guidance:** if you suspect a write went to the wrong file, confirm which path a tool
session resolves by checking `.fusion/fusion.db` for a `mtime` change immediately after the
write in both the project root and (if applicable) the task's worktree directory. A
non-standard `settings.worktreesDir` combined with a broken `git` CLI in the execution
environment (missing binary, or `git config --global --add safe.directory '*'` not set for a
bind-mounted repo owned by a different UID) was the confirmed trigger; setting
`safe.directory` correctly or installing `git` resolves the underlying condition even without
this fix, and this fix additionally removes the dependency on that condition being addressed.
There is no data-recovery step needed once the resolver is fixed — no rows were corrupted, they
were written to (and remain recoverable from) the worktree-local `.fusion/fusion.db` if it still
exists on disk.

View File

@@ -0,0 +1,98 @@
/**
* FNXC:Storage 2026-07-09-00:00:
* FN-7730 symptom verification. The original report: board mutations applied
* through fn_task_update / CEO override / Release Manager closure / direct SQL
* "appear to complete" but are never durably visible to the engine or a
* subsequent read, with NO error surfaced. The root cause (see task FN-7730
* `research` document) is a project-root resolution mismatch: a pi-extension
* tool session running inside a non-standard-location linked worktree could
* silently resolve its TaskStore against the worktree's own locally-hydrated
* `.fusion/fusion.db` instead of the true project root, when the `git` CLI
* path-resolution fallback failed (missing binary / Docker dubious-ownership /
* NFS-overlay permission issues) with no thrown error.
*
* This test reproduces the full write -> second-connection-read shape end to
* end: it resolves the project root the SAME way `packages/cli/src/extension.ts`
* does (via `resolvePiExtensionProjectRoot`) from inside a non-standard-location
* worktree with the git CLI made to fail, writes a dependency-edit mutation and
* an archival (column move) mutation through a TaskStore opened at the resolved
* path, and asserts a FRESH second TaskStore instance opened directly against
* the true project root sees both mutations immediately — proving the write
* path and the engine's read path now agree on the same physical database file.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
const cleanupDirs: string[] = [];
afterEach(() => {
vi.doUnmock("node:child_process");
vi.resetModules();
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("write-path durability across a non-standard-location worktree (FN-7730)", () => {
it("mutations written via the resolved project root are visible to a fresh second connection at the true root", async () => {
vi.resetModules();
vi.doMock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
};
});
const root = mkdtempSync(join(tmpdir(), "fn-7730-durability-root-"));
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-durability-wt-"));
cleanupDirs.push(root, worktreeDir);
mkdirSync(join(root, ".fusion"), { recursive: true });
// Fabricate the on-disk linked-worktree metadata for a NON-standard
// location (outside `.worktrees`), matching a configured settings.worktreesDir.
const worktreeGitDir = join(root, ".git", "worktrees", "durability-wt");
mkdirSync(worktreeGitDir, { recursive: true });
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
// Simulate hydrateWorktreeDb's ensureWorktreeSchema having already given
// the worktree its own local `.fusion` directory (a decoy target the old
// naive fallback walk would have matched).
mkdirSync(join(worktreeDir, ".fusion"), { recursive: true });
const { resolvePiExtensionProjectRoot } = await import("../pi-extensions.js");
const { TaskStore } = await import("../store.js");
const resolvedRoot = resolvePiExtensionProjectRoot(worktreeDir);
expect(resolvedRoot).toBe(resolve(root));
expect(resolvedRoot).not.toBe(resolve(worktreeDir));
// Write path: a TaskStore opened exactly the way the CLI extension's
// getStore(cwd) would, at the resolved root.
const writerStore = new TaskStore(resolvedRoot);
await writerStore.init();
const depTarget = await writerStore.createTask({ description: "FN-7730 dependency target" });
const task = await writerStore.createTask({ description: "FN-7730 mutated task" });
await writerStore.updateTaskDependencies(task.id, { operation: "add", dependency: depTarget.id });
await writerStore.archiveTask(task.id);
await writerStore.close();
// Read path: a FRESH second TaskStore instance opened directly against the
// true project root, modeling the engine's own store.
const readerStore = new TaskStore(resolve(root));
await readerStore.init();
try {
const reReadTask = await readerStore.getTask(task.id, { includeDeleted: true });
expect(reReadTask).toBeTruthy();
expect(reReadTask?.dependencies ?? []).toContain(depTarget.id);
expect(reReadTask?.column).toBe("archived");
} finally {
await readerStore.close();
}
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../pi-extensions.js";
@@ -86,6 +86,91 @@ describe("getProjectRootFromWorktree", () => {
rmSync(root, { recursive: true, force: true });
}
});
// FNXC:Storage 2026-07-09-00:00: FN-7730 regression — a non-standard-location
// linked worktree (settings.worktreesDir configured off the default
// `.worktrees`, e.g. containerized deployments) must resolve to the true
// project root via git's own on-disk `.git`/`commondir` worktree metadata,
// WITHOUT depending on the `git` CLI succeeding. Previously the only
// non-standard-location resolution path was `git rev-parse`, which silently
// returns null (no thrown error) when the `git` binary is unavailable or
// fails (e.g. Docker "detected dubious ownership" safe.directory refusal on a
// bind-mounted repo) — collapsing resolution to a naive `.fusion` upward walk
// that stops at the worktree's OWN locally-hydrated `.fusion/fusion.db`
// instead of the real project root.
it("resolves a non-standard-location linked worktree via .git/commondir metadata even when the git CLI is unavailable", async () => {
vi.resetModules();
vi.doMock("node:child_process", () => ({
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
execSync: vi.fn(),
}));
const root = mkdtempSync(join(tmpdir(), "fn-7730-root-"));
// Deliberately NOT under a `.worktrees`/`.fusion/worktrees` path so the
// hardcoded regex fast paths in getProjectRootFromWorktree cannot match.
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-custom-wt-"));
try {
const expectedRoot = resolve(root);
mkdirSync(join(root, ".fusion"), { recursive: true });
// Fabricate the on-disk linked-worktree metadata git itself would write
// for `git worktree add <worktreeDir>` — no real git repo/binary needed.
const worktreeGitDir = join(root, ".git", "worktrees", "custom-wt");
mkdirSync(worktreeGitDir, { recursive: true });
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
const { getProjectRootFromWorktree: fresh } = await import("../pi-extensions.js");
expect(fresh(worktreeDir)).toBe(expectedRoot);
expect(fresh(join(worktreeDir, "subdir", "file.ts"))).toBe(expectedRoot);
} finally {
vi.doUnmock("node:child_process");
vi.resetModules();
rmSync(worktreeDir, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
}
});
// FNXC:Storage 2026-07-09-00:00: FN-7730 symptom verification — reproduces the
// exact silent-write-loss shape: a non-standard-location worktree that ALSO
// already has its own locally-hydrated `.fusion/fusion.db` (created by
// hydrateWorktreeDb for dependency-closure hydration) must still resolve to
// the TRUE project root, not the worktree's decoy `.fusion` — even with the
// git CLI unavailable. Before the fix this returned the worktree itself,
// silently redirecting every fn_task_update-style write into the throwaway
// hydration copy.
it("prefers the true project root over a worktree's own hydrated .fusion when git is unavailable (FN-7730)", async () => {
vi.resetModules();
vi.doMock("node:child_process", () => ({
spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "fatal: detected dubious ownership" })),
execSync: vi.fn(),
}));
const root = mkdtempSync(join(tmpdir(), "fn-7730-root2-"));
const worktreeDir = mkdtempSync(join(tmpdir(), "fn-7730-custom-wt2-"));
try {
const expectedRoot = resolve(root);
mkdirSync(join(root, ".fusion"), { recursive: true });
const worktreeGitDir = join(root, ".git", "worktrees", "custom-wt2");
mkdirSync(worktreeGitDir, { recursive: true });
writeFileSync(join(worktreeGitDir, "commondir"), "../..\n");
writeFileSync(join(worktreeDir, ".git"), `gitdir: ${worktreeGitDir}\n`);
// Simulate hydrateWorktreeDb's ensureWorktreeSchema: the worktree gets its
// own local `.fusion` directory as a hydration/decoy target.
mkdirSync(join(worktreeDir, ".fusion"), { recursive: true });
const { resolvePiExtensionProjectRoot: fresh } = await import("../pi-extensions.js");
expect(fresh(worktreeDir)).toBe(expectedRoot);
expect(fresh(worktreeDir)).not.toBe(resolve(worktreeDir));
} finally {
vi.doUnmock("node:child_process");
vi.resetModules();
rmSync(worktreeDir, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
}
});
});
describe("@fusion/core export surface", () => {

View File

@@ -86,7 +86,121 @@ export function getProjectRootFromWorktree(
return null;
}
/**
* FNXC:Storage 2026-07-09-00:00:
* FN-7730 root cause: board mutations issued from a pi-extension tool session
* (fn_task_update, etc.) resolve their TaskStore against `resolveProjectRoot(cwd)`
* in packages/cli/src/extension.ts, which calls getProjectRootFromWorktree(cwd)
* with NO worktreesDirCandidates. When a project configures a non-default
* `settings.worktreesDir` (packages/engine/src/worktree-paths.ts
* resolveWorktreesDir supports an arbitrary relative/absolute location — common in
* containerized deployments), neither hardcoded regex above matches, and the ONLY
* remaining path was getProjectRootFromGitLinkedWorktree(), which shelled out to
* `git rev-parse` via spawnSync. A failing git invocation (missing `git` binary in
* a minimal container, Docker's "detected dubious ownership" safe.directory
* refusal on a bind-mounted repo owned by a different UID, or any other non-zero
* exit) returned null with NO thrown error — by design, so a non-worktree cwd
* doesn't explode — but with no non-git fallback. resolveProjectRoot's caller then
* fell back to a naive upward walk for the first ancestor with a `.fusion` dir,
* which matched IMMEDIATELY at the task's own worktree (hydrateWorktreeDb's
* ensureWorktreeSchema already created a local, one-way-hydrated `.fusion/fusion.db`
* there for the dependency-closure copy). Every write tool call then silently
* landed in that throwaway worktree-local db — never synced back to the project
* root — with zero error surfaced. See task FN-7730 `research` document for the
* full investigation.
*
* Fix: resolve the linked-worktree relationship directly from git's own on-disk
* worktree metadata (the `.git` file + its `commondir` sidecar) FIRST. This is
* pure filesystem I/O — no subprocess, no git-binary dependency, unaffected by
* Docker UID/safe.directory restrictions. The `git rev-parse` CLI path is kept as
* a secondary fallback for any layout the fs parser can't resolve (e.g. detached
* gitdir configurations outside the standard worktree layout), preserving prior
* behavior for those edge cases.
*/
function getMainRepoRootFromGitFile(cwd: string): string | null {
let current = resolve(cwd);
const visited = new Set<string>();
while (!visited.has(current)) {
visited.add(current);
const gitPath = join(current, ".git");
let gitStat: ReturnType<typeof statSync> | undefined;
try {
gitStat = statSync(gitPath);
} catch {
gitStat = undefined;
}
if (gitStat?.isDirectory()) {
// A `.git` directory means `current` IS a normal repo root (or the main
// worktree), not itself a linked worktree — no linked-worktree parent
// to resolve at this level.
return null;
}
if (gitStat?.isFile()) {
const commonGitDir = resolveCommonGitDirFromWorktreeGitFile(gitPath, current);
if (!commonGitDir) {
return null;
}
const parentRoot = commonGitDir.endsWith(`${sep}.git`) ? dirname(commonGitDir) : commonGitDir;
return existsSync(join(parentRoot, ".fusion")) ? parentRoot : null;
}
const parent = resolve(current, "..");
if (parent === current) {
return null;
}
current = parent;
}
return null;
}
/**
* Parse a linked worktree's `.git` file (`gitdir: <path>`) and its sidecar
* `commondir` file (relative or absolute path to the shared `.git` directory) —
* the same on-disk contract `git worktree add` writes and `git rev-parse
* --git-common-dir` reads, but via plain file reads instead of a subprocess.
*/
function resolveCommonGitDirFromWorktreeGitFile(gitFilePath: string, gitFileDir: string): string | null {
let gitFileContent: string;
try {
gitFileContent = readFileSync(gitFilePath, "utf8");
} catch {
return null;
}
const match = gitFileContent.match(/^gitdir:\s*(.+)\s*$/m);
if (!match) {
return null;
}
const worktreeGitDir = resolve(gitFileDir, match[1]!.trim());
const commondirPath = join(worktreeGitDir, "commondir");
try {
const commondirContent = readFileSync(commondirPath, "utf8").trim();
if (commondirContent) {
return resolve(worktreeGitDir, commondirContent);
}
} catch {
// commondir sidecar missing/unreadable — fall through to the pattern-based
// derivation below.
}
// Standard worktree gitdir shape: `<repo>/.git/worktrees/<name>`. Strip the
// `worktrees/<name>` suffix to recover `<repo>/.git`.
const worktreesSuffix = /^(.*[\\/]\.git)[\\/]worktrees[\\/][^\\/]+[\\/]?$/;
const suffixMatch = worktreeGitDir.match(worktreesSuffix);
return suffixMatch ? suffixMatch[1]! : null;
}
function getProjectRootFromGitLinkedWorktree(cwd: string): string | null {
const fsResolvedRoot = getMainRepoRootFromGitFile(cwd);
if (fsResolvedRoot) {
return fsResolvedRoot;
}
const spawnSync = getSpawnSync();
if (!spawnSync) {
return null;