From 7763ba5fab7bd11ade1c97cfbb38e0fbbad544b6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 11 Jun 2026 20:27:29 -0700 Subject: [PATCH] FN-6278: preflight reusable merge worktree cwd Stabilize reusable merge worktree cwd resolution before merge-runner git spawns. - Add a reusable merge integration root preflight that detects missing, empty, or de-registered task worktrees before using them as cwd. - Reacquire/repair unusable reuse-task-worktree roots before merge execution and reject unusable roots during handoff. - Cover vanished and unregistered worktree recovery paths plus focused preflight unit cases. - Document the FN-6278 preflight behavior alongside transient merge recovery and merge integration worktree contracts. Files changed: docs/architecture.md | 4 +- .../__tests__/merger-integration-worktree.test.ts | 82 ++++++++ .../merge-runner-spawn-enoent-prevention.test.ts | 233 +++++++++++++++++++++ packages/engine/src/merger-integration-worktree.ts | 71 +++++++ packages/engine/src/merger.ts | 19 +- 5 files changed, 396 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-6278 Fusion-Task-Lineage: ceecf70f-9e2d-4ffe-8c6f-a9a5b673d72d --- docs/architecture.md | 4 +- .../merger-integration-worktree.test.ts | 82 ++++++ ...rge-runner-spawn-enoent-prevention.test.ts | 233 ++++++++++++++++++ .../engine/src/merger-integration-worktree.ts | 71 ++++++ packages/engine/src/merger.ts | 19 +- 5 files changed, 396 insertions(+), 13 deletions(-) create mode 100644 packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 1bad8ee65d..67e475b3e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -681,7 +681,7 @@ When stuck-kill retries are exhausted, `checkStuckBudget()` marks the task `stat - `recoverMissingWorktreeReviewFailures()` is a narrow failed-review recovery: only `status: "failed"` `in-review` tasks with the explicit session-start signature `Refusing to start coding agent in missing worktree:` (from `assertValidWorktreeSession()`) are requeued. Recovery clears stale session metadata (`worktree`, `branch`, `sessionFile`, transient failure state), preserves valid step progress/retry counters, logs the auto-recovery reason, and moves the task back to `todo` for a clean retry. - `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`. - `recoverAlreadyMergedReviewTasks()` auto-finalizes retry-exhausted `in-review` tasks when self-healing can prove their work already landed on the merge target. On this landed-content path it clears soft blockers (`paused`, stale `status: "failed"`, and residual `error`) before moving to `done`; true hard blockers (for example incomplete steps, awaiting-user-review, or failed pre-merge workflow steps) still park the task in stable `in-review/failed` state with a blocker error instead of entering an auto-finalize loop. - - `recoverTransientMergeFailures()` handles retry-exhausted `in-review` merge failures only when `classifyTransientMergeError()` returns a bounded transient class: `lease-handoff-target-not-queued`, `spurious-concurrent-advance-same-sha`, or `process-spawn-failure` (`spawn ENOTDIR` / `spawn … ENOENT`). Recovery resets `mergeRetries`, clears transient `status`/`error`, increments `mergeDetails.transientRecoveryCount`, and requeues auto-merge. The budget stays capped by `MAX_TRANSIENT_MERGE_RECOVERIES`; exhausted tasks remain parked with the `merger:transient-failure-budget-exhausted` audit path so real structural failures cannot loop forever. + - `recoverTransientMergeFailures()` handles retry-exhausted `in-review` merge failures only when `classifyTransientMergeError()` returns a bounded transient class: `lease-handoff-target-not-queued`, `spurious-concurrent-advance-same-sha`, or `process-spawn-failure` (`spawn ENOTDIR` / `spawn … ENOENT`). Recovery resets `mergeRetries`, clears transient `status`/`error`, increments `mergeDetails.transientRecoveryCount`, and requeues auto-merge. The budget stays capped by `MAX_TRANSIENT_MERGE_RECOVERIES`; exhausted tasks remain parked with the `merger:transient-failure-budget-exhausted` audit path so real structural failures cannot loop forever. FN-6278 makes this recovery mostly after-the-fact insurance for cwd spawn faults: the merge runner now preflights reuse integration roots and repairs/reacquires missing or de-registered task worktrees before the first git spawn, so a stale `task.worktree` should not consume the transient recovery budget by repeatedly producing `spawn git ENOENT`. - `reconcileTaskWorktreeMetadata()` (FN-4962) reconciles stale `task.worktree`/`task.branch` rows against authoritative `git worktree list --porcelain` branch mappings during startup recovery, periodic maintenance, and completion fan-out. The stage must run before `reclaim-stale-active-branches`: stale rows rebound to live `fusion/` worktrees emit `task:auto-recover-worktree-metadata-rebound`; stale rows with no live branch mapping are nulled (`worktree=null`, `branch=null`, `baseCommitSha` unchanged) and emit `task:auto-recover-worktree-metadata-cleared`. - `recoverInProgressLimbo()` (FN-5219) is the safety net for stranded executor rows: reset/requeue paths must never leave a task in `in-progress` without a runnable execution context. After metadata reconcile, stale `in-progress` tasks with null branch, missing/cleared worktree metadata, no live executor claim, and all-pending steps are audited and moved back to `todo`. @@ -1618,7 +1618,7 @@ The GitHub tracking state listener now attaches to every registered project stor - Setting type: `MergeStrategy = "direct" | "pull-request"` (`types.ts`) - `aiMergeTask()` in `merger.ts` performs merge flow - FN-5782 wires branch-group routing into merge target resolution: tasks with `branchContext.assignmentMode === "shared"` and a resolvable `branch_groups` row merge onto `branch_groups.branchName` (`mergeTarget.source = "branch-group-integration"`) instead of the project default branch; ungrouped and `per-task-derived` tasks keep the existing direct-to-default path unchanged. Merge emits `merge:branch-group-routed` audit telemetry for routed members. FN-5846 extends the same contract to deterministic/self-healing finalize paths (`recoverAlreadyMergedReviewTasks`, interrupted/deadlock/misbound finalizers, and the `mergeConfirmed` fast path): a resolvable shared member is re-routed to the group branch before reachability checks, `mergeTargetSource`/`mergeTargetBranch` are stamped by the finalizer, `recordBranchGroupMemberLanded` is called, and a defensive audit event is emitted if a path would otherwise evaluate the member against the project default branch. FN-5788 adds a callable promotion-decision hook (`evaluateBranchGroupPromotion`) and `merge:branch-group-promotion-gated` telemetry; FN-5830 lands the completion gate + promotion machinery via `evaluateBranchGroupCompletion` and idempotent `promoteBranchGroup` (single shared→default merge/PR with finalized status and PR tracking persistence). -- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`. +- FN-5279 adds `mergeIntegrationWorktree` for auto-merge only. Default `reuse-task-worktree` hands merger ownership from executor to the merger inside the task worktree after five gates (clean tree, expected branch, no live executor session, canonical branch/worktree binding, lease handoff). Refusals emit `merge:reuse-handoff-refused`, leave the task in `in-review`, and do **not** silently fall back to project-root merge mode. `cwd-integration-branch` is the explicit opt-in project-root path; `cwd-main` is a deprecated alias normalized to `cwd-integration-branch`. Integration-branch defaults across merger and self-healing flows are resolved dynamically via `resolveIntegrationBranch(rootDir, settings)` (`integrationBranch` → `baseBranch` → `origin/HEAD` → `main`). When `worktrunk.enabled=true`, worktrunk-managed merge/worktree behavior still wins and the handoff path emits a defer event instead of taking over. FN-5363 tightens this path: `acquireMergeQueueLease({ targetTaskId })` is strict (no queue-head fallback), merge queue rows are enqueue/lease-gated to `in-review` tasks, and stale non-review rows are auto-cleaned (including on `in-review` column exit when leases are absent or expired). FN-5353 extends the same contract: merger self-enqueues the target before strict target leasing, null target leases are surfaced as `merge:reuse-handoff-refused` with `reason: "target-not-queued"`, `acquireReuseHandoff` hard-refuses `reason: "worktree-equals-project-root"`, and `resolveMergeIntegrationRoot` returns a missing-worktree sentinel (`rootDir: ""`) so reacquire executes before any reuse gate can misroute against project root. FN-6278 adds a stable cwd preflight before root-derived git spawns: in `reuse-task-worktree` mode, an empty, missing, incomplete, or de-registered `task.worktree` is repaired/reacquired before the first spawn, while `cwd-integration-branch` remains a no-op project-root path. FN-5351 adds a production verification trail for integration-branch invariants: `merge:integration-worktree-state`, `merge:cwd-integration-fallback-refused`, and `merge:integration-ref-advance`. - `merger.ts` also exposes a test-only `__test__` helper object for internal merger unit/integration coverage (for example autostash orphan cleanup behavior) - Supports workflow-step execution after merge (post-merge phase) - Deterministic verification now runs a bootstrap preamble (`node scripts/ensure-test-artifacts.mjs`) before configured `testCommand`/`buildCommand`, then self-heals Vite `Failed to resolve entry for package "@fusion/..."` workspace-entry faults by rebuilding the missing package once and retrying the failed command. If that retry still reports the same missing-entry fault, merger raises a typed environment fault and `ProjectEngine` leaves the task in-review (no verificationFailureCount increment or in-progress bounce) so the next recovery sweep can retry after other runs rebuild artifacts. diff --git a/packages/engine/src/__tests__/merger-integration-worktree.test.ts b/packages/engine/src/__tests__/merger-integration-worktree.test.ts index 079f9837e1..91e5d923e4 100644 --- a/packages/engine/src/__tests__/merger-integration-worktree.test.ts +++ b/packages/engine/src/__tests__/merger-integration-worktree.test.ts @@ -12,6 +12,7 @@ import { activeSessionRegistry, executingTaskLock } from "../active-session-regi import * as branchAutocorrect from "../branch-autocorrect.js"; import { acquireReuseHandoff, + ensureUsableMergeIntegrationRoot, MergeHandoffRefusedError, probeIntegrationWorktreeState, releaseReuseHandoff, @@ -93,6 +94,87 @@ describe("resolveMergeIntegrationRoot", () => { }); }); +describe("ensureUsableMergeIntegrationRoot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("treats an empty reuse worktree sentinel as missing without probing git", async () => { + const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree"); + + await expect( + ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }), + ).resolves.toMatchObject({ + ok: false, + checked: "reuse-task-worktree", + reason: "missing-task-worktree", + }); + expect(classifySpy).not.toHaveBeenCalled(); + }); + + it("classifies absent reuse worktrees before they can be used as cwd", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ + ok: false, + classification: "missing", + reason: "worktree directory does not exist", + }); + + const result = await ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/dead-task-worktree", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }); + + expect(result).toMatchObject({ + ok: false, + checked: "reuse-task-worktree", + reason: "unusable-task-worktree", + classification: { classification: "missing" }, + }); + expect(worktreePool.classifyTaskWorktree).toHaveBeenCalledWith("/tmp/project-root", "/tmp/dead-task-worktree"); + }); + + it("classifies de-registered reuse worktrees before handoff", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ + ok: false, + classification: "unregistered", + reason: "not registered in git worktree list", + }); + + await expect( + ensureUsableMergeIntegrationRoot({ + resolution: { mode: "reuse-task-worktree", rootDir: "/tmp/unregistered-task-worktree", branchName: "fusion/fn-5279" }, + projectRoot: "/tmp/project-root", + }), + ).resolves.toMatchObject({ + ok: false, + reason: "unusable-task-worktree", + classification: { classification: "unregistered" }, + }); + }); + + it("leaves healthy reuse roots unchanged", async () => { + vi.spyOn(worktreePool, "classifyTaskWorktree").mockResolvedValue({ ok: true }); + const resolution = { mode: "reuse-task-worktree" as const, rootDir: "/tmp/task-worktree", branchName: "fusion/fn-5279" }; + + await expect( + ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }), + ).resolves.toEqual({ ok: true, resolution, checked: "reuse-task-worktree" }); + }); + + it("does not stat or git-probe projectRoot/default mode", async () => { + const classifySpy = vi.spyOn(worktreePool, "classifyTaskWorktree"); + const resolution = { mode: "cwd-integration-branch" as const, rootDir: "/tmp/project-root", branchName: "fusion/fn-5279" }; + + await expect( + ensureUsableMergeIntegrationRoot({ resolution, projectRoot: "/tmp/project-root" }), + ).resolves.toEqual({ ok: true, resolution, checked: false }); + expect(classifySpy).not.toHaveBeenCalled(); + }); +}); + describe("resolveIntegrationRemote", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts b/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts new file mode 100644 index 0000000000..51de441fbe --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/merge-runner-spawn-enoent-prevention.test.ts @@ -0,0 +1,233 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../pi.js", () => ({ + createFnAgent: vi.fn(async () => ({ + prompt: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + })), + describeModel: vi.fn(() => "mock-provider/mock-model"), + promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise }, prompt: string) => { + await session.prompt(prompt); + }), + compactSessionContext: vi.fn(), +})); + +import type { Settings } from "@fusion/core"; +import { activeSessionRegistry, executingTaskLock } from "../../active-session-registry.js"; +import { aiMergeTask } from "../../merger.js"; +import { git, hasGit, makeReliabilityFixture } from "./_helpers.js"; + +const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const; + +async function setupReuseMergeFixture(opts: { + taskId: string; + fileName: string; + fileContent: string; + skipEnqueue?: boolean; +}): Promise<{ + rootDir: string; + store: Awaited>["store"]; + taskId: string; + branch: string; + fixture: Awaited>; + worktreeRoot: string; + worktreePath: string; +}> { + const fixture = await makeReliabilityFixture({ + taskId: opts.taskId, + settings: { + baseBranch: "master", + mergeIntegrationWorktree: "reuse-task-worktree", + worktreeRebaseRemote: "origin", + } as Partial, + }); + const { rootDir, store, task } = fixture; + const actualTask = await store.getTask(task.id); + const branch = `fusion/${actualTask!.id.toLowerCase()}`; + const worktreeRoot = `${rootDir}-worktrees`; + const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase()); + + git(rootDir, "git branch -m main master"); + const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const })); + await store.updateTask(task.id, { + baseBranch: "master", + branch, + steps: completedSteps, + currentStep: completedSteps.length, + } as any); + await fixture.createBranch(branch); + await fixture.writeAndCommit(opts.fileName, opts.fileContent, `feat: add ${opts.taskId} merge content`); + await fixture.checkout("master"); + + await mkdir(worktreeRoot, { recursive: true }); + git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`); + await store.updateTask(task.id, { worktree: worktreePath, branch } as any); + if (!opts.skipEnqueue) { + store.enqueueMergeQueue(task.id); + } + + return { rootDir, store, taskId: task.id, branch, fixture, worktreeRoot, worktreePath }; +} + +async function cleanupFixture(fixture: Awaited>, worktreeRoot: string): Promise { + await fixture.cleanup(); + await rm(worktreeRoot, RM); +} + +describe("FN-6278 reliability interactions: merge runner cwd preflight", () => { + beforeEach(() => { + activeSessionRegistry.clear(); + executingTaskLock._clearForTest(); + }); + + it.skipIf(!hasGit)("reacquires before spawning git when the reuse worktree cwd vanished", async () => { + const { fixture, rootDir, store, taskId, branch, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-VANISHED", + fileName: "packages/engine/src/fn-6278-ri-vanished.ts", + fileContent: "export const vanishedReuseWorktree = true;\n", + }); + + try { + // Leave the git worktree registration stale but remove the filesystem cwd. + // Before FN-6278, the first merge-runner git spawn using this cwd threw + // `spawn git ENOENT` and could park the task failed after retry exhaustion. + await rm(worktreePath, RM); + + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.status ?? null).toBeNull(); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquired"); + expect(auditTypes).toContain("merge:reuse-fallback-new-worktree"); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + expect(auditTypes).not.toContain("MergeNonConflictFailure"); + + const freshAcquire = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquire"); + expect(freshAcquire?.metadata).toMatchObject({ + taskId, + reason: "unusable-task-worktree", + expectedBranch: branch, + priorWorktreePath: worktreePath, + diagnostics: { + requestedMode: "reuse-task-worktree", + classification: expect.objectContaining({ classification: "missing" }), + }, + }); + const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired"); + expect(acquired?.target).toBe(worktreePath); + expect(git(rootDir, "git ls-files")).toContain("packages/engine/src/fn-6278-ri-vanished.ts"); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("reacquires before spawning git when the reuse worktree is present but de-registered", async () => { + const { fixture, rootDir, store, taskId, branch, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-UNREGISTERED", + fileName: "packages/engine/src/fn-6278-ri-unregistered.ts", + fileContent: "export const unregisteredReuseWorktree = true;\n", + }); + const unregisteredPath = join(worktreeRoot, "present-but-unregistered"); + + try { + // Remove the valid linked worktree, then point the task at a different + // present directory that has git metadata but is not in `git worktree list`. + // This drives the distinct `classification: unregistered` preflight branch. + git(rootDir, `git worktree remove --force ${JSON.stringify(worktreePath)}`); + await mkdir(unregisteredPath, { recursive: true }); + await writeFile(join(unregisteredPath, ".git"), "gitdir: /tmp/fusion-unregistered-placeholder\n", "utf-8"); + await store.updateTask(taskId, { worktree: unregisteredPath, branch } as any); + + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.status ?? null).toBeNull(); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + const freshAcquire = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquire"); + expect(freshAcquire?.metadata).toMatchObject({ + taskId, + reason: "unusable-task-worktree", + expectedBranch: branch, + priorWorktreePath: unregisteredPath, + diagnostics: { + requestedMode: "reuse-task-worktree", + classification: expect.objectContaining({ classification: "unregistered" }), + }, + }); + expect(git(rootDir, "git ls-files")).toContain("packages/engine/src/fn-6278-ri-unregistered.ts"); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("leaves a healthy reuse worktree on the normal handoff path", async () => { + const { fixture, rootDir, store, taskId, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-HEALTHY", + fileName: "packages/engine/src/fn-6278-ri-healthy.ts", + fileContent: "export const healthyReuseWorktree = true;\n", + }); + + try { + const result = await aiMergeTask(store, rootDir, taskId); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(result.merged).toBe(true); + expect(taskAfter?.column).toBe("done"); + expect(taskAfter?.mergeRetries ?? 0).not.toBeGreaterThanOrEqual(3); + expect(auditTypes).toContain("merge:reuse-handoff-acquired"); + expect(auditTypes).not.toContain("merge:reuse-worktree-fresh-acquire"); + expect(auditTypes).not.toContain("merge:reuse-fallback-new-worktree"); + const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired"); + expect(acquired?.target).toBe(worktreePath); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); + + it.skipIf(!hasGit)("still surfaces genuine handoff failures after a healthy cwd preflight", async () => { + const { fixture, rootDir, store, taskId, worktreeRoot, worktreePath } = await setupReuseMergeFixture({ + taskId: "FN-6278-RI-ACTIVE-BINDING", + fileName: "packages/engine/src/fn-6278-ri-active-binding.ts", + fileContent: "export const nonRecoverableHandoffFailure = true;\n", + }); + activeSessionRegistry.registerPath(worktreePath, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" }); + + try { + await expect(aiMergeTask(store, rootDir, taskId)).rejects.toMatchObject({ + name: "MergeHandoffRefusedError", + gate: "active-session-binding", + }); + const taskAfter = await store.getTask(taskId); + const audits = store.getRunAuditEvents({ taskId }); + const auditTypes = audits.map((event) => event.mutationType); + + expect(taskAfter?.column).toBe("in-review"); + expect(taskAfter?.status ?? null).not.toBe("failed"); + expect(taskAfter?.error ?? null).not.toBe("spawn git ENOENT"); + expect(auditTypes).toContain("merge:reuse-handoff-refused"); + expect(auditTypes).not.toContain("merge:reuse-worktree-fresh-acquire"); + const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused"); + expect(refused?.metadata).toMatchObject({ gate: "active-session-binding" }); + } finally { + await cleanupFixture(fixture, worktreeRoot); + } + }, 60_000); +}); diff --git a/packages/engine/src/merger-integration-worktree.ts b/packages/engine/src/merger-integration-worktree.ts index a852ab363e..e830c593fc 100644 --- a/packages/engine/src/merger-integration-worktree.ts +++ b/packages/engine/src/merger-integration-worktree.ts @@ -71,6 +71,62 @@ export function resolveMergeIntegrationRoot( }; } +export type MergeIntegrationRootPreflightResult = + | { ok: true; resolution: MergeIntegrationRootResolution; checked: false | "reuse-task-worktree" } + | { + ok: false; + resolution: MergeIntegrationRootResolution; + checked: "reuse-task-worktree"; + reason: "missing-task-worktree" | "unusable-task-worktree"; + classification?: Awaited>; + }; + +export interface EnsureUsableMergeIntegrationRootInput { + resolution: MergeIntegrationRootResolution; + projectRoot: string; +} + +/** + * Preflight the merge integration cwd before any merge-runner git spawn. + * + * In cwd/project-root mode this is intentionally a no-op: the project root is + * the stable repository checkout and the common path should not pay an extra + * stat or git-worktree-list probe. In reuse-task-worktree mode the resolved + * `task.worktree` is user/session mutable, so classify it before it can be + * used as `cwd`; callers can then reacquire/recreate the worktree instead of + * letting Node surface a misleading `spawn git ENOENT` for a vanished cwd. + */ +export async function ensureUsableMergeIntegrationRoot( + input: EnsureUsableMergeIntegrationRootInput, +): Promise { + if (input.resolution.mode !== "reuse-task-worktree") { + return { ok: true, resolution: input.resolution, checked: false }; + } + + const reusableRoot = input.resolution.rootDir.trim(); + if (!reusableRoot) { + return { + ok: false, + resolution: input.resolution, + checked: "reuse-task-worktree", + reason: "missing-task-worktree", + }; + } + + const classification = await classifyTaskWorktree(input.projectRoot, reusableRoot); + if (!classification.ok) { + return { + ok: false, + resolution: input.resolution, + checked: "reuse-task-worktree", + reason: "unusable-task-worktree", + classification, + }; + } + + return { ok: true, resolution: input.resolution, checked: "reuse-task-worktree" }; +} + export interface ResolveIntegrationRemoteInput { settings: Pick; rootDir: string; @@ -306,6 +362,21 @@ function asCentralClaimAccessor(store: TaskStore): { export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise { const expectedBranch = canonicalFusionBranchName(input.task.id); const worktreePath = input.worktreePath; + const preflight = await ensureUsableMergeIntegrationRoot({ + resolution: { + mode: "reuse-task-worktree", + rootDir: worktreePath, + branchName: expectedBranch, + }, + projectRoot: input.projectRoot, + }); + if (!preflight.ok) { + throw new MergeHandoffRefusedError("integration-root-preflight", preflight.reason, { + taskId: input.task.id, + worktreePath, + classification: preflight.classification ?? null, + }); + } if (canonicalizePath(worktreePath) === canonicalizePath(input.projectRoot)) { throw new MergeHandoffRefusedError("reuse-misconfigured", "worktree-equals-project-root", { taskId: input.task.id, diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 81fe69852a..9cd0dd777f 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -129,6 +129,7 @@ import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from " import { decideAutoPrerebase, probeDivergence, runAutoPrerebase } from "./merger-auto-prerebase.js"; import { acquireReuseHandoff, + ensureUsableMergeIntegrationRoot, MergeHandoffRefusedError, probeIntegrationWorktreeState, releaseReuseHandoff, @@ -8170,19 +8171,15 @@ export async function aiMergeTask( let reuseHandoff: HandoffResult | undefined; if (integrationRoot.mode === "reuse-task-worktree") { - const reusableWorktreePath = task.worktree?.trim(); - if (!reusableWorktreePath) { - await reacquireReuseIntegrationWorktree("missing-task-worktree", { + const preflight = await ensureUsableMergeIntegrationRoot({ + resolution: integrationRoot, + projectRoot: projectRootDir, + }); + if (!preflight.ok) { + await reacquireReuseIntegrationWorktree(preflight.reason, { requestedMode: requestedIntegrationMode, + classification: preflight.classification ?? null, }); - } else { - const classification = await classifyTaskWorktree(projectRootDir, reusableWorktreePath); - if (!classification.ok) { - await reacquireReuseIntegrationWorktree("unusable-task-worktree", { - requestedMode: requestedIntegrationMode, - classification, - }); - } } }