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
This commit is contained in:
gsxdsm
2026-06-11 20:27:29 -07:00
parent 64d831576d
commit 7763ba5fab
5 changed files with 396 additions and 13 deletions

View File

@@ -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/<id>` 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.

View File

@@ -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();

View File

@@ -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<unknown> }, 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<ReturnType<typeof makeReliabilityFixture>>["store"];
taskId: string;
branch: string;
fixture: Awaited<ReturnType<typeof makeReliabilityFixture>>;
worktreeRoot: string;
worktreePath: string;
}> {
const fixture = await makeReliabilityFixture({
taskId: opts.taskId,
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
worktreeRebaseRemote: "origin",
} as Partial<Settings>,
});
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<ReturnType<typeof makeReliabilityFixture>>, worktreeRoot: string): Promise<void> {
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);
});

View File

@@ -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<ReturnType<typeof classifyTaskWorktree>>;
};
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<MergeIntegrationRootPreflightResult> {
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<ProjectSettings, "worktreeRebaseRemote">;
rootDir: string;
@@ -306,6 +362,21 @@ function asCentralClaimAccessor(store: TaskStore): {
export async function acquireReuseHandoff(input: ReuseHandoffInput): Promise<HandoffResult> {
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,

View File

@@ -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,
});
}
}
}