fix(engine): harden merge finalize SQLite bind and recover bare merge subjects

Two compounding bugs surfaced as `feat(FN-XXXX): merge fusion/fn-XXXX`
commits landing on main:

1. The verification-fix finalize path could bind `undefined` to SQLite
   parameter 4 (`commitSha`) of `upsertTaskCommitAssociation` under the
   parallel-attempt race, failing the merge over a denormalization
   write after the commit had already landed. Centralized both
   duplicated callsites into a helper that validates each git output
   before binding.

2. Four self-healing/aiMergeTask recovery sites copied
   `classification.commit.subject` verbatim into
   `mergeDetails.mergeCommitMessage`, persisting the tier-3
   `merge ${branch}` fallback when it ended up on the landed commit.
   New `regenerateBareMergeSubject` helper detects the bare pattern
   and rebuilds a descriptive subject via the AI summarizer. Cosmetic
   only — the git commit is not amended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-26 21:55:29 -07:00
parent 6a6c6fdbfd
commit 4e4830f592
4 changed files with 190 additions and 31 deletions

View File

@@ -0,0 +1,8 @@
---
"@fusion/engine": patch
---
Fix two bugs that compounded to produce bare `feat(FN-XXXX): merge fusion/fn-XXXX` merge commits in the dashboard:
- **`Provided value cannot be bound to SQLite parameter 4` (TypeError) mid-merge**: the verification-fix finalize path called `upsertTaskCommitAssociation` with `commitSha` derived from a `git rev-parse HEAD` whose surrounding exec could reject under the parallel-attempt race, leaving `commitSha` undefined when bound to positional parameter 4. Extracted both duplicated callsites into a `recordCommitAssociationFromHead` helper that catches exec failures and validates each git output is non-empty before binding. The merge no longer fails over a denormalized lookup write when the commit itself landed cleanly.
- **Bare-fallback subjects persisted into `mergeDetails.mergeCommitMessage`**: when `buildDeterministicMergeMessage`'s tier-3 fallback (`merge ${branch}`) made it onto a landed commit, the four `classification.commit.subject` / `landedCommit.subject` recovery sites in `self-healing.ts` and `aiMergeTask` copied that bare subject verbatim into `mergeDetails`. Added `regenerateBareMergeSubject` (in a new `merger-bare-subject.ts` module to keep self-healing's import graph narrow) which detects the bare pattern via `BARE_MERGE_SUBJECT_RE` and regenerates a descriptive subject from the landed commit's diff stat via the existing AI commit-subject summarizer. Cosmetic only — the git commit is never amended; the regenerated subject only populates the persisted `mergeDetails` and the in-process `MergeResult`. Gated by `settings.useAiMergeCommitSummary`.

View File

