fix(FN-5345): address code-review findings on engine fixes

Follow-up to 1983dac6e addressing nine findings from a code review of the
FN-5345/FN-5377 engine fixes. Includes a real bug fix (commit-message bypass
of the amend detection), two reliability invariant restorations (FN-4811 +
FN-4954 in the new D3 reuse-fallback path), a resource-leak cleanup, plus
test/audit/taxonomy polish.

HIGH

- D3 reuse-fallback now respects FN-4811 active-session safety: matches whose
  path is currently owned by a different task in activeSessionRegistry are
  skipped, never silently rebound. Skipped owners are recorded in audit
  metadata for forensics.
- D3 reuse-fallback now respects FN-4954 pool-lease bookkeeping: when
  recycleWorktrees=true AND a worktree pool is attached, the direct-reuse
  shortcut is bypassed and the existing acquireTaskWorktree path is used so
  WorktreePool.acquire/.release stays consistent. Without this guard the
  new path could trip PoolDoubleLeaseError.
- prepare-commit-msg amend detection tokenizes the parent command line and
  stops at the first message-supplying flag (-m/-F/--message/--file/=variants)
  so a commit message containing the substring '--amend' cannot bypass the
  guard. New regression test in prepare-commit-msg-empty-guard.real-git.test.ts.

MEDIUM

