fix(FN-5345): refuse empty commits + early empty-own-diff finalize

Three engine-level fixes for the in-review wedge class identified via FN-5345
(verification-only task with empty handoff commit + drifted worktree mapping
escalating to 'merge-deadlock-detected: verified content not on main' after
FN-4999 completion-handoff-limbo recovery exhausts).

D1 — prepare-commit-msg empty-commit guard
  Fusion task worktrees install a prepare-commit-msg hook that refuses
  'git commit --allow-empty' and other zero-staged-diff commits. Amend
  (detected via $2==commit source arg or '--amend' in 'ps -o args= -p $PPID')
  and merge/squash/cherry-pick/revert/rebase ceremonies are allowed.

D5 — early empty-own-diff fast-path in aiMergeTask
  In reuse-task-worktree integration mode, BEFORE any reuse-handoff acquisition,
  detect branches with own_commit_count >= 1 but zero net diff vs merge-base
  (git diff --quiet <mergeBase>..<branch>) and finalize as no-op with
  mergeDetails.noOpMerge=true + task:auto-recover-finalize-already-on-main
  audit (reason: empty-own-diff-early-fast-path). cwd-main integration mode
  is unchanged. classifyOwnedLandedEvidence also detects empty-own-diff so
  self-healing and post-handoff finalize paths benefit too.

D3 — reuse-fallback consults existing branch registration
  Before creating a fresh worktree in merger's reacquireReuseIntegrationWorktree,
  consult 'git worktree list --porcelain' for existing registrations of
  fusion/<id>. Reuse extant usable registrations directly. Prune stale
  registrations first. Eliminates FN-5083-class double-registration where
  'git worktree add -f' on an already-registered branch produced two worktrees
  both claiming the branch and wedged the next handoff gate.

Tests
  - New real-git backstop: prepare-commit-msg-empty-guard.real-git.test.ts
  - New reliability interaction backstop in merge-reuse-task-worktree.test.ts
    ('FN-5345: empty-own-diff branch auto-finalizes via early fast-path')
  - commit-msg-trailer.real-git.test.ts updated to use real commits (it was
    relying on --allow-empty, which the new guard now refuses)
  - Full @fusion/engine suite: 448 files / 5879 tests / 9 skipped, all green
  - pnpm lint green, pnpm build green
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 17:31:48 -07:00
parent fd202e9356
commit 1983dac6e4
7 changed files with 569 additions and 5 deletions

View File

@@ -0,0 +1,10 @@
---
"@runfusion/fusion": patch
---
Engine reliability: prevent the FN-5345 in-review wedge class.
- Fusion task worktrees now install a `prepare-commit-msg` empty-commit guard that refuses `git commit --allow-empty` and other zero-staged-diff commits, while still allowing legitimate amend / merge / squash / cherry-pick / revert / rebase paths.
- Merger gains an early empty-own-diff fast-path in `reuse-task-worktree` integration mode: branches with own commits but zero net tree change vs merge-base now auto-finalize as no-op BEFORE any reuse-handoff acquisition runs, preventing `registered-branch-mismatch` + `merge-deadlock-detected: verified content not on main` wedges.
- `classifyOwnedLandedEvidence` also detects the empty-own-diff case and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too.
- Merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree, reusing extant usable registrations of `fusion/<id>` and pruning stale ones, eliminating FN-5083-class branch-registration double-registration.

View File

