From 5a9f354e9a451949abadb8962590ebb66ad368b4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 9 Jul 2026 09:23:03 -0700 Subject: [PATCH] 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) --- ...n-7730-worktree-project-root-resolution.md | 7 ++ docs/storage.md | 54 +++++++++ ...i-extensions-write-path-durability.test.ts | 98 +++++++++++++++ .../core/src/__tests__/pi-extensions.test.ts | 87 ++++++++++++- packages/core/src/pi-extensions.ts | 114 ++++++++++++++++++ 5 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7730-worktree-project-root-resolution.md create mode 100644 packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts diff --git a/.changeset/fn-7730-worktree-project-root-resolution.md b/.changeset/fn-7730-worktree-project-root-resolution.md new file mode 100644 index 0000000000..153e6b26e8 --- /dev/null +++ b/.changeset/fn-7730-worktree-project-root-resolution.md @@ -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. diff --git a/docs/storage.md b/docs/storage.md index 8e65f1d18c..39028c1c95 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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(/.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/` and + `.fusion/worktrees/` 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: `) +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. diff --git a/packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts b/packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts new file mode 100644 index 0000000000..918ff37cbe --- /dev/null +++ b/packages/core/src/__tests__/pi-extensions-write-path-durability.test.ts @@ -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(); + 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(); + } + }); +}); diff --git a/packages/core/src/__tests__/pi-extensions.test.ts b/packages/core/src/__tests__/pi-extensions.test.ts index 142adb8298..33d332d455 100644 --- a/packages/core/src/__tests__/pi-extensions.test.ts +++ b/packages/core/src/__tests__/pi-extensions.test.ts @@ -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 ` — 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", () => { diff --git a/packages/core/src/pi-extensions.ts b/packages/core/src/pi-extensions.ts index 4cc6bfd8ce..7c141e051f 100644 --- a/packages/core/src/pi-extensions.ts +++ b/packages/core/src/pi-extensions.ts @@ -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(); + + while (!visited.has(current)) { + visited.add(current); + const gitPath = join(current, ".git"); + let gitStat: ReturnType | 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: `) 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: `/.git/worktrees/`. Strip the + // `worktrees/` suffix to recover `/.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;