fix(FN-5304): guard against silent no-op landed-file attribution mismatches
- Add a merger guard that verifies source fusion/FN branch attribution when rebase capture reports zero own commits - Fail finalize with explicit no-op attribution mismatch handling instead of marking mergeConfirmed on ambiguous no-op ranges - Type and emit dedicated audit events for mismatch and source-ref-unavailable skip diagnostics - Expand branch attribution, merger, and reliability interaction tests to cover the full no-op guard matrix Fusion-Task-Id: FN-5304
This commit is contained in:
committed by
gsxdsm
parent
9011c2107e
commit
c5e9dc43f1
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "../branch-attribution.js";
|
||||
import {
|
||||
BranchAttributionError,
|
||||
SilentNoOpAttributionMismatchError,
|
||||
collectOwnTaskCommitsForRange,
|
||||
filterFilesToOwnTaskCommits,
|
||||
} from "../branch-attribution.js";
|
||||
|
||||
describe("FN-5039 branch-attribution", () => {
|
||||
it("returns empty attribution for empty range", async () => {
|
||||
@@ -153,6 +158,44 @@ describe("FN-5039 branch-attribution", () => {
|
||||
expect(result.foreignCommits).toEqual([]);
|
||||
});
|
||||
|
||||
it("FN-5304: collectOwnTaskCommitsForRange counts only attributable commits", async () => {
|
||||
const log = [
|
||||
"sha-a\x00fix(FN-5304): one\x00body\x1e",
|
||||
"sha-b\x00fix(FN-9999): foreign\x00body\x1e",
|
||||
"sha-c\x00chore: none\x00body\x1e",
|
||||
].join("");
|
||||
const execMock = vi.fn().mockResolvedValueOnce({ stdout: log });
|
||||
|
||||
const result = await collectOwnTaskCommitsForRange({
|
||||
worktreePath: "/tmp/wt",
|
||||
rangeRef: "base..fusion/fn-5304",
|
||||
taskId: "FN-5304",
|
||||
execAsyncImpl: execMock as never,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ownCommitCount: 1, ownCommitShas: ["sha-a"] });
|
||||
});
|
||||
|
||||
it("FN-5304: SilentNoOpAttributionMismatchError carries expected metadata", () => {
|
||||
const err = new SilentNoOpAttributionMismatchError({
|
||||
taskId: "FN-5304",
|
||||
recordedSha: "recorded123",
|
||||
rebaseMergeBaseSha: "base123",
|
||||
sourceBranchRef: "fusion/fn-5304",
|
||||
sourceBranchOwnCommitCount: 2,
|
||||
sourceBranchOwnCommitShas: ["own1", "own2"],
|
||||
});
|
||||
|
||||
expect(err.name).toBe("SilentNoOpAttributionMismatchError");
|
||||
expect(err.taskId).toBe("FN-5304");
|
||||
expect(err.recordedSha).toBe("recorded123");
|
||||
expect(err.rebaseMergeBaseSha).toBe("base123");
|
||||
expect(err.sourceBranchRef).toBe("fusion/fn-5304");
|
||||
expect(err.sourceBranchOwnCommitCount).toBe(2);
|
||||
expect(err.sourceBranchOwnCommitShas).toEqual(["own1", "own2"]);
|
||||
expect(err.message).toContain("silent no-op attribution mismatch");
|
||||
});
|
||||
|
||||
it("throws BranchAttributionError when git command fails", async () => {
|
||||
const execMock = vi.fn().mockRejectedValueOnce(new Error("fatal: bad revision"));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BranchAttributionError } from "../branch-attribution.js";
|
||||
import { BranchAttributionError, SilentNoOpAttributionMismatchError } from "../branch-attribution.js";
|
||||
import * as attributionModule from "../branch-attribution.js";
|
||||
import { createMockStore, mockedCreateFnAgent, mockedExecSync, mockedExistsSync, type Task } from "./merger-test-helpers.js";
|
||||
import * as mergerModule from "../merger.js";
|
||||
@@ -109,6 +109,107 @@ describe("FN-4646 aiMergeTask landedFiles capture", () => {
|
||||
expect(detailsUpdate?.[1].modifiedFiles).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ sourceOwnCommitCount: 0, expectedNoOp: true },
|
||||
{ sourceOwnCommitCount: 2, expectedNoOp: false },
|
||||
])("FN-5304: source tip attribution guard (sourceOwnCommitCount=$sourceOwnCommitCount)", async ({ sourceOwnCommitCount, expectedNoOp }) => {
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
files: [],
|
||||
foreignCommits: [],
|
||||
ownCommitCount: 0,
|
||||
ownCommitShas: [],
|
||||
rawDiffFileCount: 0,
|
||||
commitAttributions: [],
|
||||
});
|
||||
vi.spyOn(attributionModule, "collectOwnTaskCommitsForRange").mockResolvedValue({
|
||||
ownCommitCount: sourceOwnCommitCount,
|
||||
ownCommitShas: sourceOwnCommitCount > 0 ? ["own1", "own2"] : [],
|
||||
});
|
||||
|
||||
const capturePromise = mergerModule.captureRebaseLandedFilesForTask({
|
||||
rootDir: "/tmp/root",
|
||||
rebaseMergeBaseSha: "base123",
|
||||
recordedSha: "recorded123",
|
||||
taskId: "FN-5304",
|
||||
sourceBranchRef: "fusion/fn-5304",
|
||||
});
|
||||
|
||||
if (expectedNoOp) {
|
||||
const capture = await capturePromise;
|
||||
expect(capture.noOpVerifiedShortCircuit).toBe(true);
|
||||
expect(capture.landedFilesAttributionRestricted).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(capturePromise).rejects.toMatchObject({
|
||||
name: "SilentNoOpAttributionMismatchError",
|
||||
taskId: "FN-5304",
|
||||
recordedSha: "recorded123",
|
||||
rebaseMergeBaseSha: "base123",
|
||||
sourceBranchRef: "fusion/fn-5304",
|
||||
sourceBranchOwnCommitCount: 2,
|
||||
sourceBranchOwnCommitShas: ["own1", "own2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("FN-5304: source ref unavailable keeps no-op short-circuit and emits skip callback", async () => {
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
files: [],
|
||||
foreignCommits: [],
|
||||
ownCommitCount: 0,
|
||||
ownCommitShas: [],
|
||||
rawDiffFileCount: 0,
|
||||
commitAttributions: [],
|
||||
});
|
||||
vi.spyOn(attributionModule, "collectOwnTaskCommitsForRange").mockRejectedValue(new Error("missing ref"));
|
||||
const onNoOpGuardSkipped = vi.fn();
|
||||
|
||||
const capture = await mergerModule.captureRebaseLandedFilesForTask({
|
||||
rootDir: "/tmp/root",
|
||||
rebaseMergeBaseSha: "base123",
|
||||
recordedSha: "recorded123",
|
||||
taskId: "FN-5304",
|
||||
sourceBranchRef: "fusion/fn-5304",
|
||||
onNoOpGuardSkipped,
|
||||
});
|
||||
|
||||
expect(capture.noOpVerifiedShortCircuit).toBe(true);
|
||||
expect(onNoOpGuardSkipped).toHaveBeenCalledWith("source-ref-unavailable");
|
||||
});
|
||||
|
||||
it("FN-5304: aiMergeTask refuses no-op mismatch and parks task in failed in-review", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
files: [],
|
||||
foreignCommits: [],
|
||||
ownCommitCount: 0,
|
||||
ownCommitShas: [],
|
||||
rawDiffFileCount: 0,
|
||||
commitAttributions: [],
|
||||
});
|
||||
vi.spyOn(attributionModule, "collectOwnTaskCommitsForRange").mockResolvedValue({ ownCommitCount: 1, ownCommitShas: ["own1"] });
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const s = String(cmd);
|
||||
if (s.includes("rev-parse --verify")) return Buffer.from("abc123");
|
||||
if (s === "git rev-parse HEAD" || s.startsWith("git rev-parse HEAD ")) return "rebasesha123";
|
||||
if (s.includes("git log")) return "- feat: summary";
|
||||
if (s.includes("merge-base")) return Buffer.from("abc123");
|
||||
if (s.includes("rev-parse \"abc123\"")) return "rebasebase123";
|
||||
if (s.includes("rev-list --reverse \"rebasebase123..fusion/FN-4646\"")) return "";
|
||||
if (s.includes("status --porcelain")) return "";
|
||||
if (s.includes("rev-parse --git-path CHERRY_PICK_HEAD")) return ".git/CHERRY_PICK_HEAD";
|
||||
if (s.includes("rev-parse --git-path sequencer")) return ".git/sequencer";
|
||||
if (s.includes("branch -d") || s.includes("branch -D") || s.includes("worktree remove")) return Buffer.from("");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(mergerModule.aiMergeTask(store, "/tmp/root", "FN-4646")).rejects.toBeInstanceOf(SilentNoOpAttributionMismatchError);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-4646", "in-review", expect.any(Object));
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-4646", expect.objectContaining({ status: "failed" }));
|
||||
const detailsUpdate = (store.updateTask as any).mock.calls.find((call: any[]) => call[1]?.mergeDetails?.noOpVerifiedShortCircuit);
|
||||
expect(detailsUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sums shortstat across multiple own commits on rebase", async () => {
|
||||
const store = makeStore({ directMergeCommitStrategy: "always-rebase" });
|
||||
vi.spyOn(attributionModule, "filterFilesToOwnTaskCommits").mockResolvedValue({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { captureRebaseLandedFilesForTask, sumShortstatsForCommits } from "../../merger.js";
|
||||
import { filterFilesToOwnTaskCommits } from "../../branch-attribution.js";
|
||||
import { filterFilesToOwnTaskCommits, SilentNoOpAttributionMismatchError } from "../../branch-attribution.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
@@ -91,6 +91,32 @@ describeIfGit("FN-5103 reliability interaction: landed-files attribution", () =>
|
||||
expect(capture.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it("FN-5304: refuses no-op fast-path when source branch tip still carries own commits", async () => {
|
||||
const { repoDir, baseSha } = await initRepo("fn-5304-ri-");
|
||||
dirs.push(repoDir);
|
||||
const taskId = "FN-5304";
|
||||
|
||||
git(repoDir, `git checkout -b fusion/${taskId.toLowerCase()}`);
|
||||
await commitFile(repoDir, "task-owned-a.ts", "a\n", "fix(FN-5304): owned A", taskId);
|
||||
await commitFile(repoDir, "task-owned-b.ts", "b\n", "fix(FN-5304): owned B", taskId);
|
||||
|
||||
git(repoDir, "git checkout main");
|
||||
await commitFile(repoDir, "upstream-a.ts", "u\n", "fix(FN-9999): upstream A", "FN-9999");
|
||||
await commitFile(repoDir, "upstream-b.ts", "v\n", "fix(FN-9999): upstream B", "FN-9999");
|
||||
|
||||
const recordedSha = git(repoDir, "git rev-parse HEAD");
|
||||
|
||||
await expect(
|
||||
captureRebaseLandedFilesForTask({
|
||||
rootDir: repoDir,
|
||||
rebaseMergeBaseSha: baseSha,
|
||||
recordedSha,
|
||||
taskId,
|
||||
sourceBranchRef: `fusion/${taskId.toLowerCase()}`,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SilentNoOpAttributionMismatchError);
|
||||
});
|
||||
|
||||
it("surfaces attribution failure when git reads fail", async () => {
|
||||
const { repoDir, baseSha } = await initRepo("fn-5103-ri-");
|
||||
dirs.push(repoDir);
|
||||
|
||||
@@ -40,6 +40,35 @@ export class BranchAttributionError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SilentNoOpAttributionMismatchError extends Error {
|
||||
readonly taskId: string;
|
||||
readonly recordedSha: string;
|
||||
readonly rebaseMergeBaseSha: string;
|
||||
readonly sourceBranchRef: string;
|
||||
readonly sourceBranchOwnCommitCount: number;
|
||||
readonly sourceBranchOwnCommitShas: string[];
|
||||
|
||||
constructor(params: {
|
||||
taskId: string;
|
||||
recordedSha: string;
|
||||
rebaseMergeBaseSha: string;
|
||||
sourceBranchRef: string;
|
||||
sourceBranchOwnCommitCount: number;
|
||||
sourceBranchOwnCommitShas: string[];
|
||||
}) {
|
||||
super(
|
||||
`silent no-op attribution mismatch: ${params.sourceBranchRef} carries ${params.sourceBranchOwnCommitCount} attributable commit(s) for ${params.taskId} not present in recorded head ${params.recordedSha}`,
|
||||
);
|
||||
this.name = "SilentNoOpAttributionMismatchError";
|
||||
this.taskId = params.taskId;
|
||||
this.recordedSha = params.recordedSha;
|
||||
this.rebaseMergeBaseSha = params.rebaseMergeBaseSha;
|
||||
this.sourceBranchRef = params.sourceBranchRef;
|
||||
this.sourceBranchOwnCommitCount = params.sourceBranchOwnCommitCount;
|
||||
this.sourceBranchOwnCommitShas = params.sourceBranchOwnCommitShas;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BranchAttributionOptions {
|
||||
worktreePath: string;
|
||||
baseRef: string;
|
||||
@@ -48,6 +77,13 @@ export interface BranchAttributionOptions {
|
||||
execAsyncImpl?: typeof execAsync;
|
||||
}
|
||||
|
||||
export interface BranchRangeAttributionOptions {
|
||||
worktreePath: string;
|
||||
rangeRef: string;
|
||||
taskId: string;
|
||||
execAsyncImpl?: typeof execAsync;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -104,6 +140,56 @@ function taskIdsMatch(a: string | null, b: string): boolean {
|
||||
return a.toUpperCase() === b.toUpperCase();
|
||||
}
|
||||
|
||||
export async function collectOwnTaskCommitsForRange(opts: BranchRangeAttributionOptions): Promise<{ ownCommitCount: number; ownCommitShas: string[] }> {
|
||||
const execImpl = opts.execAsyncImpl ?? execAsync;
|
||||
let logOutput: string;
|
||||
try {
|
||||
const result = await execImpl(
|
||||
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(opts.rangeRef)}`,
|
||||
{
|
||||
cwd: opts.worktreePath,
|
||||
encoding: "utf-8",
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
},
|
||||
);
|
||||
logOutput = result.stdout;
|
||||
} catch (error) {
|
||||
const stderr =
|
||||
typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
|
||||
? error.stderr.trim()
|
||||
: String(error);
|
||||
throw new BranchAttributionError(
|
||||
`git command failed: git log --format=%H%x00%s%x00%B%x1e ${opts.rangeRef} (${stderr || "no stderr"})`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!logOutput.trim()) {
|
||||
return { ownCommitCount: 0, ownCommitShas: [] };
|
||||
}
|
||||
|
||||
const ownCommitShas: string[] = [];
|
||||
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
|
||||
for (const record of records) {
|
||||
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
|
||||
if (!sha) {
|
||||
throw new BranchAttributionError("malformed git log output: missing commit sha");
|
||||
}
|
||||
const body = bodyParts.join("\x00");
|
||||
const trailerAttributedTaskId = extractAttributedTaskId(body);
|
||||
const subjectAttribution = trailerAttributedTaskId
|
||||
? { attributedTaskId: null, source: "none" as const }
|
||||
: extractTaskIdFromSubject(subject);
|
||||
const attributedTaskId = trailerAttributedTaskId ?? subjectAttribution.attributedTaskId;
|
||||
if (taskIdsMatch(attributedTaskId, opts.taskId)) {
|
||||
ownCommitShas.push(sha);
|
||||
}
|
||||
}
|
||||
|
||||
return { ownCommitCount: ownCommitShas.length, ownCommitShas };
|
||||
}
|
||||
|
||||
export async function filterFilesToOwnTaskCommits(opts: BranchAttributionOptions): Promise<AttributionResult> {
|
||||
const execImpl = opts.execAsyncImpl ?? execAsync;
|
||||
const runGit = async (command: string): Promise<string> => {
|
||||
|
||||
@@ -35,7 +35,11 @@ import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||
import { canonicalFusionBranchName } from "./worktree-names.js";
|
||||
import { filterFilesToOwnTaskCommits } from "./branch-attribution.js";
|
||||
import {
|
||||
collectOwnTaskCommitsForRange,
|
||||
filterFilesToOwnTaskCommits,
|
||||
SilentNoOpAttributionMismatchError,
|
||||
} from "./branch-attribution.js";
|
||||
import { hostname } from "node:os";
|
||||
import {
|
||||
buildTaskLineageTrailer,
|
||||
@@ -5151,7 +5155,9 @@ export async function captureRebaseLandedFilesForTask(params: {
|
||||
rebaseMergeBaseSha: string;
|
||||
recordedSha: string;
|
||||
taskId: string;
|
||||
sourceBranchRef?: string;
|
||||
onAttributionFailure?: (message: string) => Promise<void> | void;
|
||||
onNoOpGuardSkipped?: (reason: "source-ref-unavailable") => Promise<void> | void;
|
||||
attributionExecAsyncImpl?: (command: string, options: { cwd?: string; encoding?: BufferEncoding; maxBuffer?: number }) => Promise<{ stdout: string; stderr: string }>;
|
||||
}): Promise<{
|
||||
landedFiles: string[];
|
||||
@@ -5162,7 +5168,16 @@ export async function captureRebaseLandedFilesForTask(params: {
|
||||
landedFilesAttributionRestricted?: boolean;
|
||||
landedFilesCaptureFallback?: MergeDetails["landedFilesCaptureFallback"];
|
||||
}> {
|
||||
const { rootDir, rebaseMergeBaseSha, recordedSha, taskId, onAttributionFailure, attributionExecAsyncImpl } = params;
|
||||
const {
|
||||
rootDir,
|
||||
rebaseMergeBaseSha,
|
||||
recordedSha,
|
||||
taskId,
|
||||
sourceBranchRef,
|
||||
onAttributionFailure,
|
||||
onNoOpGuardSkipped,
|
||||
attributionExecAsyncImpl,
|
||||
} = params;
|
||||
try {
|
||||
const attribution = await filterFilesToOwnTaskCommits({
|
||||
worktreePath: rootDir,
|
||||
@@ -5171,6 +5186,33 @@ export async function captureRebaseLandedFilesForTask(params: {
|
||||
execAsyncImpl: attributionExecAsyncImpl as any,
|
||||
});
|
||||
if (attribution.ownCommitCount === 0) {
|
||||
if (sourceBranchRef && sourceBranchRef !== recordedSha) {
|
||||
const sourceRange = `${rebaseMergeBaseSha}..${sourceBranchRef}`;
|
||||
try {
|
||||
const sourceAttribution = await collectOwnTaskCommitsForRange({
|
||||
worktreePath: rootDir,
|
||||
rangeRef: sourceRange,
|
||||
taskId,
|
||||
execAsyncImpl: attributionExecAsyncImpl as any,
|
||||
});
|
||||
if (sourceAttribution.ownCommitCount > 0) {
|
||||
throw new SilentNoOpAttributionMismatchError({
|
||||
taskId,
|
||||
recordedSha,
|
||||
rebaseMergeBaseSha,
|
||||
sourceBranchRef,
|
||||
sourceBranchOwnCommitCount: sourceAttribution.ownCommitCount,
|
||||
sourceBranchOwnCommitShas: sourceAttribution.ownCommitShas,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SilentNoOpAttributionMismatchError) {
|
||||
throw error;
|
||||
}
|
||||
await onNoOpGuardSkipped?.("source-ref-unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
landedFiles: [],
|
||||
filesChanged: 0,
|
||||
@@ -5191,6 +5233,9 @@ export async function captureRebaseLandedFilesForTask(params: {
|
||||
landedFilesAttributionRestricted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SilentNoOpAttributionMismatchError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (onAttributionFailure) {
|
||||
await onAttributionFailure(message);
|
||||
@@ -8005,6 +8050,7 @@ export async function aiMergeTask(
|
||||
rebaseMergeBaseSha,
|
||||
recordedSha,
|
||||
taskId,
|
||||
sourceBranchRef: branch,
|
||||
onAttributionFailure: async (message) => {
|
||||
mergerLog.warn(`${taskId}: landed-files attribution failed (Error), falling back to full-range capture (${message})`);
|
||||
await store.appendAgentLog(
|
||||
@@ -8015,6 +8061,14 @@ export async function aiMergeTask(
|
||||
"merger",
|
||||
);
|
||||
},
|
||||
onNoOpGuardSkipped: async (reason) => {
|
||||
mergerLog.warn(`${taskId}: no-op fast-path guard skipped — source branch ref unavailable`);
|
||||
await (audit as any).database({
|
||||
type: "merge:no-op-attribution-mismatch-skipped",
|
||||
target: taskId,
|
||||
metadata: { reason },
|
||||
});
|
||||
},
|
||||
});
|
||||
landedFiles = capture.landedFiles;
|
||||
filesChanged = capture.filesChanged;
|
||||
@@ -8040,7 +8094,29 @@ export async function aiMergeTask(
|
||||
landedFiles = Array.from(new Set(parsedLandedFiles));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (captureError) {
|
||||
if (captureError instanceof SilentNoOpAttributionMismatchError) {
|
||||
mergerLog.error(
|
||||
`[merger] ${taskId}: refused no-op fast-path — branch tip carries ${captureError.sourceBranchOwnCommitCount} attributable own commits not present in rebased HEAD`,
|
||||
);
|
||||
await (audit as any).database({
|
||||
type: "merge:no-op-attribution-mismatch",
|
||||
target: taskId,
|
||||
metadata: {
|
||||
recordedSha: captureError.recordedSha,
|
||||
rebaseMergeBaseSha: captureError.rebaseMergeBaseSha,
|
||||
sourceBranchRef: captureError.sourceBranchRef,
|
||||
sourceBranchOwnCommitCount: captureError.sourceBranchOwnCommitCount,
|
||||
sourceBranchOwnCommitShas: captureError.sourceBranchOwnCommitShas,
|
||||
},
|
||||
});
|
||||
await store.updateTask(taskId, {
|
||||
status: "failed",
|
||||
error: captureError.message,
|
||||
});
|
||||
await store.moveTask(taskId, "in-review", { preserveProgress: true, moveSource: "engine" } as any);
|
||||
throw captureError;
|
||||
}
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
@@ -8116,6 +8192,9 @@ export async function aiMergeTask(
|
||||
if (err instanceof SquashAuditError || err?.name === "SquashAuditError") {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof SilentNoOpAttributionMismatchError || err?.name === "SilentNoOpAttributionMismatchError") {
|
||||
throw err;
|
||||
}
|
||||
mergerLog.warn(`${taskId}: failed to collect/store merge details: ${err.message}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user