From 926dda6fc710d69a822e519bc4bbce91a980824a Mon Sep 17 00:00:00 2001 From: Fusion Agent Date: Thu, 20 Aug 2026 20:23:52 +0000 Subject: [PATCH] FN-078: repair multi-repository workspace merge coordination Keep long-running workspace land operations live and enforce repository-local scope isolation. - Renew workspace land leases throughout dependency sync, AI merge, push, and intent resolution. - Abort stale land bodies before durable or remote commit points and fence lease-dependent writes. - Centralize repository File Scope resolution across squash, completion, and checkout guards. - Add regression coverage, architecture documentation, and a patch changeset. Files changed: .changeset/fn-078-workspace-lease-and-scope.md | 7 + docs/architecture.md | 3 +- .../executor-workspace-main-checkout-guard.test.ts | 13 ++ .../__tests__/executor-workspace-taskdone.test.ts | 21 +++ .../src/__tests__/workspace-merger-lease.test.ts | 195 ++++++++++++++++++++- .../src/executor/workspace-main-checkout-guard.ts | 4 +- .../src/executor/worktree-task-done-scope-leak.ts | 4 +- .../engine/src/merge/merger-ai-squash-gates.ts | 9 +- packages/engine/src/merge/merger-ai.ts | 55 +++++- packages/engine/src/worktree/workspace-paths.ts | 26 +++ 10 files changed, 321 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-078 Fusion-Task-Lineage: 8ddfa438-d6c0-4d3a-972a-3594bfc05ffb Co-authored-by: Fusion --- .../fn-078-workspace-lease-and-scope.md | 7 + docs/architecture.md | 3 +- ...utor-workspace-main-checkout-guard.test.ts | 13 ++ .../executor-workspace-taskdone.test.ts | 21 ++ .../__tests__/workspace-merger-lease.test.ts | 195 +++++++++++++++++- .../executor/workspace-main-checkout-guard.ts | 4 +- .../executor/worktree-task-done-scope-leak.ts | 4 +- .../src/merge/merger-ai-squash-gates.ts | 9 +- packages/engine/src/merge/merger-ai.ts | 55 ++++- .../engine/src/worktree/workspace-paths.ts | 26 +++ 10 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-078-workspace-lease-and-scope.md diff --git a/.changeset/fn-078-workspace-lease-and-scope.md b/.changeset/fn-078-workspace-lease-and-scope.md new file mode 100644 index 0000000000..0fcf62a9d4 --- /dev/null +++ b/.changeset/fn-078-workspace-lease-and-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep multi-repository merges live during long AI land operations. +category: fix +dev: Renews repository land leases and unifies workspace File Scope resolution across merge and completion guards. diff --git a/docs/architecture.md b/docs/architecture.md index d2a52aeec7..dde6073c80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -630,7 +630,8 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan. - **No root task worktree (FN-034):** when `.fusion/workspace.json` declares repositories, planning, execution, graph nodes, code/plan review, pause/resume, restart, and worktree-session recovery acquire or reuse only the declared repositories' worktrees. The workspace root and `/.worktrees/` are never task checkout or reviewer cwd. Historical singular `worktree`, `branch`, execution-base, and root-session routing metadata is cleared through an advisory-locked, project-scoped mutation that preserves every `workspaceWorktrees` entry and completed-step progress; recovery never deletes or recreates a root checkout. - **Workspace entry mutation (FN-9052):** every per-repository `workspaceWorktrees` update goes through `TaskStore.mergeWorkspaceWorktreeEntry`, which holds the per-task PostgreSQL advisory transaction lock and merges one key under the composite project/task scope. Per-repo callers must never wholesale-replace the map, because a concurrent sibling acquisition, landing, failure, or teardown mutation would otherwise lose its entry. - **Durable multi-node workspace coordination (FN-9059):** `project.workspace_coordination_leases`, serialized under a project/resource advisory transaction lock, owns sub-repository acquire exclusivity, per-repository land, workspace liveness/recovery guards, and merge-dispatch admission across engine processes. The owner triple `(taskId, nodeId, incarnationId)` makes only same-process re-entry idempotent; the same task from another node or process incarnation contends. Fresh/reclaimed claims receive a monotonically increasing fence token, while re-entry and renewal retain it. Git-writing tenancies publish once per tenancy—on acquire/reclaimed-expired, not re-entry—the per-repository `refs/fusion/workspace-lease/` or per-merge `refs/fusion/merge-dispatch/` fence ref. A workspace merge dispatches its deterministic per-merge pin to every target sub-repository remote before any workspace land begins; the workspace root need not be a git checkout and is never used as a substitute remote. A re-entrant claimant reuses its pin, except to repair a claim-to-publish gap with no pin; acquire-kind leases have no fence ref. A land push atomically CASes the target ref observed by the tenancy plus its repository fence and enclosing merge-dispatch fence refs in one push; a merge-only push CASes its target and dispatch pin. Thus a superseded owner is rejected even when the target tip has not moved. Lease-protected durable writes run through `withValidWorkspaceLeaseAsync`, and lease mutation is owner-and-fence conditional; no validate-then-act path is valid. - - **Workspace merge and land crash boundaries (FN-9059):** merge dispatch claims at body dispatch rather than enqueue; a losing claimant benignly drops. The body re-proves its fence at admission, the target-plus-fence atomic push, subsequent PR/branch/status effects, and terminal outcome persistence. Non-CASable effects are idempotent and follow the fenced push, so mid-merge expiry stops a superseded body at its next commit point; a pushed result whose outcome cannot persist is `merge-completed-unrecorded`, never a re-push. Before workspace land pushes, `project.workspace_land_intents` records the expected tip, intended SHA, remote/integration identity, and fence pin. The ordered protocol is intent → atomic push → lease-validated `landedSha` persist plus intent resolution. The node-independent reconciler lists pending intents project-wide, fetches the recorded remote, and resolves reachability on its integration ref. Only a live holder (its own/equal fence or a lower-fence predecessor) or recovery with no held unexpired lease can resolve an intent; stale fence matches, local tip equality, and local object availability are not authority. + - **Workspace merge and land crash boundaries (FN-9059):** merge dispatch claims at body dispatch rather than enqueue; a losing claimant benignly drops. Each live per-repository land lease renews before its five-minute TTL throughout dependency sync, AI merge/review, retry, push, and intent resolution. A refused or failed renewal aborts the land body before its next durable or remote commit point; renewal only maintains liveness, while the current owner/fence handle remains the write authority. The body re-proves its fence at admission, the target-plus-fence atomic push, subsequent PR/branch/status effects, and terminal outcome persistence. Non-CASable effects are idempotent and follow the fenced push, so mid-merge expiry stops a superseded body at its next commit point; a pushed result whose outcome cannot persist is `merge-completed-unrecorded`, never a re-push. Before workspace land pushes, `project.workspace_land_intents` records the expected tip, intended SHA, remote/integration identity, and fence pin. The ordered protocol is intent → atomic push → lease-validated `landedSha` persist plus intent resolution. The node-independent reconciler lists pending intents project-wide, fetches the recorded remote, and resolves reachability on its integration ref. Only a live holder (its own/equal fence or a lower-fence predecessor) or recovery with no held unexpired lease can resolve an intent; stale fence matches, local tip equality, and local object availability are not authority. + - **Workspace File Scope interpretation (FN-078):** squash gates, completion scope-leak checks, and main-checkout guards use one repo-local resolver. A matching repo-prefixed declaration has priority; only a declaration with no configured repository prefix can fall back as repo-local scope. If any declaration belongs to another configured repository, a repo with no matching prefix receives no fallback authority. This preserves whole-repo `repo` → `**`, normalized segment-aware nested keys, empty-scope behavior, and strict foreign-repository isolation. - **Main-checkout completion guard (FN-9058):** `fn_task_done` probes every configured sub-repo main checkout before any workspace worktree invariant, so `main_checkout_edit` takes precedence over `no_commits` and cannot be skipped by zero-acquire or no-commit eligibility. It uses the immutable first-execution anchor (never only the re-stamped per-attempt timestamp), blocks task-era status entries and bounded recent-HEAD evidence without `--since` or ancestry filtering, and emits `worktree:workspace-main-checkout-edit`. Unattributable pre-existing dirt, unavailable probes, and unresolved timing only warn: refusal has a bounded requeue budget and the guard is read-only. - **Task-pinned orphan recovery:** task-ID-pinned acquisition holds one path reservation across classification, preservation, quarantine reconciliation, and recreation. Inactive incomplete or unregistered directories are atomically moved to `/.fusion/recovery/worktrees`, or to `/.fusion-recovery/worktrees` after an `EXDEV` cross-filesystem refusal. Each actual recovery root retains the newest 10 recognized Fusion-generated entries; pruning is fail-soft and preserves unknown, symlinked, unreadable, or active paths. Worktree pool and self-healing scans exclude both `.ai-merge` and `.fusion-recovery` as internal container boundaries. diff --git a/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts index f3dbc25f35..239d896db0 100644 --- a/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-main-checkout-guard.test.ts @@ -62,6 +62,19 @@ describeIfGit("workspace main-checkout guard", () => { expect(result.violations.find((finding) => finding.repo === "repo-b")?.files).toContain("src/new.ts"); }); + it("treats repo-local File Scope as declared scope for a single workspace repository", async () => { + fixture = await createWorkspaceFixture(["repo-a"]); + const activeTask = task(); + const file = path.join(fixture.repoPath("repo-a"), "src", "local.ts"); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, "export {};\n"); + const changed = new Date(Date.parse(activeTask.firstExecutionAt!) + 10_000); + await import("node:fs/promises").then(({ utimes }) => utimes(file, changed, changed)); + + const result = await detectWorkspaceMainCheckoutWork({ rootDir: fixture.rootDir, settings }, activeTask, fixture.repos, ["src/**"]); + expect(result.violations).toContainEqual(expect.objectContaining({ repo: "repo-a", files: ["src/local.ts"], evidence: "declared-scope-change" })); + }); + it("uses firstExecutionAt instead of the later retry attempt anchor", async () => { fixture = await createWorkspaceFixture(["repo-a"]); const first = new Date(Date.now() + 1_000).toISOString(); diff --git a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts index fb48e1c39c..dd5bac654e 100644 --- a/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts +++ b/packages/engine/src/__tests__/executor-workspace-taskdone.test.ts @@ -25,6 +25,7 @@ import { TaskExecutor } from "../executor.js"; import { deriveRepoForPath, deriveRepoScopeSubset, + resolveRepoDeclaredScope, splitRepoScopedPath, UNSCOPED_REPO, } from "../worktree/workspace-paths.js"; @@ -115,6 +116,13 @@ describe("U2 — workspace-paths repo-prefix helper (unit)", () => { // repo-root scope entry maps to whole-repo ** expect(deriveRepoScopeSubset(["repo-a"], "repo-a")).toEqual(["**"]); }); + it("resolves prefixed, repo-local, and foreign-only scope without cross-repo fallback", () => { + const repoKeys = ["repo-a", "apps/web", "repo-b"]; + expect(resolveRepoDeclaredScope(["apps/web/src/**", "repo-a"], "apps/web", repoKeys)).toEqual({ scope: ["src/**"], source: "repo-subset" }); + expect(resolveRepoDeclaredScope(["src/**", "README.md"], "repo-a", repoKeys)).toEqual({ scope: ["src/**", "README.md"], source: "unprefixed-fallback" }); + expect(resolveRepoDeclaredScope(["repo-a/src/**", "src/**"], "repo-b", repoKeys)).toEqual({ scope: [], source: "foreign-repo-only" }); + expect(resolveRepoDeclaredScope(["repo-a", "repo-a/src/**"], "repo-a", repoKeys)).toEqual({ scope: ["**", "src/**"], source: "repo-subset" }); + }); }); describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { @@ -147,6 +155,19 @@ describeIfGit("U2 KTD4 — per-repo scope-leak guard in fn_task_done", () => { expect(result.message).toContain("OFFSCOPE.md"); }); + it("accepts repo-local scope for a one-repository workspace", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); + const store = createStore(["src/**"]); + const executor = workspaceExecutor(fx, store); + const task = makeTask({ + branch: BRANCH, + workspaceWorktrees: { "repo-a": { worktreePath: a.worktreePath, branch: BRANCH, baseCommitSha: a.baseCommitSha } }, + }); + + await expect((executor as any).evaluateTaskDoneScopeLeak(task, fx.rootDir, PROMPT, SETTINGS)).resolves.toEqual({ blocked: false }); + }); + it("all-clean: only in-scope changes in both repos → not blocked", async () => { fx = await createWorkspaceFixture(); const a = addRepoWorktree(fx, "repo-a", "src/a.ts"); diff --git a/packages/engine/src/__tests__/workspace-merger-lease.test.ts b/packages/engine/src/__tests__/workspace-merger-lease.test.ts index 57aa33b057..08137e195f 100644 --- a/packages/engine/src/__tests__/workspace-merger-lease.test.ts +++ b/packages/engine/src/__tests__/workspace-merger-lease.test.ts @@ -26,7 +26,7 @@ import { EventEmitter } from "node:events"; import { execSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import path from "node:path"; -import type { Task, TaskStore } from "@fusion/core"; +import type { Task, TaskStore, WorkspaceLeaseHandle } from "@fusion/core"; import { createSharedPgTaskStoreTestHarness, pgDescribe, type SharedPgTaskStoreHarness } from "../../../core/src/__test-utils__/pg-test-harness.js"; import { landSquash, landWorkspaceTask, WorkspaceMergeDispatchSupersededError, WorkspaceRepoLandBusyError } from "../merge/merger-ai.js"; import { ensureTenancyFenceRef, mergeDispatchFenceRef, WorkspaceFenceRefError } from "../merge/workspace-fence-ref.js"; @@ -232,6 +232,88 @@ pgDescribeIfGit("workspace land dispatch finalization (PostgreSQL)", () => { expect(fx.git("repo-b", `git ls-remote origin ${mergeDispatchFenceRef(taskId)}`)).toMatch(/^[0-9a-f]{40,64}\s/); }); + /* + FNXC:Workspace 2026-08-20-20:08: + A per-repository lander must lose every post-loss commit point when a durable successor + reclaims its expired repo lease. Exercise that ownership change through PostgreSQL rather + than a false-return mock, so the predecessor cannot write intent, push, persist landedSha, + or release the successor's handle. + */ + it("fences a real repository lander after a successor reclaims its durable repo lease", async () => { + const store = h.store(); + const taskId = "FN-078-PG-REPO-TAKEOVER"; + const repoRel = "repo-a"; + const repo = fx.repoPath(repoRel); + const remote = path.join(fx.rootDir, "origin.git"); + execSync(`git init --bare ${remote}`, { stdio: "pipe" }); + fx.git(repoRel, `git remote add origin ${remote}`); + fx.git(repoRel, "git push -u origin main"); + addRepoBranchWithEdit(fx, repoRel, taskId, "predecessor must be fenced\n"); + + await store.createTaskWithReservedId( + { description: "repository lease successor takeover", column: "in-review" }, + { taskId, applyDefaultWorkflowSteps: false }, + ); + await store.updateTask(taskId, { + branch: BRANCH, + workspaceWorktrees: { [repoRel]: { worktreePath: repo, branch: BRANCH } }, + } as Partial); + const task = (await store.getTask(taskId))!; + const tipBefore = fx.git(repoRel, "git rev-parse main"); + const realRenew = store.renewWorkspaceLease.bind(store); + const recordIntent = vi.spyOn(store, "recordWorkspaceLandIntent"); + const resolveIntent = vi.spyOn(store, "resolveWorkspaceLandIntent"); + const releaseLease = vi.spyOn(store, "releaseWorkspaceLease"); + let successorHandle: WorkspaceLeaseHandle | undefined; + let successorClaimed = false; + + vi.spyOn(store, "renewWorkspaceLease").mockImplementation(async (handle, leaseMs) => { + if (!successorClaimed) { + await h.adminSql().unsafe(`UPDATE project.workspace_coordination_leases + SET expires_at = '${new Date(Date.now() - 1_000).toISOString()}' + WHERE lease_key = '${handle.leaseKey}'`); + const successor = await store.acquireWorkspaceLease({ + leaseKey: handle.leaseKey, + kind: "land", + owner: { taskId: "FN-078-PG-SUCCESSOR", nodeId: "node-successor", incarnationId: "inc-successor" }, + leaseMs, + }); + expect(successor.outcome).toBe("reclaimed-expired"); + if (successor.outcome === "conflict") throw new Error("expected successor repo lease claim"); + successorHandle = successor.handle; + successorClaimed = true; + } + return realRenew(handle, leaseMs); + }); + + vi.useFakeTimers(); + await expect(landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + await vi.advanceTimersByTimeAsync(60_000); + }), + reviewAgent: approveReviewAgent, + })).rejects.toBeInstanceOf(WorkspaceRepoLandBusyError); + + expect(successorClaimed).toBe(true); + expect(successorHandle).toBeDefined(); + const successor = successorHandle!; + expect(recordIntent).not.toHaveBeenCalled(); + expect(resolveIntent).not.toHaveBeenCalled(); + expect(fx.git(repoRel, "git rev-parse main")).toBe(tipBefore); + const [heldLease] = await store.inspectWorkspaceLeases({ leaseKeys: [`repo:${repoRel}`] }); + expect(heldLease).toMatchObject({ + status: "held", + owner: successor.owner, + fenceToken: successor.fenceToken, + }); + expect(releaseLease).toHaveBeenCalledWith(expect.objectContaining({ + owner: expect.not.objectContaining(successor.owner), + fenceToken: expect.not.toBe(successor.fenceToken), + })); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + it("leaves a real pushed land unfinalized when a successor reclaims the dispatch fence", async () => { const store = h.store(); const taskId = "FN-9059-PG-DISPATCH"; @@ -307,6 +389,7 @@ pgDescribeIfGit("workspace land dispatch finalization (PostgreSQL)", () => { describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () => { let fx: WorkspaceFixture; afterEach(() => { + vi.useRealTimers(); fx?.cleanup(); activeSessionRegistry.clear(); vi.restoreAllMocks(); @@ -450,6 +533,116 @@ describeIfGit("landWorkspaceTask — per-repo land lease (Phase C U3, KTD4)", () expect(fx.git("repo-a", "git rev-parse main")).not.toBe(tipBefore); }); + /* + FNXC:Workspace 2026-08-20-19:59: + Repository land renewal is a liveness aid, not a substitute for the durable owner/fence check. + These real-Git tests force the periodic seam while a clean-room merge is active so an expired or + successor-reclaimed owner cannot create an intent, push, or terminal workspace mutation. + */ + it("renews a repository land lease through the former TTL before intent and landed persistence", async () => { + fx = await createWorkspaceFixture(["repo-a"]); + const repoRel = "repo-a"; + const repo = fx.repoPath(repoRel); + const remote = path.join(fx.rootDir, "origin.git"); + execSync(`git init --bare ${remote}`, { stdio: "pipe" }); + fx.git(repoRel, `git remote add origin ${remote}`); + fx.git(repoRel, "git push -u origin main"); + addRepoBranchWithEdit(fx, repoRel, "FN-078-RENEW", "renewed land\n"); + const task = makeTask("FN-078-RENEW", { [repoRel]: { worktreePath: repo, branch: BRANCH } }); + const store = createStore(task); + const initialHandle = { + leaseKey: `repo:${repoRel}`, kind: "land" as const, + owner: { taskId: task.id, nodeId: "node-a", incarnationId: "inc-a" }, fenceToken: 1n, + }; + let latestHandle = initialHandle; + const renewWorkspaceLease = vi.fn(async (handle: typeof initialHandle) => { + latestHandle = { ...handle }; + return latestHandle; + }); + const resolveWorkspaceLandIntent = vi.fn(async (input: any) => { + expect(input.handle).toBe(latestHandle); + await input.persistLandedSha(); + return { outcome: "resolved" }; + }); + Object.assign(store, { + acquireWorkspaceLease: vi.fn().mockResolvedValue({ outcome: "acquired", handle: initialHandle }), + renewWorkspaceLease, + recordWorkspaceLeaseFenceRef: vi.fn(async (input: any) => ({ ...input.handle, fenceRefName: input.fenceRefName, fenceRefSha: input.fenceRefSha })), + recordWorkspaceLandIntent: vi.fn().mockResolvedValue({ outcome: "valid" }), + resolveWorkspaceLandIntent, + releaseWorkspaceLease: vi.fn().mockResolvedValue(true), + }); + vi.useFakeTimers(); + const result = await landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // Advance beyond the incident's five-minute TTL while the land critical section is live. + await vi.advanceTimersByTimeAsync(5 * 60_000); + }), + reviewAgent: approveReviewAgent, + }); + + expect(result.repos[0]?.status).toBe("landed"); + expect(renewWorkspaceLease).toHaveBeenCalledTimes(5); + expect(resolveWorkspaceLandIntent).toHaveBeenCalledOnce(); + expect((store.releaseWorkspaceLease as any).mock.calls[0][0]).toBe(latestHandle); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + + it.each([ + ["returns no renewed handle", async () => undefined], + ["throws while renewing", async () => { throw new Error("renewal transport failure"); }], + ])("fences a stale repository lander when renewal %s", async (_description, renew) => { + fx = await createWorkspaceFixture(["repo-a"]); + const repoRel = "repo-a"; + const repo = fx.repoPath(repoRel); + const remote = path.join(fx.rootDir, "origin.git"); + execSync(`git init --bare ${remote}`, { stdio: "pipe" }); + fx.git(repoRel, `git remote add origin ${remote}`); + fx.git(repoRel, "git push -u origin main"); + addRepoBranchWithEdit(fx, repoRel, "FN-078-LOSS", "stale land\n"); + const tipBefore = fx.git(repoRel, "git rev-parse main"); + const task = makeTask("FN-078-LOSS", { [repoRel]: { worktreePath: repo, branch: BRANCH } }); + const store = createStore(task); + const predecessorHandle = { + leaseKey: `repo:${repoRel}`, kind: "land" as const, + owner: { taskId: task.id, nodeId: "node-a", incarnationId: "inc-a" }, fenceToken: 1n, + }; + const successorHandle = { ...predecessorHandle, owner: { taskId: "FN-078-SUCCESSOR", nodeId: "node-b", incarnationId: "inc-b" }, fenceToken: 2n }; + const recordWorkspaceLandIntent = vi.fn().mockResolvedValue({ outcome: "valid" }); + Object.assign(store, { + acquireWorkspaceLease: vi.fn().mockResolvedValue({ outcome: "acquired", handle: predecessorHandle }), + renewWorkspaceLease: vi.fn(renew), + recordWorkspaceLeaseFenceRef: vi.fn(async (input: any) => ({ ...input.handle, fenceRefName: input.fenceRefName, fenceRefSha: input.fenceRefSha })), + recordWorkspaceLandIntent, + resolveWorkspaceLandIntent: vi.fn(), + releaseWorkspaceLease: vi.fn().mockResolvedValue(true), + }); + vi.useFakeTimers(); + await expect(landWorkspaceTask(store, task, fx.rootDir, {}, { + mergeAgent: squashMergeAgent(BRANCH, async () => { + // The renewal refusal models a successor reclaiming the expired durable handle. + await vi.advanceTimersByTimeAsync(60_000); + }), + reviewAgent: approveReviewAgent, + })).rejects.toBeInstanceOf(WorkspaceRepoLandBusyError); + + expect(recordWorkspaceLandIntent).not.toHaveBeenCalled(); + expect((store.resolveWorkspaceLandIntent as any)).not.toHaveBeenCalled(); + expect(fx.git(repoRel, "git rev-parse main")).toBe(tipBefore); + // Release carries the predecessor identity/fence and can never release the successor's claim. + expect((store.releaseWorkspaceLease as any).mock.calls[0][0]).toMatchObject({ + owner: predecessorHandle.owner, + fenceToken: predecessorHandle.fenceToken, + }); + expect((store.releaseWorkspaceLease as any).mock.calls[0][0]).not.toMatchObject({ + owner: successorHandle.owner, + fenceToken: successorHandle.fenceToken, + }); + expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + }); + it("concurrency: two tasks landing the SAME sub-repo serialize — one acquires the land lease, the other fast-fails (no interleaved update-ref)", async () => { fx = await createWorkspaceFixture(["repo-a"]); addRepoBranchWithEdit(fx, "repo-a", "FN-3001", "a feature\n"); diff --git a/packages/engine/src/executor/workspace-main-checkout-guard.ts b/packages/engine/src/executor/workspace-main-checkout-guard.ts index 9479fe7264..f60930a909 100644 --- a/packages/engine/src/executor/workspace-main-checkout-guard.ts +++ b/packages/engine/src/executor/workspace-main-checkout-guard.ts @@ -9,7 +9,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import type { Settings, Task } from "@fusion/core"; -import { deriveRepoScopeSubset, normalizeRepoRelPath } from "../worktree/workspace-paths.js"; +import { normalizeRepoRelPath, resolveRepoDeclaredScope } from "../worktree/workspace-paths.js"; import { resolveWorktreesDir } from "../worktree/worktree-paths.js"; import { isAlwaysAllowedScopeLeakPath, workflowPathMatchesDeclaredScope } from "./workflow-feedback-paths.js"; @@ -92,7 +92,7 @@ export async function detectWorkspaceMainCheckoutWork( continue; } } catch { skipped.push(repo); continue; } - const repoScope = deriveRepoScopeSubset(declaredScope, repo); + const repoScope = resolveRepoDeclaredScope(declaredScope, repo, repoKeys).scope; const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings, { workspaceRootDir: deps.rootDir, repoRelPath: repo })); const excluded = (file: string) => { const absolute = path.resolve(checkout, file); diff --git a/packages/engine/src/executor/worktree-task-done-scope-leak.ts b/packages/engine/src/executor/worktree-task-done-scope-leak.ts index 914c909647..7ab4625e9b 100644 --- a/packages/engine/src/executor/worktree-task-done-scope-leak.ts +++ b/packages/engine/src/executor/worktree-task-done-scope-leak.ts @@ -4,7 +4,7 @@ * fn_task_done File Scope leak guard (workspace multi-repo + singular checkout). */ import type { Settings, Task, TaskStore } from "@fusion/core"; -import { deriveRepoScopeSubset } from "../worktree/workspace-paths.js"; +import { resolveRepoDeclaredScope } from "../worktree/workspace-paths.js"; import { executorLog } from "../logger.js"; import type { EngineRunContext, RunAuditor } from "../util/run-audit.js"; import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js"; @@ -114,7 +114,7 @@ export async function evaluateTaskDoneScopeLeak( const repoTouched = [...new Set([...repoUncommitted, ...repoCommitted])]; // Repo-LOCAL declared-scope subset for THIS repo (prefix stripped). Same filter as the // non-workspace branch below — one surface. - const repoScopeSubset = deriveRepoScopeSubset(declaredScope, repoRel); + const repoScopeSubset = resolveRepoDeclaredScope(declaredScope, repoRel, repoKeys).scope; const repoOffScope = repoTouched .filter((filePath) => !workflowPathMatchesDeclaredScope(filePath, repoScopeSubset)) .filter((filePath) => !isAlwaysAllowedScopeLeakPath(filePath)) diff --git a/packages/engine/src/merge/merger-ai-squash-gates.ts b/packages/engine/src/merge/merger-ai-squash-gates.ts index 83fd49993b..e2548d8270 100644 --- a/packages/engine/src/merge/merger-ai-squash-gates.ts +++ b/packages/engine/src/merge/merger-ai-squash-gates.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { Task, TaskStore } from "@fusion/core"; -import { deriveRepoForPath, deriveRepoScopeSubset, UNSCOPED_REPO } from "../worktree/workspace-paths.js"; +import { resolveRepoDeclaredScope } from "../worktree/workspace-paths.js"; import { createCommitRangeFilesReader, enforceSquashFileScopeInvariant, FileScopeViolationError } from "./merger-file-scope.js"; import type { RunAuditor } from "../util/run-audit.js"; @@ -10,13 +10,10 @@ const execFileAsync = promisify(execFile); export function resolveRepoDeclaredScopeTransform({ repoRel, repoKeys }: { repoRel: string; repoKeys: readonly string[] }) { return { transform(scope: string[]): string[] { - const subset = deriveRepoScopeSubset(scope, repoRel); - if (subset.length) return subset; - return scope.some((entry) => deriveRepoForPath(entry, repoKeys) !== UNSCOPED_REPO) ? [] : scope; + return resolveRepoDeclaredScope(scope, repoRel, repoKeys).scope; }, describe(scope: string[]): "repo-subset" | "unprefixed-fallback" | "foreign-repo-only" { - if (deriveRepoScopeSubset(scope, repoRel).length) return "repo-subset"; - return scope.some((entry) => deriveRepoForPath(entry, repoKeys) !== UNSCOPED_REPO) ? "foreign-repo-only" : "unprefixed-fallback"; + return resolveRepoDeclaredScope(scope, repoRel, repoKeys).source; }, }; } diff --git a/packages/engine/src/merge/merger-ai.ts b/packages/engine/src/merge/merger-ai.ts index 870c960145..f8d0d61255 100644 --- a/packages/engine/src/merge/merger-ai.ts +++ b/packages/engine/src/merge/merger-ai.ts @@ -901,7 +901,7 @@ export interface LandRepoContext { */ noCommitsExpected?: boolean; /** FNXC:Workspace 2026-08-15-08:36: Present only for workspace sub-repos; it fences the durable intent and remote ref advance. */ - workspaceLand?: { handle: WorkspaceLeaseHandle; repoRelPath: string; remote: string }; + workspaceLand?: { getHandle: () => WorkspaceLeaseHandle; repoRelPath: string; remote: string; assertLive: () => void }; /** FNXC:WorkspaceMergeDispatch 2026-08-15-22:55: Task-level pin that fences every merge-body ref advance. */ workspaceDispatchFence?: { fenceRefName: string; fenceRefSha: string }; store: TaskStore; @@ -1168,7 +1168,9 @@ export async function landOneRepo( // move. A later reconciler can then settle an interrupted remote advance without re-squashing. let workspaceFence: { remote: string; fenceRefName: string; fenceRefSha: string } | undefined; if (ctx.workspaceLand) { - const { handle, repoRelPath, remote } = ctx.workspaceLand; + ctx.workspaceLand.assertLive(); + const { repoRelPath, remote } = ctx.workspaceLand; + const handle = ctx.workspaceLand.getHandle(); if (!handle.fenceRefName || !handle.fenceRefSha) { throw new Error(`Workspace land lease ${handle.leaseKey} is missing its fence pin`); } @@ -1186,6 +1188,7 @@ export async function landOneRepo( // 4 + 5. Land the squash on the target branch and sync the user's // checkout (AI reconciles a conflicting restore). + ctx.workspaceLand?.assertLive(); await setStatus("landing"); const landed = await landSquash({ projectRootDir: repoRootDir, mergeRoot, integrationBranch, tipSha, squashSha, taskId, audit, @@ -2312,11 +2315,45 @@ export async function landWorkspaceTask( throw error; } + /* + FNXC:Workspace 2026-08-20-19:45: + A repository land can legitimately outlive the five-minute durable TTL while dependency sync and + AI review run. Renew its current owner/fence handle during the complete land body, but keep every + intent and push fenced by the durable handle: a failed renewal aborts before the next commit point. + */ + let leaseLost = false; + const leaseAbort = new AbortController(); + let renewalInFlight: Promise | undefined; + const renewWorkspaceLease = (store as Partial).renewWorkspaceLease; + const renewLease = async (): Promise => { + if (!durableLandLease || typeof renewWorkspaceLease !== "function" || leaseLost) return; + try { + const renewed = await renewWorkspaceLease.call(store, durableLandLease, 5 * 60_000); + if (!renewed) throw new Error("Workspace lease renewal was refused"); + durableLandLease = renewed; + } catch { + leaseLost = true; + leaseAbort.abort("workspace-repo-land-lease-lost"); + } + }; + const renewalTimer = durableLandLease && typeof renewWorkspaceLease === "function" + ? setInterval(() => { + if (!renewalInFlight) { + renewalInFlight = renewLease().finally(() => { renewalInFlight = undefined; }); + } + }, 60_000) + : undefined; + renewalTimer?.unref?.(); + const landSignal = options.signal ? AbortSignal.any([options.signal, leaseAbort.signal]) : leaseAbort.signal; + const assertLeaseLive = () => { + if (leaseLost || leaseAbort.signal.aborted) throw new WorkspaceRepoLandBusyError(repoRel, "durable-workspace-lease", taskId); + }; + try { const landResult = await landOneRepo(repoRootDir, entry.branch, integrationBranch, { taskId, settings, audit, log, setStatus, maxPasses, mergeAgent, reviewAgent, stashResolveAgent, - includeTaskId, trailers, taskTitle, signal: options.signal, + includeTaskId, trailers, taskTitle, signal: landSignal, allowDirtyLocalCheckoutSync, // FNXC:Workspace 2026-06-24-23:50: one sub-repo's dependency-sync failure must not block // landing the others — degrade verification for that repo, still land the git squash. @@ -2325,10 +2362,11 @@ export async function landWorkspaceTask( noCommitsExpected: task.noCommitsExpected === true, repoRel, repoKeys, - ...(durableLandLease ? { workspaceLand: { handle: durableLandLease, repoRelPath: repoRel, remote: "origin" } } : {}), + ...(durableLandLease ? { workspaceLand: { getHandle: () => { assertLeaseLive(); return durableLandLease!; }, repoRelPath: repoRel, remote: "origin", assertLive: assertLeaseLive } } : {}), ...(workspaceDispatchFence ? { workspaceDispatchFence } : {}), store, }); + assertLeaseLive(); if (landResult.outcome === "landed") { /* FNXC:Workspace 2026-06-22-04:10 (Phase C review A1 — persist-after-advance is a HARD failure): @@ -2341,6 +2379,7 @@ export async function landWorkspaceTask( */ try { if (durableLandLease) { + assertLeaseLive(); const resolved = await store.resolveWorkspaceLandIntent({ handle: durableLandLease, taskId, @@ -2379,6 +2418,12 @@ export async function landWorkspaceTask( repos.push({ repo: repoRel, repoRootDir, integrationBranch, branch: entry.branch, status: "empty" }); } } catch (err: unknown) { + /* + FNXC:Workspace 2026-08-20-19:58: + A lost repository tenancy is not a repository land failure: writing landFailure here would + let a stale owner mutate workspace state after its durable lease was revoked. + */ + if (leaseLost) throw new WorkspaceRepoLandBusyError(repoRel, "durable-workspace-lease", taskId); if (isMergeAbortedError(err)) throw err; // A WorkspacePartialLandError from the persist-failure window above must PROPAGATE // (the engine parks/retries). The outer try/finally below resets status first (A3). @@ -2399,6 +2444,8 @@ export async function landWorkspaceTask( // loop and the landed predicate above skips them (only the failed repo retries). break; } finally { + if (renewalTimer) clearInterval(renewalTimer); + await renewalInFlight?.catch(() => undefined); /* FNXC:Workspace 2026-06-22-02:10 (Phase C U3, KTD4): Release the land lease — on land SUCCESS or land FAILURE — but ONLY when WE hold diff --git a/packages/engine/src/worktree/workspace-paths.ts b/packages/engine/src/worktree/workspace-paths.ts index 299dbfe357..64c958deb4 100644 --- a/packages/engine/src/worktree/workspace-paths.ts +++ b/packages/engine/src/worktree/workspace-paths.ts @@ -113,3 +113,29 @@ export function deriveRepoScopeSubset(declaredScope: readonly string[], repoRel: } return subset; } + +export type RepoDeclaredScopeResolution = { + scope: string[]; + source: "repo-subset" | "unprefixed-fallback" | "foreign-repo-only"; +}; + +/** + * Resolve File Scope exactly as one workspace repository sees it. + * + * FNXC:Workspace 2026-08-20-19:45: + * Workspace merge, completion, and main-checkout guards must interpret task scope identically. + * A matching repo prefix wins; only declarations with no configured repository owner may fall back + * to repo-local patterns. A sibling-prefixed declaration is never authority for this repository. + */ +export function resolveRepoDeclaredScope( + declaredScope: readonly string[], + repoRel: string, + repoKeys: readonly string[], +): RepoDeclaredScopeResolution { + const subset = deriveRepoScopeSubset(declaredScope, repoRel); + if (subset.length > 0) return { scope: subset, source: "repo-subset" }; + const hasRepoOwnedEntry = declaredScope.some((entry) => deriveRepoForPath(entry, repoKeys) !== UNSCOPED_REPO); + return hasRepoOwnedEntry + ? { scope: [], source: "foreign-repo-only" } + : { scope: [...declaredScope], source: "unprefixed-fallback" }; +}