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

@@ -122,6 +122,35 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
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 () => {
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-noop-finalize-"));
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("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.
const amendNoEdit = git(worktreeDir, "git commit --amend --no-edit");
expect(amendNoEdit.status).toBe(0);

View File

@@ -586,4 +586,71 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
},
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
* 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(
store: TaskStore,
rootDir: string,
@@ -6445,6 +6637,8 @@ export async function aiMergeTask(
| "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk"
| "merge:reuse-fallback-new-worktree"
| "merge:reuse-fallback-pruned-stale-registration"
| "merge:reuse-fallback-reused-existing-registration"
| "branch:auto-canonicalize-case",
metadata: Record<string, unknown>,
target: string,
@@ -6479,115 +6673,20 @@ export async function aiMergeTask(
// 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;
if (settings.mergeIntegrationWorktree !== "cwd-main") {
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 {
const earlyResult = await tryEarlyEmptyOwnDiffFinalize({
task,
taskId,
store,
audit,
log: mergerLog,
projectRootDir,
mergeTargetBranch: mergeTarget.branch,
completeTask: (result) => completeTask(store, taskId, result),
});
if (earlyResult) return earlyResult;
} catch (earlyErr: unknown) {
mergerLog.warn(
`${taskId}: early empty-own-diff fast-path failed; falling through to standard merge path: ${earlyErr instanceof Error ? earlyErr.message : String(earlyErr)}`,
);
@@ -6621,109 +6720,134 @@ export async function aiMergeTask(
//
// If registered at a stale/missing path, run `git worktree prune` so the
// 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 poolBypassRequired = Boolean(options.pool && settings.recycleWorktrees);
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 (poolBypassRequired) {
// Pool semantics require acquireTaskWorktree; skip direct-reuse.
} else {
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) {
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;
const skippedForeignOwners: { path: string; ownerTaskId: string }[] = [];
for (const match of matches) {
if (!match.path) continue;
const exists = existsSync(match.path);
if (!exists) {
prunedAnyStaleRegistration = true;
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);
if (cls.ok) {
reusableMatch = { path: match.path, branch: match.branch || expectedBranch };
break;
}
prunedAnyStaleRegistration = true;
continue;
}
const cls = await classifyTaskWorktree(projectRootDir, match.path);
if (cls.ok) {
reusableMatch = { path: match.path, branch: match.branch || expectedBranch };
break;
if (prunedAnyStaleRegistration) {
try {
await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 });
await emitReuseHandoffAuditEvent(
"merge:reuse-fallback-pruned-stale-registration",
{
taskId,
branch: expectedBranch,
diagnostics: {
matches: matches.map((m) => ({ path: m.path, branch: m.branch })),
skippedForeignOwners,
},
},
projectRootDir,
);
} catch (pruneErr) {
mergerLog.warn(
`${taskId}: git worktree prune failed before reacquire: ${pruneErr instanceof Error ? pruneErr.message : String(pruneErr)}`,
);
}
}
prunedAnyStaleRegistration = true;
}
if (prunedAnyStaleRegistration) {
try {
await execAsync(`git worktree prune`, { cwd: projectRootDir, encoding: "utf-8", timeout: 30_000 });
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",
"merge:reuse-fallback-reused-existing-registration",
{
taskId,
reason: "pruned-stale-branch-registration",
branch: expectedBranch,
diagnostics: { matches: matches.map((m) => ({ path: m.path, branch: m.branch })) },
prePrune: true,
reason,
branch: reusableMatch.branch,
worktreePath: reusableMatch.path,
source: "existing",
diagnostics,
skippedForeignOwners,
integrationRemote: integrationRemote ?? null,
integrationBranch: mergeTarget.branch,
},
projectRootDir,
reusableMatch.path,
);
} 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",
{
await store.recordActivity({
type: "task:merge-worktree-reacquired",
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;
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(

View File

@@ -160,6 +160,8 @@ export type GitMutationType =
| "merge:reuse-handoff-released"
| "merge:reuse-handoff-deferred-to-worktrunk"
| "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-acquired"
| "merge:audit-failure"

View File

@@ -117,10 +117,36 @@ 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.
#
# 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 "")
case "$PARENT_CMD" in
*' --amend'*|*' --amend '*) exit 0 ;;
esac
if [ -z "$PARENT_CMD" ] && [ -r "/proc/$PPID/cmdline" ]; then
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
done
GIT_DIR=$(git rev-parse --git-dir)
if [ -f "$GIT_DIR/MERGE_HEAD" ] \\