@@ -0,0 +1,74 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import {
resolveTitleSummarizerSettingsModel,
summarizeCommitSubject,
type Settings,
} from "@fusion/core";
import { mergerLog } from "./logger.js";
const execAsync = promisify(exec);
function quoteArg(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}
/**
* Bare-fallback merge subject pattern from `buildDeterministicMergeMessage`'s
* tier-3 branch (e.g. `feat(FN-5592): merge fusion/fn-5592`). Used to detect
* commits whose subject is the last-resort `merge ${branch}` form so callers
* can regenerate a descriptive subject for `mergeDetails.mergeCommitMessage`.
*/
export const BARE_MERGE_SUBJECT_RE = /^[a-z]+(?:\([^)]+\))?!?:\s*merge\s+\S+\s*$/i;
/**
* If `subject` is the bare tier-3 fallback (`feat(TASK): merge <branch>`),
* regenerate a descriptive subject from the landed commit's diff stat via
* the AI commit-subject summarizer. Returns the original subject when the
* pattern doesn't match, AI summaries are disabled, or regeneration fails.
*
* Cosmetic only: never amends the git commit. Callers use the result to
* populate `mergeDetails.mergeCommitMessage` so dashboards display a
* meaningful subject even when the on-commit subject is the bare fallback.
*
* Lives in its own module (not `merger.ts`) so importers like `self-healing.ts`
* don't pay the cost of merger's transitive graph — tests that mock
* `node:child_process` narrowly would otherwise break.
*/
export async function regenerateBareMergeSubject(params: {
subject: string | undefined;
commitSha: string;
branch: string;
taskId: string;
rootDir: string;
settings: Settings;
}): Promise<string | undefined> {
const { subject, commitSha, branch, taskId, rootDir, settings } = params;
if (!subject || !BARE_MERGE_SUBJECT_RE.test(subject)) return subject;
if (!settings.useAiMergeCommitSummary) return subject;
if (!commitSha) return subject;
try {
const { stdout: diffStat } = await execAsync(
`git show ${quoteArg(commitSha)} --stat --format=`,
{ cwd: rootDir, encoding: "utf-8" },
);
const trimmedStat = diffStat.trim();
if (trimmedStat.length === 0) return subject;
const resolved = resolveTitleSummarizerSettingsModel(settings);
const regenerated = await summarizeCommitSubject(
trimmedStat,
rootDir,
resolved.provider,
resolved.modelId,
{ branch, taskId },
);
if (!regenerated) return subject;
const prefixMatch = subject.match(/^([a-z]+(?:\([^)]+\))?!?:)/i);
const prefix = prefixMatch ? prefixMatch[1] : `feat(${taskId}):`;
return `${prefix} ${regenerated}`;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: bare-merge-subject regeneration failed (${message})`);
return subject;
}
}

View File

@@ -94,6 +94,8 @@ import { classifyTaskWorktree, getRegisteredWorktreeBranches, RemovalReason, rem
import { activeSessionRegistry } from "./active-session-registry.js"; import { activeSessionRegistry } from "./active-session-registry.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js"; import { mergerLog } from "./logger.js";
import { regenerateBareMergeSubject } from "./merger-bare-subject.js";
export { regenerateBareMergeSubject, BARE_MERGE_SUBJECT_RE } from "./merger-bare-subject.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js"; import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
import { isContextLimitError } from "./context-limit-detector.js"; import { isContextLimitError } from "./context-limit-detector.js";
import { withRateLimitRetry } from "./rate-limit-retry.js"; import { withRateLimitRetry } from "./rate-limit-retry.js";
@@ -3777,6 +3779,54 @@ async function generateAiMergeSubject(
} }
} }
/**
* Capture HEAD's sha, subject, and authored timestamp via git, then persist a
* canonical lineage-trailer association. Validates each git output is a
* non-empty string before binding to SQLite — `upsertTaskCommitAssociation`
* binds `commitSha` to positional parameter 4, and any `undefined` or empty
* value would otherwise raise `TypeError: Provided value cannot be bound to
* SQLite parameter 4` mid-merge (observed under parallel finalize-attempt
* races where one of the three `git` calls aborted before the others).
*
* Returns silently and logs a warning when validation fails — the association
* is a denormalized convenience for lineage lookups, not a correctness
* invariant, so a missed write must not block the merge.
*/
async function recordCommitAssociationFromHead(
store: TaskStore,
rootDir: string,
taskId: string,
lineageId: string,
): Promise<void> {
let sha = "";
let subject = "";
let authoredAt = "";
try {
sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim();
subject = (await execAsync("git log -1 --format=%s HEAD", { cwd: rootDir })).stdout.trim();
authoredAt = (await execAsync("git log -1 --format=%aI HEAD", { cwd: rootDir })).stdout.trim();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: skipped commit-association write — git inspection failed (${message})`);
return;
}
if (!sha || !subject || !authoredAt) {
mergerLog.warn(
`${taskId}: skipped commit-association write — empty git output (sha=${sha.length}, subject=${subject.length}, authoredAt=${authoredAt.length})`,
);
return;
}
await store.upsertTaskCommitAssociation({
taskLineageId: lineageId,
taskIdSnapshot: taskId,
commitSha: sha,
commitSubject: subject,
authoredAt,
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
});
}
/** /**
* Derive a non-AI subject summary from the branch's step commit log. The log * Derive a non-AI subject summary from the branch's step commit log. The log
* is `- subj1\n- subj2\n…` (most recent first). The naive "use lines[0]" choice * is `- subj1\n- subj2\n…` (most recent first). The naive "use lines[0]" choice
@@ -4435,18 +4485,7 @@ export async function commitOrAmendMergeWithFixes(
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
); );
if (store && lineageId) { if (store && lineageId) {
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim(); await recordCommitAssociationFromHead(store, rootDir, taskId, lineageId);
const subject = (await execAsync("git log -1 --format=%s HEAD", { cwd: rootDir })).stdout.trim();
const authoredAt = (await execAsync("git log -1 --format=%aI HEAD", { cwd: rootDir })).stdout.trim();
await store.upsertTaskCommitAssociation({
taskLineageId: lineageId,
taskIdSnapshot: taskId,
commitSha: sha,
commitSubject: subject,
authoredAt,
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
});
} }
mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`); mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
return { ok: true, reason: "committed" }; return { ok: true, reason: "committed" };
@@ -4478,18 +4517,7 @@ export async function commitOrAmendMergeWithFixes(
{ cwd: rootDir, env: mergerCommitEnv() }, { cwd: rootDir, env: mergerCommitEnv() },
); );
if (store && lineageId) { if (store && lineageId) {
const sha = (await execAsync("git rev-parse HEAD", { cwd: rootDir })).stdout.trim(); await recordCommitAssociationFromHead(store, rootDir, taskId, lineageId);
const subject = (await execAsync("git log -1 --format=%s HEAD", { cwd: rootDir })).stdout.trim();
const authoredAt = (await execAsync("git log -1 --format=%aI HEAD", { cwd: rootDir })).stdout.trim();
await store.upsertTaskCommitAssociation({
taskLineageId: lineageId,
taskIdSnapshot: taskId,
commitSha: sha,
commitSubject: subject,
authoredAt,
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
});
} }
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`); mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
return { ok: true, reason: "committed" }; return { ok: true, reason: "committed" };
@@ -7871,13 +7899,21 @@ export async function aiMergeTask(
if (aheadInfo?.aheadCount === 0) { if (aheadInfo?.aheadCount === 0) {
const classification = await classifyOwnedLandedEvidence(rootDir, task, { mergeTargetBranch: aheadInfo.baseRef }); const classification = await classifyOwnedLandedEvidence(rootDir, task, { mergeTargetBranch: aheadInfo.baseRef });
if (classification.kind === "owned-commit") { if (classification.kind === "owned-commit") {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: classification.commit.subject,
commitSha: classification.commit.sha,
branch,
taskId,
rootDir,
settings,
});
const mergeDetails: MergeDetails = { const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}), ...(task.mergeDetails || {}),
commitSha: classification.commit.sha, commitSha: classification.commit.sha,
filesChanged: classification.commit.filesChanged, filesChanged: classification.commit.filesChanged,
insertions: classification.commit.insertions, insertions: classification.commit.insertions,
deletions: classification.commit.deletions, deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject, mergeCommitMessage,
mergeConfirmed: true, mergeConfirmed: true,
mergedAt: new Date().toISOString(), mergedAt: new Date().toISOString(),
prNumber: task.prInfo?.number, prNumber: task.prInfo?.number,
@@ -8132,13 +8168,21 @@ export async function aiMergeTask(
if (classification.kind === "owned-commit") { if (classification.kind === "owned-commit") {
const mergedAt = new Date().toISOString(); const mergedAt = new Date().toISOString();
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: classification.commit.subject,
commitSha: classification.commit.sha,
branch,
taskId,
rootDir,
settings,
});
await store.updateTask(taskId, { await store.updateTask(taskId, {
mergeDetails: { mergeDetails: {
commitSha: classification.commit.sha, commitSha: classification.commit.sha,
filesChanged: classification.commit.filesChanged, filesChanged: classification.commit.filesChanged,
insertions: classification.commit.insertions, insertions: classification.commit.insertions,
deletions: classification.commit.deletions, deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject, mergeCommitMessage,
mergedAt, mergedAt,
mergeConfirmed: true, mergeConfirmed: true,
prNumber: task.prInfo?.number, prNumber: task.prInfo?.number,
@@ -8152,7 +8196,7 @@ export async function aiMergeTask(
result.filesChanged = classification.commit.filesChanged; result.filesChanged = classification.commit.filesChanged;
result.insertions = classification.commit.insertions; result.insertions = classification.commit.insertions;
result.deletions = classification.commit.deletions; result.deletions = classification.commit.deletions;
result.mergeCommitMessage = classification.commit.subject; result.mergeCommitMessage = mergeCommitMessage;
result.mergedAt = mergedAt; result.mergedAt = mergedAt;
result.mergeTargetBranch = mergeTarget.branch; result.mergeTargetBranch = mergeTarget.branch;
result.mergeTargetSource = mergeTarget.source; result.mergeTargetSource = mergeTarget.source;

View File

@@ -48,6 +48,7 @@ import { resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js"; import { canonicalFusionBranchName } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js"; import { resolveIntegrationBranch } from "./integration-branch.js";
import type { OwnedLandedClassification } from "./merger.js"; import type { OwnedLandedClassification } from "./merger.js";
import { regenerateBareMergeSubject } from "./merger-bare-subject.js";
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js"; import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
import { import {
buildNtfyClickUrl, buildNtfyClickUrl,
@@ -4198,13 +4199,21 @@ export class SelfHealingManager {
const mergedAt = new Date().toISOString(); const mergedAt = new Date().toISOString();
if (classification.kind === "owned-commit") { if (classification.kind === "owned-commit") {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: classification.commit.subject,
commitSha: classification.commit.sha,
branch: task.branch ?? "",
taskId: task.id,
rootDir: this.options.rootDir,
settings,
});
const mergeDetails: MergeDetails = { const mergeDetails: MergeDetails = {
...(task.mergeDetails || {}), ...(task.mergeDetails || {}),
commitSha: classification.commit.sha, commitSha: classification.commit.sha,
filesChanged: classification.commit.filesChanged, filesChanged: classification.commit.filesChanged,
insertions: classification.commit.insertions, insertions: classification.commit.insertions,
deletions: classification.commit.deletions, deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject, mergeCommitMessage,
mergeConfirmed: true, mergeConfirmed: true,
mergedAt, mergedAt,
mergeTargetBranch: ahead.baseRef, mergeTargetBranch: ahead.baseRef,
@@ -4292,6 +4301,14 @@ export class SelfHealingManager {
for (const task of candidates) { for (const task of candidates) {
const classification = await classifyOwnedLandedEvidenceForSelfHealing(this.options.rootDir, task, mergeTargetBranch); const classification = await classifyOwnedLandedEvidenceForSelfHealing(this.options.rootDir, task, mergeTargetBranch);
if (classification.kind === "owned-commit") { if (classification.kind === "owned-commit") {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: classification.commit.subject,
commitSha: classification.commit.sha,
branch: task.branch ?? "",
taskId: task.id,
rootDir: this.options.rootDir,
settings,
});
await this.store.updateTask(task.id, { await this.store.updateTask(task.id, {
mergeDetails: { mergeDetails: {
...(task.mergeDetails || {}), ...(task.mergeDetails || {}),
@@ -4299,7 +4316,7 @@ export class SelfHealingManager {
filesChanged: classification.commit.filesChanged, filesChanged: classification.commit.filesChanged,
insertions: classification.commit.insertions, insertions: classification.commit.insertions,
deletions: classification.commit.deletions, deletions: classification.commit.deletions,
mergeCommitMessage: classification.commit.subject, mergeCommitMessage,
}, },
}); });
await this.recordIntegrityAudit(task.id, "task:integrity-reconcile-modified-files", { await this.recordIntegrityAudit(task.id, "task:integrity-reconcile-modified-files", {
@@ -5110,13 +5127,21 @@ export class SelfHealingManager {
const landedCommit = await this.findLandedTaskCommit(task); const landedCommit = await this.findLandedTaskCommit(task);
if (landedCommit) { if (landedCommit) {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: landedCommit.subject,
commitSha: landedCommit.sha,
branch: task.branch ?? "",
taskId: task.id,
rootDir: this.options.rootDir,
settings,
});
const mergeDetails: MergeDetails = { const mergeDetails: MergeDetails = {
commitSha: landedCommit.sha, commitSha: landedCommit.sha,
rebaseBaseSha: landedCommit.rebaseBaseSha, rebaseBaseSha: landedCommit.rebaseBaseSha,
filesChanged: landedCommit.filesChanged, filesChanged: landedCommit.filesChanged,
insertions: landedCommit.insertions, insertions: landedCommit.insertions,
deletions: landedCommit.deletions, deletions: landedCommit.deletions,
mergeCommitMessage: landedCommit.subject, mergeCommitMessage,
mergedAt: new Date().toISOString(), mergedAt: new Date().toISOString(),
mergeConfirmed: true, mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number, prNumber: getPrimaryPrInfo(task)?.number,
@@ -5520,13 +5545,21 @@ export class SelfHealingManager {
try { try {
const landedCommit = await this.findLandedTaskCommit(task); const landedCommit = await this.findLandedTaskCommit(task);
if (landedCommit) { if (landedCommit) {
const mergeCommitMessage = await regenerateBareMergeSubject({
subject: landedCommit.subject,
commitSha: landedCommit.sha,
branch: task.branch ?? "",
taskId: task.id,
rootDir: this.options.rootDir,
settings,
});
const mergeDetails: MergeDetails = { const mergeDetails: MergeDetails = {
commitSha: landedCommit.sha, commitSha: landedCommit.sha,
rebaseBaseSha: landedCommit.rebaseBaseSha, rebaseBaseSha: landedCommit.rebaseBaseSha,
filesChanged: landedCommit.filesChanged, filesChanged: landedCommit.filesChanged,
insertions: landedCommit.insertions, insertions: landedCommit.insertions,
deletions: landedCommit.deletions, deletions: landedCommit.deletions,
mergeCommitMessage: landedCommit.subject, mergeCommitMessage,
mergedAt: new Date().toISOString(), mergedAt: new Date().toISOString(),
mergeConfirmed: true, mergeConfirmed: true,
prNumber: getPrimaryPrInfo(task)?.number, prNumber: getPrimaryPrInfo(task)?.number,