@@ -217,6 +217,7 @@ Detailed mechanism logs live in `docs/architecture.md` and `docs/design/`. The c
- **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session. - **Meta-task auto-archive safety guards (FN-5064)**: `auto-archive-meta-resolved`/`auto-archive-meta-stalled` must skip archival (with `task:auto-archive-meta-*-skipped` audits) whenever guard checks detect substantive work signals such as unique branch commits, recent executor activity, pending `taskDoneRetryCount`, merge-in-progress state, or active worktree session.
- **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight. - **Scheduler fanout tiebreaker (FN-4969)**: within the same priority class, scheduler dispatch prefers runnable `todo` tasks with the highest active dependency-dependent fanout; `urgent` always outranks lower priorities regardless of fanout, and `overlapBlockedBy`/file-scope overlap blockers are excluded from unblock weight.
- **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass). - **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers and emits `scheduler:overlap-priority-inversion` once per (candidate, blocker, pass).
- **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count <mergeTarget>..<branch>` is > 0, and `git diff --quiet <mergeBase>..<branch>` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The `cwd-main` integration mode is unchanged. `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/<id>` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first.
- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. - **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/<id>` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`.
- **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run.
- **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-main`. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-main`.
@@ -271,7 +272,7 @@ Sanctioned user-facing daemons that intentionally outlive the caller may keep `d
- Commit messages: `feat(FN-XXX):`, `fix(FN-XXX):`, `test(FN-XXX):` - Commit messages: `feat(FN-XXX):`, `fix(FN-XXX):`, `test(FN-XXX):`
- One commit per step (not per file change) - One commit per step (not per file change)
- Always include the task ID prefix - Always include the task ID prefix
- Fusion-managed task worktrees install both identity-guard `pre-commit` and trailer-appending `commit-msg` hooks; task-worktree commits should carry a `Fusion-Task-Id: FN-NNNN` trailer (FN-5089, configurable via `commitMsgHookEnabled`). Attribution still falls back to branch/subject when the trailer hook is disabled. - Fusion-managed task worktrees install identity-guard `pre-commit`, trailer-appending `commit-msg`, and empty-commit-refusal `prepare-commit-msg` hooks. Task-worktree commits should carry a `Fusion-Task-Id: FN-NNNN` trailer (FN-5089, configurable via `commitMsgHookEnabled`). Attribution still falls back to branch/subject when the trailer hook is disabled. The `prepare-commit-msg` empty-commit guard (FN-5345/FN-5377) refuses `git commit --allow-empty` and other zero-staged-diff commits in fusion worktrees; legitimate amend / merge / squash / cherry-pick / revert / rebase ceremonies are allowed (amend is detected via `$2=="commit"` source arg or `--amend` in `ps -o args= -p $PPID`).
## Merging Branches Into Main ## Merging Branches Into Main
@@ -549,6 +550,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in
- FN-5039 backstop: `packages/engine/src/__tests__/reliability-interactions/worktree-contamination-attribution.real-git.test.ts` guards `captureModifiedFiles` trailer attribution filtering and `task:worktree-contamination-detected` audit fan-out across rebase contamination, clean, untrailered, and fallback paths. - FN-5039 backstop: `packages/engine/src/__tests__/reliability-interactions/worktree-contamination-attribution.real-git.test.ts` guards `captureModifiedFiles` trailer attribution filtering and `task:worktree-contamination-detected` audit fan-out across rebase contamination, clean, untrailered, and fallback paths.
- FN-4976 backstop: `packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts` guards `cleanupConflictingWorktree` clearing stale same-task `activeSessionRegistry` entries before the FN-4811 foreign-owner check, while preserving refusal behavior for foreign owners and live same-task bindings. - FN-4976 backstop: `packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts` guards `cleanupConflictingWorktree` clearing stale same-task `activeSessionRegistry` entries before the FN-4811 foreign-owner check, while preserving refusal behavior for foreign owners and live same-task bindings.
- FN-4999 backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` covers the `recoverCompletionHandoffLimbo` sweep stage (grace window, active-task skip, merge-blocker guard, capped retries, and audit fan-out). - FN-4999 backstop: `packages/engine/src/__tests__/reliability-interactions/completion-handoff-limbo.test.ts` covers the `recoverCompletionHandoffLimbo` sweep stage (grace window, active-task skip, merge-blocker guard, capped retries, and audit fan-out).
- FN-5345/FN-5377 backstops: `packages/engine/src/__tests__/reliability-interactions/merge-reuse-task-worktree.test.ts` (`FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff`) covers the early no-op fast-path under drifted worktree mapping; `packages/engine/src/__tests__/real-git/prepare-commit-msg-empty-guard.real-git.test.ts` covers the empty-commit refusal hook (refuses `--allow-empty`, allows amend + real commits, no-op outside fusion worktrees).
- FN-5083 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-branch-rebind.test.ts` covers in-review branch rebind composition with metadata-cleared state, idempotent re-sweeps, and ambiguous-candidate skip behavior. - FN-5083 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-branch-rebind.test.ts` covers in-review branch rebind composition with metadata-cleared state, idempotent re-sweeps, and ambiguous-candidate skip behavior.
- FN-5093 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-stalled-detector.test.ts` covers composition between quiet-window in-review stalled surfacing and adjacent reason-driven/paused/ghost-recovery/auto-merge gating paths. - FN-5093 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-stalled-detector.test.ts` covers composition between quiet-window in-review stalled surfacing and adjacent reason-driven/paused/ghost-recovery/auto-merge gating paths.
- FN-5103 backstop: `packages/engine/src/__tests__/reliability-interactions/landed-files-attribution.test.ts` covers attribution-restricted rebase landed-files capture, verified-short-circuit zero-own-commit capture, and attribution-failure fallback composition. - FN-5103 backstop: `packages/engine/src/__tests__/reliability-interactions/landed-files-attribution.test.ts` covers attribution-restricted rebase landed-files capture, verified-short-circuit zero-own-commit capture, and attribution-failure fallback composition.

View File

@@ -29,24 +29,30 @@ describe("commit-msg trailer hook (real git)", () => {
taskAttributionTrailerName: "Fusion-Task-Id", taskAttributionTrailerName: "Fusion-Task-Id",
}); });
git(worktreeDir, "git commit --allow-empty -m 'feat(KB-7): first'"); // FN-5345/FN-5377: pre-commit hook now refuses empty commits in fusion
// worktrees, so this test stages a real file change for each commit
// instead of using --allow-empty. The trailer-hook behavior under test
// is unchanged.
writeFileSync(join(worktreeDir, "step1.txt"), "first\n");
git(worktreeDir, "git add step1.txt && git commit -m 'feat(KB-7): first'");
const firstBody = git(worktreeDir, "git log -1 --format=%B"); const firstBody = git(worktreeDir, "git log -1 --format=%B");
expect(firstBody).toContain("Fusion-Task-Id: KB-7"); expect(firstBody).toContain("Fusion-Task-Id: KB-7");
const firstTrailers = git(worktreeDir, "git log -1 --format=%B | git interpret-trailers --parse"); const firstTrailers = git(worktreeDir, "git log -1 --format=%B | git interpret-trailers --parse");
expect(firstTrailers).toContain("Fusion-Task-Id: KB-7"); expect(firstTrailers).toContain("Fusion-Task-Id: KB-7");
git(worktreeDir, "git commit --amend --no-edit --allow-empty"); git(worktreeDir, "git commit --amend --no-edit");
const amendNoEditBody = git(worktreeDir, "git log -1 --format=%B"); const amendNoEditBody = git(worktreeDir, "git log -1 --format=%B");
expect((amendNoEditBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1); expect((amendNoEditBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten' --allow-empty"); git(worktreeDir, "git commit --amend -m 'feat(KB-7): rewritten'");
const rewrittenBody = git(worktreeDir, "git log -1 --format=%B"); const rewrittenBody = git(worktreeDir, "git log -1 --format=%B");
expect(rewrittenBody).toContain("feat(KB-7): rewritten"); expect(rewrittenBody).toContain("feat(KB-7): rewritten");
expect((rewrittenBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1); expect((rewrittenBody.match(/Fusion-Task-Id:\s*KB-7/g) ?? []).length).toBe(1);
const taskFile = git(worktreeDir, "git rev-parse --git-path fusion-task-id"); const taskFile = git(worktreeDir, "git rev-parse --git-path fusion-task-id");
writeFileSync(isAbsolute(taskFile) ? taskFile : resolve(worktreeDir, taskFile), "kb-7\n"); writeFileSync(isAbsolute(taskFile) ? taskFile : resolve(worktreeDir, taskFile), "kb-7\n");
git(worktreeDir, "git commit --allow-empty -m 'feat(KB-7): lowercase metadata' --allow-empty"); writeFileSync(join(worktreeDir, "step2.txt"), "lowercase\n");
git(worktreeDir, "git add step2.txt && git commit -m 'feat(KB-7): lowercase metadata'");
const lowercaseBody = git(worktreeDir, "git log -1 --format=%B"); const lowercaseBody = git(worktreeDir, "git log -1 --format=%B");
expect(lowercaseBody).toContain("Fusion-Task-Id: KB-7"); expect(lowercaseBody).toContain("Fusion-Task-Id: KB-7");

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { installTaskWorktreeIdentityGuard } from "../../worktree-hooks.js";
function git(dir: string, cmd: string): { stdout: string; stderr: string; status: number | null } {
try {
const stdout = execSync(cmd, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString();
return { stdout, stderr: "", status: 0 };
} catch (err: any) {
return { stdout: err.stdout?.toString() ?? "", stderr: err.stderr?.toString() ?? "", status: err.status ?? null };
}
}
describe("prepare-commit-msg empty-commit guard (real git, FN-5345/FN-5377)", () => {
it("refuses --allow-empty in fusion task worktrees, but allows amend and real commits", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5345-empty-guard-"));
const worktreeDir = join(rootDir, "wt");
try {
git(rootDir, "git init -b main");
git(rootDir, 'git config user.email "t@t"');
git(rootDir, 'git config user.name "t"');
writeFileSync(join(rootDir, "README.md"), "init\n");
git(rootDir, "git add README.md");
git(rootDir, "git commit -m 'init'");
git(rootDir, "git worktree add -b fusion/fn-5345 wt HEAD");
await installTaskWorktreeIdentityGuard({
worktreePath: worktreeDir,
taskId: "FN-5345",
});
// Real commit succeeds.
writeFileSync(join(worktreeDir, "real.txt"), "real\n");
git(worktreeDir, "git add real.txt");
const real = git(worktreeDir, "git commit -m 'feat(FN-5345): real'");
expect(real.status).toBe(0);
// --allow-empty with new message is REFUSED.
const empty = git(worktreeDir, "git commit --allow-empty -m 'feat(FN-5345): empty handoff'");
expect(empty.status).not.toBe(0);
expect(empty.stderr).toContain("refusing empty commit");
expect(empty.stderr).toContain("FN-5345/FN-5377");
// --amend --no-edit (no staged changes, amend HEAD) is ALLOWED.
const amendNoEdit = git(worktreeDir, "git commit --amend --no-edit");
expect(amendNoEdit.status).toBe(0);
// --amend -m "..." (no staged changes, reword) is ALLOWED.
const amendReword = git(worktreeDir, "git commit --amend -m 'feat(FN-5345): reworded'");
expect(amendReword.status).toBe(0);
// Outside fusion worktrees (no fusion-task-id metadata), --allow-empty works normally.
const outside = git(rootDir, "git commit --allow-empty -m 'chore: legit empty in root'");
expect(outside.status).toBe(0);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
}, 30_000);
});

View File

@@ -505,4 +505,85 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
await fixture.cleanup(); await fixture.cleanup();
} }
}, 20_000); }, 20_000);
// FN-5345/FN-5377 regression backstop.
//
// A verification-only task that committed `--allow-empty` produced a branch
// with own-commit-count >= 1 but zero net tree change vs merge-base. Combined
// with drifted worktree<->branch mapping, the reuse-handoff gate would refuse
// with `registered-branch-mismatch` and the task would escalate to
// `merge-deadlock-detected: verified content not on main` after FN-4999
// completion-handoff-limbo recovery exhausts. The early empty-own-diff
// fast-path must finalize this BEFORE any reuse-handoff acquisition runs.
it.skipIf(!hasGit)(
"FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff",
async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-EMPTY-OWN-DIFF",
settings: {
baseBranch: "master",
mergeIntegrationWorktree: "reuse-task-worktree",
} as any,
});
try {
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);
// Produce an empty handoff commit on the branch: own_commit_count == 1
// but `git diff --quiet mergeBase..branch` exits 0 (no net change).
git(rootDir, `git commit --allow-empty -m 'test(${actualTask!.id}): verification-only handoff'`);
await fixture.checkout("master");
// Set up the worktree mapping in a way that would normally wedge the
// reuse-handoff gate (FN-5345 scenario): worktree mapped to a
// not-yet-created path so classifyTaskWorktree would report `missing`,
// forcing reacquire-fallback into FN-5083-class branch-registration drift.
await store.updateTask(task.id, {
worktree: join(worktreeRoot, "drifted-missing-path"),
branch,
} as any);
store.enqueueMergeQueue(task.id);
const result = await aiMergeTask(store, rootDir, task.id);
expect(result.merged).toBe(true);
expect(result.noOp).toBe(true);
expect(result.mergeConfirmed).toBe(true);
expect((await store.getTask(task.id))?.column).toBe("done");
const audits = store.getRunAuditEvents({ taskId: task.id });
const auditTypes = audits.map((event) => event.mutationType);
// Early fast-path must short-circuit BEFORE any reuse-handoff event.
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
expect(auditTypes).not.toContain("merge:reuse-fallback-new-worktree");
// Records the auto-finalize audit with the empty-own-diff reason.
const finalize = audits.find(
(event) =>
event.mutationType === "task:auto-recover-finalize-already-on-main"
&& (event.metadata as any)?.reason === "empty-own-diff-early-fast-path",
);
expect(finalize).toBeDefined();
expect((finalize?.metadata as any)?.aheadCount).toBeGreaterThanOrEqual(1);
} finally {
await fixture.cleanup();
}
},
30_000,
);
}); });

View File

@@ -577,6 +577,63 @@ export async function classifyOwnedLandedEvidence(
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true }; return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
} }
// FN-5345/FN-5377: empty-own-diff detection.
//
// A branch with one or more own commits whose net tree change vs its own
// merge-base with the integration branch is empty (e.g. a verification-only
// task that produced a `git commit --allow-empty` handoff commit) is
// logically equivalent to `proven-no-op` — there is nothing to land.
//
// We require the merge-base to be reachable from the integration target so
// we never claim no-op for a branch rooted off some other ref. This pairs
// with the FN-5345/FN-5377 pre-commit empty-commit refusal hook (which
// prevents the bad state from being created going forward) and recovers
// any tasks already wedged in this state.
if (aheadCount !== null && aheadCount > 0) {
try {
const { stdout: mergeBaseOut } = await execFileAsync(
"git",
["merge-base", mergeTargetBranch, branch],
{ cwd: rootDir, encoding: "utf-8" },
);
const mergeBase = mergeBaseOut.trim();
if (mergeBase) {
let ownDiffEmpty = false;
try {
await execFileAsync(
"git",
["diff", "--quiet", `${mergeBase}..${branch}`],
{ cwd: rootDir },
);
ownDiffEmpty = true;
} catch {
// exit non-zero — net diff exists, NOT empty-own-diff
ownDiffEmpty = false;
}
if (ownDiffEmpty) {
let mergeBaseReachable = baseReachableFromTarget;
if (!mergeBaseReachable) {
try {
await execFileAsync(
"git",
["merge-base", "--is-ancestor", mergeBase, mergeTargetBranch],
{ cwd: rootDir },
);
mergeBaseReachable = true;
} catch {
mergeBaseReachable = false;
}
}
if (mergeBaseReachable) {
return { kind: "proven-no-op", baseRef: mergeTargetBranch, ownDiffEmpty: true };
}
}
}
} catch {
// merge-base lookup failed — fall through to existing classifications
}
}
let branchExists = false; let branchExists = false;
try { try {
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: rootDir }); await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: rootDir });
@@ -6401,6 +6458,142 @@ export async function aiMergeTask(
} }
}; };
// FN-5345/FN-5377: early empty-own-diff fast-path.
//
// Detect branches whose own commits introduce zero net tree change vs their
// merge-base with the integration target ("empty-own-diff") BEFORE attempting
// any reuse-handoff acquisition. This unsticks tasks where a stale empty
// handoff commit (e.g. a verification-only task that committed --allow-empty)
// combined with drifted worktree<->branch mapping would otherwise wedge the
// handoff gate with `registered-branch-mismatch` and ultimately escalate to
// `merge-deadlock-detected: verified content not on main` after FN-4999
// completion-handoff-limbo recovery exhausts.
//
// Scope is narrow on purpose:
// - ONLY in reuse-task-worktree integration mode (where the wedge lives).
// - ONLY when the branch exists, is ahead of the integration target by >= 1
// commit, and `git diff --quiet <mergeBase>..<branchTip>` reports an
// empty net diff.
// - aheadCount === 0 (already-landed) and missing-branch (no-changes-finalized)
// still go through the standard reuse-handoff path so the existing
// handoff lease lifecycle and FN-5083 branch-rebind invariants are
// preserved.
// - cwd-main integration mode (legacy / unit-test default) is unchanged.
const earlyFastPathEligible = settings.mergeIntegrationWorktree !== "cwd-main";
try {
if (!earlyFastPathEligible) throw new Error("skip-early-fast-path:not-reuse-mode");
const earlyBranch = task.branch || canonicalFusionBranchName(taskId);
let earlyBranchExists = false;
try {
await execAsync(
`git show-ref --verify --quiet ${quoteArg(`refs/heads/${earlyBranch}`)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
earlyBranchExists = true;
} catch {
earlyBranchExists = false;
}
if (earlyBranchExists) {
let earlyAheadCount: number | null = null;
try {
const { stdout } = await execAsync(
`git rev-list --count ${quoteArg(`${mergeTarget.branch}..${earlyBranch}`)}`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
);
const parsed = Number.parseInt(stdout.trim(), 10);
if (Number.isFinite(parsed)) earlyAheadCount = parsed;
} catch {
earlyAheadCount = null;
}
if (earlyAheadCount !== null && earlyAheadCount > 0) {
let earlyMergeBase = "";
try {
const { stdout } = await execAsync(
`git merge-base ${quoteArg(mergeTarget.branch)} ${quoteArg(earlyBranch)}`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
);
earlyMergeBase = stdout.trim();
} catch {
earlyMergeBase = "";
}
if (earlyMergeBase) {
let earlyOwnDiffEmpty = false;
try {
await execAsync(
`git diff --quiet ${quoteArg(`${earlyMergeBase}..${earlyBranch}`)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
earlyOwnDiffEmpty = true;
} catch {
earlyOwnDiffEmpty = false;
}
if (earlyOwnDiffEmpty) {
const noOpReason = `early fast-path: branch ${earlyBranch} has ${earlyAheadCount} own commit(s) but zero net diff vs merge-base of ${mergeTarget.branch}`;
const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}),
mergeConfirmed: true,
noOpMerge: true,
noOpReason,
landedFiles: [],
mergedAt: new Date().toISOString(),
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
};
await store.updateTask(taskId, { mergeDetails, modifiedFiles: [] });
await store.logEntry(
taskId,
`Auto-finalized no-op (early fast-path, FN-5345/FN-5377): ${noOpReason}`,
);
try {
await audit.database({
type: "task:auto-recover-finalize-already-on-main",
target: taskId,
metadata: {
phase: "merge",
reason: "empty-own-diff-early-fast-path",
baseRef: mergeTarget.branch,
branch: earlyBranch,
aheadCount: earlyAheadCount,
mergeBase: earlyMergeBase,
},
});
} catch (auditErr: unknown) {
mergerLog.warn(
`${taskId}: failed to emit empty-own-diff-early-fast-path audit: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
);
}
const result: MergeResult = {
task,
branch: earlyBranch,
merged: true,
noOp: true,
worktreeRemoved: false,
branchDeleted: false,
mergeConfirmed: true,
noOpMerge: true,
noOpReason,
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: mergeTarget.branch,
};
await completeTask(store, taskId, result);
return result;
}
}
}
}
} catch (earlyErr: unknown) {
// Fail-soft: any error falls through to the existing merge path.
// (The "skip-early-fast-path:not-reuse-mode" sentinel is the cwd-main bypass
// and is intentionally silent.)
if (earlyErr instanceof Error && earlyErr.message.startsWith("skip-early-fast-path:")) {
// intentional bypass
} else {
mergerLog.warn(
`${taskId}: early empty-own-diff fast-path failed; falling through to standard merge path: ${earlyErr instanceof Error ? earlyErr.message : String(earlyErr)}`,
);
}
}
const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main" const requestedIntegrationMode = settings.mergeIntegrationWorktree === "cwd-main"
? "cwd-main" ? "cwd-main"
: "reuse-task-worktree"; : "reuse-task-worktree";
@@ -6420,6 +6613,124 @@ export async function aiMergeTask(
reason: string, reason: string,
diagnostics: Record<string, unknown>, diagnostics: Record<string, unknown>,
): Promise<void> => { ): Promise<void> => {
// FN-5345/FN-5377: consult existing registration of `fusion/<id>` before
// creating a fresh worktree. If the branch is already registered at a
// usable extant path, rebind `task.worktree` to it (avoids FN-5083-class
// double-registration where two worktrees both claim the same branch and
// the next handoff gate refuses with `registered-branch-mismatch`).
//
// If registered at a stale/missing path, run `git worktree prune` so the
// subsequent `worktree add -f` does not produce a duplicate admin entry.
const expectedBranch = task.branch || canonicalFusionBranchName(taskId);
try {
const { stdout: porcelain } = await execAsync(
`git worktree list --porcelain`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
);
const entries: { path?: string; branch?: string }[] = [];
let current: { path?: string; branch?: string } = {};
for (const line of porcelain.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.path) entries.push(current);
current = { path: line.slice("worktree ".length).trim() };
} else if (line.startsWith("branch ")) {
// refs/heads/fusion/fn-5345 -> fusion/fn-5345
current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
} else if (line.trim() === "") {
if (current.path) {
entries.push(current);
current = {};
}
}
}
if (current.path) entries.push(current);
const expectedLower = expectedBranch.toLowerCase();
const matches = entries.filter((e) => (e.branch || "").toLowerCase() === expectedLower);
let prunedAnyStaleRegistration = false;
let reusableMatch: { path: string; branch: string } | null = null;
for (const match of matches) {
if (!match.path) continue;
const exists = existsSync(match.path);
if (!exists) {
prunedAnyStaleRegistration = true;
continue;
}
const cls = await classifyTaskWorktree(projectRootDir, match.path);
if (cls.ok) {
reusableMatch = { path: match.path, branch: match.branch || expectedBranch };
break;
}
prunedAnyStaleRegistration = true;
}
if (prunedAnyStaleRegistration) {
try {
await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 });
await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-new-worktree",
{
taskId,
reason: "pruned-stale-branch-registration",
branch: expectedBranch,
diagnostics: { matches: matches.map((m) => ({ path: m.path, branch: m.branch })) },
prePrune: true,
},
projectRootDir,
);
} catch (pruneErr) {
mergerLog.warn(
`${taskId}: git worktree prune failed before reacquire: ${pruneErr instanceof Error ? pruneErr.message : String(pruneErr)}`,
);
}
}
if (reusableMatch) {
// Reuse the extant registration directly. Skip acquireTaskWorktree's
// fresh-create path so we don't double-register the branch.
task.worktree = reusableMatch.path;
task.branch = reusableMatch.branch;
branch = reusableMatch.branch;
integrationRoot = {
...integrationRoot,
mode: "reuse-task-worktree",
rootDir: reusableMatch.path,
branchName: reusableMatch.branch,
};
reuseTaskWorktreeMerge = true;
rootDir = reusableMatch.path;
integrationRemote = await resolveIntegrationRemote({
settings,
rootDir,
integrationBranch: mergeTarget.branch,
});
await store.updateTask(taskId, { worktree: reusableMatch.path, branch: reusableMatch.branch });
await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-new-worktree",
{
taskId,
reason: `${reason}:reused-existing-registration`,
branch: reusableMatch.branch,
worktreePath: reusableMatch.path,
source: "existing",
diagnostics,
integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch,
},
reusableMatch.path,
);
await store.recordActivity({
type: "task:merge-worktree-reacquired",
taskId,
taskTitle: task.title,
details: `Merge worktree reacquired from existing registration: ${reason}`,
metadata: { reason, branch: reusableMatch.branch, worktreePath: reusableMatch.path, source: "existing" },
});
return;
}
} catch (listErr) {
mergerLog.warn(
`${taskId}: git worktree list consult failed before reacquire; proceeding with fresh creation: ${listErr instanceof Error ? listErr.message : String(listErr)}`,
);
}
const acquisition = await acquireTaskWorktree({ const acquisition = await acquireTaskWorktree({
task, task,
rootDir: projectRootDir, rootDir: projectRootDir,

View File

@@ -7,6 +7,7 @@ const execAsync = promisify(exec);
export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"] as const; export const DEFAULT_ALLOWED_BRANCH_PATTERNS = ["^fusion/step-\\d+-[a-z0-9-]+$"] as const;
const COMMIT_MSG_HOOK_MARKER = "# fusion-managed-commit-msg-hook"; const COMMIT_MSG_HOOK_MARKER = "# fusion-managed-commit-msg-hook";
const PREPARE_COMMIT_MSG_HOOK_MARKER = "# fusion-managed-prepare-commit-msg-hook";
function toShellCasePattern(pattern: string): string { function toShellCasePattern(pattern: string): string {
return pattern return pattern
@@ -36,6 +37,12 @@ if [ ! -f "$TASK_FILE" ]; then
exit 0 exit 0
fi fi
# Note: empty-commit refusal (FN-5345/FN-5377) lives in the
# prepare-commit-msg hook (installed by installTaskWorktreeIdentityGuard).
# That hook gets the commit-source argument and can distinguish 'amend' /
# 'merge' / 'squash' (which legitimately may produce empty commits) from
# new commits with --allow-empty.
WORKTREE_TASK_ID=$(cat "$TASK_FILE") WORKTREE_TASK_ID=$(cat "$TASK_FILE")
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId) # Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
EXPECTED_BRANCH=${JSON.stringify(`fusion/${taskId.toLowerCase()}`)} EXPECTED_BRANCH=${JSON.stringify(`fusion/${taskId.toLowerCase()}`)}
@@ -73,6 +80,66 @@ async function resolveGitPath(worktreePath: string, gitPath: string): Promise<st
} }
} }
/**
* Build the shared prepare-commit-msg empty-commit guard hook.
*
* Refuses to author empty commits in fusion-managed task worktrees. A
* verification-only task that finds no repro should leave own-commits at 0
* and let the merger's empty-own-diff / proven-no-op classifier finalize it.
* Manufacturing an empty handoff commit (e.g. `git commit --allow-empty`)
* defeats that classifier and wedges the task in the merge path (FN-5345).
*
* prepare-commit-msg gets the commit-source argument so we can correctly
* allow legitimate empty commits during amend / merge / squash / template
* paths and only refuse new --allow-empty commits.
*/
export function buildPrepareCommitMsgEmptyGuardHook(taskId: string): string {
return `#!/bin/sh
set -eu
${PREPARE_COMMIT_MSG_HOOK_MARKER}
# fusion-task-id-seed: ${taskId}
# Only enforce in fusion-managed task worktrees.
TASK_FILE=$(git rev-parse --git-path fusion-task-id)
[ -f "$TASK_FILE" ] || exit 0
COMMIT_SOURCE="\${2:-}"
# Allow legitimate paths that may produce empty commits:
# - commit (amend via --amend with no -m)
# - merge (merge commit)
# - squash (squash merge)
# - cherry-pick / revert / rebase ceremonies (detected via git-dir markers)
case "$COMMIT_SOURCE" in
commit|merge|squash) exit 0 ;;
esac
# 'git commit --amend -m "..."' reports source=message (not commit), so the
# source arg alone cannot distinguish amend-with-new-message from
# --allow-empty -m. Inspect the parent process command line as a tiebreaker.
PARENT_CMD=$(ps -o args= -p "$PPID" 2>/dev/null || echo "")
case "$PARENT_CMD" in
*' --amend'*|*' --amend '*) exit 0 ;;
esac
GIT_DIR=$(git rev-parse --git-dir)
if [ -f "$GIT_DIR/MERGE_HEAD" ] \\
|| [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] \\
|| [ -f "$GIT_DIR/REVERT_HEAD" ] \\
|| [ -d "$GIT_DIR/rebase-merge" ] \\
|| [ -d "$GIT_DIR/rebase-apply" ]; then
exit 0
fi
if git diff --cached --quiet --no-ext-diff 2>/dev/null; then
printf '%s\\n' "fusion: refusing empty commit \u2014 staged diff is empty." >&2
printf '%s\\n' " Use fn_task_document_write for narrative output, not git commits." >&2
printf '%s\\n' " (FN-5345/FN-5377 empty-commit guard)" >&2
exit 1
fi
`;
}
export function buildCommitMsgTrailerHook( export function buildCommitMsgTrailerHook(
taskId: string, taskId: string,
options: { options: {
@@ -141,6 +208,23 @@ async function installCommitMsgHook(input: {
await writeFileAtomic(hookPath, hook, 0o755); await writeFileAtomic(hookPath, hook, 0o755);
} }
async function installPrepareCommitMsgEmptyGuard(input: {
worktreePath: string;
taskId: string;
}): Promise<void> {
const hookPath = await resolveGitPath(input.worktreePath, "hooks/prepare-commit-msg");
const existing = await fs.readFile(hookPath, "utf-8").catch(() => null);
if (existing && !existing.includes(PREPARE_COMMIT_MSG_HOOK_MARKER)) {
console.warn(
`[worktree-hooks] prepare-commit-msg hook already exists at ${hookPath}; skipping Fusion empty-commit guard install for ${input.taskId}`
);
return;
}
const hook = buildPrepareCommitMsgEmptyGuardHook(input.taskId);
await writeFileAtomic(hookPath, hook, 0o755);
}
export async function installTaskWorktreeIdentityGuard(input: { export async function installTaskWorktreeIdentityGuard(input: {
worktreePath: string; worktreePath: string;
taskId: string; taskId: string;
@@ -164,4 +248,11 @@ export async function installTaskWorktreeIdentityGuard(input: {
trailerName: input.taskAttributionTrailerName ?? "Fusion-Task-Id", trailerName: input.taskAttributionTrailerName ?? "Fusion-Task-Id",
}); });
} }
// FN-5345/FN-5377: install the empty-commit guard alongside the trailer
// hook. Skipped automatically if a non-fusion hook already exists.
await installPrepareCommitMsgEmptyGuard({
worktreePath: input.worktreePath,
taskId: input.taskId,
});
} }