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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user