- Early empty-own-diff fast-path extracted into tryEarlyEmptyOwnDiffFinalize()
  helper. Removes the exception-as-control-flow sentinel ('skip-early-fast-path:
  not-reuse-mode') in favor of a plain if (eligible) { try { ... } catch {} }
  block.
- Fast-path best-effort cleans up the stranded fusion/<id> worktree and branch
  before completeTask(), so empty-own-diff residuals do not accumulate in
  .worktrees/ or the branch namespace. FN-4811 guard ensures we never remove
  a foreign-owned worktree.
- Two new audit subtypes in run-audit.ts replace the prior overloading of
  merge:reuse-fallback-new-worktree:
    - merge:reuse-fallback-pruned-stale-registration
    - merge:reuse-fallback-reused-existing-registration
  merge:reuse-fallback-new-worktree is now reserved for actual new-worktree
  creation. Local emitReuseHandoffAuditEvent type union updated to match.
- New direct classifier test in merger-finalize-unproven.real-git.test.ts
  ('classifies proven-no-op for empty-own-diff branches') covers the new
  branch in classifyOwnedLandedEvidence that self-healing and post-handoff
  paths also depend on.

LOW

- Alpine/busybox ps fallback: prepare-commit-msg hook reads /proc/$PPID/cmdline
  if 'ps -o args=' returns empty (busybox ps often lacks '-o args=' support).
- New backstop test variant 'FN-5345: empty-own-diff fast-path fires even
  when branch is registered to two worktrees' reproduces the actual FN-5345
  production wedge geometry where fusion/<id> was double-registered to two
  worktrees.

Tests
  - Full @fusion/engine suite: 448 files / 5881 tests / 9 skipped, all green
  - pnpm lint green, pnpm build green
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 17:54:26 -07:00
parent 1983dac6e4
commit 8e67404680
8 changed files with 464 additions and 205 deletions

View File

@@ -4,7 +4,7 @@
Engine reliability: prevent the FN-5345 in-review wedge class. 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. - 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. Amend detection scans `ps -o args=` (with `/proc/$PPID/cmdline` fallback for Alpine/busybox) tokenized, stopping at the first message-supplying flag (`-m`, `-F`, `--message`, `--file`) so a commit message containing the substring `--amend` cannot bypass the guard.
- 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. - 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. The fast-path best-effort cleans up the stranded worktree and `fusion/<id>` branch so empty-own-diff residuals do not accumulate.
- `classifyOwnedLandedEvidence` also detects the empty-own-diff case and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. - `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. - 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. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two new audit subtypes (`merge:reuse-fallback-pruned-stale-registration`, `merge:reuse-fallback-reused-existing-registration`) replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.

View File

@@ -217,7 +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. - **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). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. 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"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/<id>` branch so empty-own-diff residuals do not accumulate. 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. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases.
- **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`.

View File

@@ -122,6 +122,35 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true }); expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
}); });
// FN-5345/FN-5377 direct classifier coverage: empty-own-diff (aheadCount > 0
// but zero net diff vs merge-base) is logically equivalent to proven-no-op.
// This pairs with the merger's early fast-path and exercises the new branch
// in classifyOwnedLandedEvidence that self-healing and post-handoff finalize
// paths also rely on.
it("classifies proven-no-op for empty-own-diff branches (FN-5345/FN-5377)", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-empty-own-diff-"));
repos.push(repo);
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git commit --allow-empty -m 'init'");
const baseSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -b fusion/fn-empty-own-diff");
// 1 own commit with zero net tree change vs merge-base.
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-OWN-DIFF): handoff'");
const branchTipSha = git(repo, "git rev-parse HEAD");
expect(branchTipSha).not.toBe(baseSha); // aheadCount >= 1
git(repo, "git checkout main");
const classification = await classifyOwnedLandedEvidence(
repo,
{ id: "FN-EMPTY-OWN-DIFF", branch: "fusion/fn-empty-own-diff", baseCommitSha: baseSha } as Task,
{ mergeTargetBranch: "main" },
);
expect(classification).toEqual({ kind: "proven-no-op", baseRef: "main", ownDiffEmpty: true });
});
it("auto-finalizes proven no-op and clears stale modifiedFiles", async () => { it("auto-finalizes proven no-op and clears stale modifiedFiles", async () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-noop-finalize-")); const repo = mkdtempSync(join(tmpdir(), "fusion-merger-noop-finalize-"));
repos.push(repo); repos.push(repo);

View File

@@ -45,6 +45,17 @@ describe("prepare-commit-msg empty-commit guard (real git, FN-5345/FN-5377)", ()
expect(empty.stderr).toContain("refusing empty commit"); expect(empty.stderr).toContain("refusing empty commit");
expect(empty.stderr).toContain("FN-5345/FN-5377"); expect(empty.stderr).toContain("FN-5345/FN-5377");
// Review-finding regression: a commit message containing the substring
// '--amend' must NOT trick the parent-cmd tokenized check into allowing
// the empty commit. The original glob pattern (*' --amend'*) would have
// matched this; the tokenized check rejects it.
const sneaky = git(
worktreeDir,
"git commit --allow-empty -m 'feat(FN-5345): fix --amend handling'",
);
expect(sneaky.status).not.toBe(0);
expect(sneaky.stderr).toContain("refusing empty commit");
// --amend --no-edit (no staged changes, amend HEAD) is ALLOWED. // --amend --no-edit (no staged changes, amend HEAD) is ALLOWED.
const amendNoEdit = git(worktreeDir, "git commit --amend --no-edit"); const amendNoEdit = git(worktreeDir, "git commit --amend --no-edit");
expect(amendNoEdit.status).toBe(0); expect(amendNoEdit.status).toBe(0);

View File

@@ -586,4 +586,71 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
}, },
30_000, 30_000,
); );
// FN-5345/FN-5377 backstop variant: reproduce the actual production wedge
// geometry where `fusion/<id>` is registered to TWO worktrees simultaneously
// (e.g. faint-creek + hazy-quail in the FN-5345 incident). The early
// fast-path runs against projectRootDir and is immune to the worktree drift,
// so it must still finalize without acquiring any reuse handoff.
it.skipIf(!hasGit)(
"FN-5345: empty-own-diff fast-path fires even when branch is registered to two worktrees",
async () => {
const fixture = await makeReliabilityFixture({
taskId: "FN-5279-RI-DOUBLE-REG",
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 pathA = join(worktreeRoot, `${actualTask!.id.toLowerCase()}-a`);
const pathB = join(worktreeRoot, `${actualTask!.id.toLowerCase()}-b`);
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);
git(rootDir, `git commit --allow-empty -m 'test(${actualTask!.id}): verification-only handoff'`);
await fixture.checkout("master");
// Register branch at pathA, then force-register at pathB — reproduces
// FN-5345's two-worktree-one-branch state.
await mkdir(worktreeRoot, { recursive: true });
git(rootDir, `git worktree add ${JSON.stringify(pathA)} ${JSON.stringify(branch)}`);
git(rootDir, `git worktree add -f ${JSON.stringify(pathB)} ${JSON.stringify(branch)}`);
// task.worktree points at one of them — doesn't matter which; the
// fast-path operates against projectRootDir.
await store.updateTask(task.id, { worktree: pathA, 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 auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
// No reuse-handoff lifecycle event should have fired — the fast-path
// ran first against projectRootDir.
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
expect(auditTypes).toContain("task:auto-recover-finalize-already-on-main");
} finally {
await fixture.cleanup();
}
},
30_000,
);
}); });

View File

@@ -6383,6 +6383,198 @@ export const mergerTestHooks = {
* regardless of pooling. On next task execution, the pooled worktree will * regardless of pooling. On next task execution, the pooled worktree will
* be acquired and prepared with a fresh branch via {@link WorktreePool.prepareForTask}. * be acquired and prepared with a fresh branch via {@link WorktreePool.prepareForTask}.
*/ */
/**
* FN-5345/FN-5377: early empty-own-diff finalize helper.
*
* Detects branches whose own commits introduce zero net tree change vs their
* merge-base with the integration target and finalizes them as no-op BEFORE
* any reuse-handoff acquisition runs. This unsticks tasks where a stale empty
* handoff commit + drifted worktree<->branch mapping would otherwise wedge
* the handoff gate with `registered-branch-mismatch` and escalate to
* `merge-deadlock-detected: verified content not on main`.
*
* Returns the finalized `MergeResult` when the fast-path fires, or `null`
* when it does not apply (branch missing, aheadCount === 0, non-empty diff,
* etc.) and the standard merge path should proceed.
*
* Scope:
* - Caller restricts to `reuse-task-worktree` integration mode.
* - Branch must exist, ahead of target by >= 1 commit, and
* `git diff --quiet <mergeBase>..<branchTip>` exits 0.
* - aheadCount === 0 (already-landed) is NOT handled here so the existing
* post-handoff `classifyOwnedLandedEvidence` path keeps its lease lifecycle.
* - On finalize, best-effort cleanup of the stranded `task.worktree` and
* `fusion/<id>` branch keeps `.worktrees/` and the branch namespace tidy.
*/
async function tryEarlyEmptyOwnDiffFinalize(input: {
task: Task;
taskId: string;
store: TaskStore;
audit: Pick<RunAuditor, "database">;
log: { warn: (m: string) => void; log: (m: string) => void };
projectRootDir: string;
mergeTargetBranch: string;
completeTask: (result: MergeResult) => Promise<void>;
}): Promise<MergeResult | null> {
const { task, taskId, store, audit, log, projectRootDir, mergeTargetBranch } = input;
const branch = task.branch || canonicalFusionBranchName(taskId);
// 1. Branch exists?
try {
await execAsync(
`git show-ref --verify --quiet ${quoteArg(`refs/heads/${branch}`)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
} catch {
return null;
}
// 2. aheadCount > 0?
let aheadCount: number;
try {
const { stdout } = await execAsync(
`git rev-list --count ${quoteArg(`${mergeTargetBranch}..${branch}`)}`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
);
const parsed = Number.parseInt(stdout.trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
aheadCount = parsed;
} catch {
return null;
}
// 3. merge-base resolvable?
let mergeBase: string;
try {
const { stdout } = await execAsync(
`git merge-base ${quoteArg(mergeTargetBranch)} ${quoteArg(branch)}`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
);
mergeBase = stdout.trim();
if (!mergeBase) return null;
} catch {
return null;
}
// 4. zero net diff vs merge-base?
try {
await execAsync(
`git diff --quiet ${quoteArg(`${mergeBase}..${branch}`)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
} catch {
// exit non-zero — diff exists, NOT empty-own-diff
return null;
}
const noOpReason = `early fast-path: branch ${branch} has ${aheadCount} own commit(s) but zero net diff vs merge-base of ${mergeTargetBranch}`;
const mergedAt = new Date().toISOString();
const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}),
mergeConfirmed: true,
noOpMerge: true,
noOpReason,
landedFiles: [],
mergedAt,
prNumber: task.prInfo?.number,
mergeTargetBranch,
};
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: mergeTargetBranch,
branch,
aheadCount,
mergeBase,
},
});
} catch (auditErr: unknown) {
log.warn(
`${taskId}: failed to emit empty-own-diff-early-fast-path audit: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`,
);
}
// FN-5345/FN-5377: best-effort cleanup of the stranded worktree + branch
// so .worktrees/ and the branch namespace do not accumulate empty-own-diff
// residuals indefinitely. Failures are non-fatal: the task is already done.
let worktreeRemoved = false;
let branchDeleted = false;
const stranded = task.worktree?.trim();
if (stranded && existsSync(stranded)) {
// FN-4811 safety: never remove a worktree currently owned by a different
// task. Same-task or unowned paths are eligible.
const activeRecord = activeSessionRegistry.lookupByPath(stranded);
if (activeRecord && activeRecord.taskId !== taskId) {
log.warn(
`${taskId}: skipping early-fast-path worktree cleanup — path ${stranded} is owned by ${activeRecord.taskId}`,
);
} else {
try {
await execAsync(
`git worktree remove --force ${quoteArg(stranded)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
worktreeRemoved = true;
} catch (removeErr) {
log.warn(
`${taskId}: failed to remove stranded worktree ${stranded} (non-fatal): ${removeErr instanceof Error ? removeErr.message : String(removeErr)}`,
);
}
}
}
try {
// Branch must be deleted from the project root, not from inside a
// worktree that may still be checked out to it.
await execAsync(
`git branch -D ${quoteArg(branch)}`,
{ cwd: projectRootDir, timeout: 30_000 },
);
branchDeleted = true;
} catch (delErr) {
log.warn(
`${taskId}: failed to delete stranded branch ${branch} (non-fatal): ${delErr instanceof Error ? delErr.message : String(delErr)}`,
);
}
if (worktreeRemoved || branchDeleted) {
try {
await store.updateTask(taskId, {
worktree: worktreeRemoved ? null : task.worktree,
branch: branchDeleted ? null : task.branch,
});
} catch (updateErr) {
log.warn(
`${taskId}: failed to clear worktree/branch pointers after early-fast-path cleanup (non-fatal): ${updateErr instanceof Error ? updateErr.message : String(updateErr)}`,
);
}
}
const result: MergeResult = {
task,
branch,
merged: true,
noOp: true,
worktreeRemoved,
branchDeleted,
mergeConfirmed: true,
noOpMerge: true,
noOpReason,
mergedAt,
mergeTargetBranch,
};
await input.completeTask(result);
return result;
}
export async function aiMergeTask( export async function aiMergeTask(
store: TaskStore, store: TaskStore,
rootDir: string, rootDir: string,
@@ -6445,6 +6637,8 @@ export async function aiMergeTask(
| "merge:reuse-handoff-released" | "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk" | "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:reuse-fallback-new-worktree" | "merge:reuse-fallback-new-worktree"
| "merge:reuse-fallback-pruned-stale-registration"
| "merge:reuse-fallback-reused-existing-registration"
| "branch:auto-canonicalize-case", | "branch:auto-canonicalize-case",
metadata: Record<string, unknown>, metadata: Record<string, unknown>,
target: string, target: string,
@@ -6479,115 +6673,20 @@ export async function aiMergeTask(
// handoff lease lifecycle and FN-5083 branch-rebind invariants are // handoff lease lifecycle and FN-5083 branch-rebind invariants are
// preserved. // preserved.
// - cwd-main integration mode (legacy / unit-test default) is unchanged. // - cwd-main integration mode (legacy / unit-test default) is unchanged.
const earlyFastPathEligible = settings.mergeIntegrationWorktree !== "cwd-main"; if (settings.mergeIntegrationWorktree !== "cwd-main") {
try { try {
if (!earlyFastPathEligible) throw new Error("skip-early-fast-path:not-reuse-mode"); const earlyResult = await tryEarlyEmptyOwnDiffFinalize({
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, task,
branch: earlyBranch, taskId,
merged: true, store,
noOp: true, audit,
worktreeRemoved: false, log: mergerLog,
branchDeleted: false, projectRootDir,
mergeConfirmed: true,
noOpMerge: true,
noOpReason,
mergedAt: mergeDetails.mergedAt,
mergeTargetBranch: mergeTarget.branch, mergeTargetBranch: mergeTarget.branch,
}; completeTask: (result) => completeTask(store, taskId, result),
await completeTask(store, taskId, result); });
return result; if (earlyResult) return earlyResult;
}
}
}
}
} catch (earlyErr: unknown) { } 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( mergerLog.warn(
`${taskId}: early empty-own-diff fast-path failed; falling through to standard merge path: ${earlyErr instanceof Error ? earlyErr.message : String(earlyErr)}`, `${taskId}: early empty-own-diff fast-path failed; falling through to standard merge path: ${earlyErr instanceof Error ? earlyErr.message : String(earlyErr)}`,
); );
@@ -6621,8 +6720,23 @@ export async function aiMergeTask(
// //
// If registered at a stale/missing path, run `git worktree prune` so the // If registered at a stale/missing path, run `git worktree prune` so the
// subsequent `worktree add -f` does not produce a duplicate admin entry. // subsequent `worktree add -f` does not produce a duplicate admin entry.
//
// Safety guards:
// - FN-4811 active-session: skip a match whose path is currently owned
// by a DIFFERENT task in `activeSessionRegistry`. Same-task or unowned
// paths are eligible for direct reuse.
// - FN-4954 pool-lease: when `recycleWorktrees=true` AND a pool is
// attached, skip the direct-reuse shortcut and fall through to
// `acquireTaskWorktree`, which integrates with `WorktreePool.acquire`
// so the pool's `leased` map stays consistent. Without that fall-through
// the new path would bypass pool bookkeeping and could collide with
// `PoolDoubleLeaseError`.
const expectedBranch = task.branch || canonicalFusionBranchName(taskId); const expectedBranch = task.branch || canonicalFusionBranchName(taskId);
const poolBypassRequired = Boolean(options.pool && settings.recycleWorktrees);
try { try {
if (poolBypassRequired) {
// Pool semantics require acquireTaskWorktree; skip direct-reuse.
} else {
const { stdout: porcelain } = await execAsync( const { stdout: porcelain } = await execAsync(
`git worktree list --porcelain`, `git worktree list --porcelain`,
{ cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 }, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 },
@@ -6648,6 +6762,7 @@ export async function aiMergeTask(
const matches = entries.filter((e) => (e.branch || "").toLowerCase() === expectedLower); const matches = entries.filter((e) => (e.branch || "").toLowerCase() === expectedLower);
let prunedAnyStaleRegistration = false; let prunedAnyStaleRegistration = false;
let reusableMatch: { path: string; branch: string } | null = null; let reusableMatch: { path: string; branch: string } | null = null;
const skippedForeignOwners: { path: string; ownerTaskId: string }[] = [];
for (const match of matches) { for (const match of matches) {
if (!match.path) continue; if (!match.path) continue;
const exists = existsSync(match.path); const exists = existsSync(match.path);
@@ -6655,6 +6770,12 @@ export async function aiMergeTask(
prunedAnyStaleRegistration = true; prunedAnyStaleRegistration = true;
continue; continue;
} }
// FN-4811: refuse to rebind onto a path owned by a different task.
const activeRecord = activeSessionRegistry.lookupByPath(match.path);
if (activeRecord && activeRecord.taskId !== taskId) {
skippedForeignOwners.push({ path: match.path, ownerTaskId: activeRecord.taskId });
continue;
}
const cls = await classifyTaskWorktree(projectRootDir, match.path); const cls = await classifyTaskWorktree(projectRootDir, match.path);
if (cls.ok) { if (cls.ok) {
reusableMatch = { path: match.path, branch: match.branch || expectedBranch }; reusableMatch = { path: match.path, branch: match.branch || expectedBranch };
@@ -6666,13 +6787,14 @@ export async function aiMergeTask(
try { try {
await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 }); await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 });
await emitReuseHandoffAuditEvent( await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-new-worktree", "merge:reuse-fallback-pruned-stale-registration",
{ {
taskId, taskId,
reason: "pruned-stale-branch-registration",
branch: expectedBranch, branch: expectedBranch,
diagnostics: { matches: matches.map((m) => ({ path: m.path, branch: m.branch })) }, diagnostics: {
prePrune: true, matches: matches.map((m) => ({ path: m.path, branch: m.branch })),
skippedForeignOwners,
},
}, },
projectRootDir, projectRootDir,
); );
@@ -6703,14 +6825,15 @@ export async function aiMergeTask(
}); });
await store.updateTask(taskId, { worktree: reusableMatch.path, branch: reusableMatch.branch }); await store.updateTask(taskId, { worktree: reusableMatch.path, branch: reusableMatch.branch });
await emitReuseHandoffAuditEvent( await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-new-worktree", "merge:reuse-fallback-reused-existing-registration",
{ {
taskId, taskId,
reason: `${reason}:reused-existing-registration`, reason,
branch: reusableMatch.branch, branch: reusableMatch.branch,
worktreePath: reusableMatch.path, worktreePath: reusableMatch.path,
source: "existing", source: "existing",
diagnostics, diagnostics,
skippedForeignOwners,
integrationRemote: integrationRemote ?? null, integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch, integrationBranch: mergeTarget.branch,
}, },
@@ -6725,6 +6848,7 @@ export async function aiMergeTask(
}); });
return; return;
} }
}
} catch (listErr) { } catch (listErr) {
mergerLog.warn( mergerLog.warn(
`${taskId}: git worktree list consult failed before reacquire; proceeding with fresh creation: ${listErr instanceof Error ? listErr.message : String(listErr)}`, `${taskId}: git worktree list consult failed before reacquire; proceeding with fresh creation: ${listErr instanceof Error ? listErr.message : String(listErr)}`,

View File

@@ -160,6 +160,8 @@ export type GitMutationType =
| "merge:reuse-handoff-released" | "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk" | "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:reuse-fallback-new-worktree" | "merge:reuse-fallback-new-worktree"
| "merge:reuse-fallback-pruned-stale-registration"
| "merge:reuse-fallback-reused-existing-registration"
| "merge:reuse-worktree-fresh-acquire" | "merge:reuse-worktree-fresh-acquire"
| "merge:reuse-worktree-fresh-acquired" | "merge:reuse-worktree-fresh-acquired"
| "merge:audit-failure" | "merge:audit-failure"

View File

@@ -117,10 +117,36 @@ esac
# 'git commit --amend -m "..."' reports source=message (not commit), so the # 'git commit --amend -m "..."' reports source=message (not commit), so the
# source arg alone cannot distinguish amend-with-new-message from # source arg alone cannot distinguish amend-with-new-message from
# --allow-empty -m. Inspect the parent process command line as a tiebreaker. # --allow-empty -m. Inspect the parent process command line as a tiebreaker.
#
# Sourcing:
# - 'ps -o args= -p $PPID' is POSIX (macOS, BSD, glibc Linux).
# - Alpine/busybox 'ps' may not support '-o args='; fall back to
# /proc/$PPID/cmdline (Linux including busybox).
#
# Matching: tokenize PARENT_CMD by whitespace and require an EXACT '--amend'
# token APPEARING BEFORE the first message-supplying flag ('-m', '-F',
# '--message', '--file', '--message=...', '--file=...'). 'ps -o args=' joins
# argv with spaces, so a commit message containing the substring '--amend'
# (e.g. -m 'fix --amend handling') re-tokenizes into a standalone '--amend'
# token — we must not be fooled by message content. Since '--amend' is a
# positional flag that always appears before the message args, stopping at
# the first message flag is reliable on both macOS ps and Linux
# /proc/$PPID/cmdline (which preserves argv boundaries with NUL separators).
PARENT_CMD=$(ps -o args= -p "$PPID" 2>/dev/null || echo "") PARENT_CMD=$(ps -o args= -p "$PPID" 2>/dev/null || echo "")
case "$PARENT_CMD" in if [ -z "$PARENT_CMD" ] && [ -r "/proc/$PPID/cmdline" ]; then
*' --amend'*|*' --amend '*) exit 0 ;; PARENT_CMD=$(tr '\0' ' ' < "/proc/$PPID/cmdline" 2>/dev/null || echo "")
fi
for tok in $PARENT_CMD; do
case "$tok" in
-m|-F|--message|--file|--message=*|--file=*)
# Message args start here; everything after this is user-controlled.
break
;;
--amend)
exit 0
;;
esac esac
done
GIT_DIR=$(git rev-parse --git-dir) GIT_DIR=$(git rev-parse --git-dir)
if [ -f "$GIT_DIR/MERGE_HEAD" ] \\ if [ -f "$GIT_DIR/MERGE_HEAD" ] \\