feat(FN-3862): add merge attribution changeset
Completes Step 6 of FN-3862 with documentation and delivery, including a changeset for the `@runfusion/fusion` package. Also removes an unused line from the self-healing module. Fusion-Task-Id: FN-3862
This commit is contained in:
5
.changeset/FN-3862-merge-attribution.md
Normal file
5
.changeset/FN-3862-merge-attribution.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Stop overwriting canonical merge commit SHAs on already-done tasks during self-healing reconciliation. Confirmed `mergeDetails.commitSha` is now preserved as authoritative; rediscovery for unconfirmed done tasks prefers the earliest owned commit so the original merge commit wins over later follow-up commits sharing the same `Fusion-Task-Id` trailer.
|
||||||
@@ -56,12 +56,16 @@ vi.mock("../worktree-pool.js", () => ({
|
|||||||
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../logger.js", () => ({
|
const { selfHealingLoggerMock } = vi.hoisted(() => ({
|
||||||
createLogger: vi.fn((_name: string) => ({
|
selfHealingLoggerMock: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
})),
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../logger.js", () => ({
|
||||||
|
createLogger: vi.fn((_name: string) => selfHealingLoggerMock),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { SelfHealingManager } from "../self-healing.js";
|
import { SelfHealingManager } from "../self-healing.js";
|
||||||
@@ -84,11 +88,7 @@ type MockLogger = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getSelfHealingLogger(): MockLogger {
|
function getSelfHealingLogger(): MockLogger {
|
||||||
const idx = mockedCreateLogger.mock.calls.findIndex(([name]) => name === "self-healing");
|
return selfHealingLoggerMock;
|
||||||
if (idx === -1) {
|
|
||||||
throw new Error("self-healing logger was not created");
|
|
||||||
}
|
|
||||||
return mockedCreateLogger.mock.results[idx]?.value as MockLogger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mock helpers ────────────────────────────────────────────────────
|
// ── Mock helpers ────────────────────────────────────────────────────
|
||||||
@@ -3286,32 +3286,30 @@ describe("stale triage processing eviction before recovery", () => {
|
|||||||
// ── Maintenance cycle concurrency ──────────────────────────────────
|
// ── Maintenance cycle concurrency ──────────────────────────────────
|
||||||
|
|
||||||
describe("recoverDoneTaskMergeMetadata", () => {
|
describe("recoverDoneTaskMergeMetadata", () => {
|
||||||
it("upgrades done task metadata to an owned landed commit", async () => {
|
it("FN-3862: confirmed task with reachable owned stored SHA preserves canonical commitSha", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
|
||||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "FN-3469",
|
id: "FN-3862",
|
||||||
column: "done",
|
column: "done",
|
||||||
paused: false,
|
paused: false,
|
||||||
baseCommitSha: "base",
|
mergeDetails: { commitSha: "merge1", mergeConfirmed: true },
|
||||||
mergeDetails: { commitSha: "sharedsha", mergeConfirmed: false },
|
|
||||||
modifiedFiles: ["AGENTS.md"],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mockedExecSync.mockImplementation((command) => {
|
mockedExecSync.mockImplementation((command) => {
|
||||||
const cmd = String(command);
|
const cmd = String(command);
|
||||||
if (cmd.includes("merge-base --is-ancestor sharedsha HEAD")) return "" as any;
|
if (cmd.includes("merge-base --is-ancestor 'merge1' HEAD")) return "" as any;
|
||||||
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b sharedsha")) {
|
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b 'merge1'")) {
|
||||||
return "sharedsha\u001ffix(FN-3468): other\u001fFusion-Task-Id: FN-3468" as any;
|
return "merge1\u001ffix(FN-3862): canonical merge\u001fFusion-Task-Id: FN-3862" as any;
|
||||||
}
|
}
|
||||||
if (cmd.includes("Fusion-Task-Id: FN-3469")) {
|
if (cmd.includes("show --shortstat --format= merge1")) {
|
||||||
return "a47b1e5\u001ffix(FN-3469): correct lazy-loaded views\n" as any;
|
return "3 files changed, 10 insertions(+), 1 deletions(-)" as any;
|
||||||
}
|
}
|
||||||
if (cmd.includes("show --shortstat --format= a47b1e5")) {
|
if (cmd.includes("Fusion-Task-Id: FN-3862")) {
|
||||||
return "2 files changed, 84 insertions(+), 2 deletions(-)" as any;
|
return "fix2\u001ffix(FN-3862): follow-up\n" as any;
|
||||||
}
|
}
|
||||||
return "" as any;
|
return "" as any;
|
||||||
});
|
});
|
||||||
@@ -3319,17 +3317,108 @@ describe("recoverDoneTaskMergeMetadata", () => {
|
|||||||
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
||||||
|
|
||||||
expect(repaired).toBe(1);
|
expect(repaired).toBe(1);
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3469", {
|
expect(store.updateTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-3862", {
|
||||||
mergeDetails: expect.objectContaining({
|
mergeDetails: expect.objectContaining({
|
||||||
commitSha: "a47b1e5",
|
commitSha: "merge1",
|
||||||
mergeConfirmed: true,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.stop();
|
manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears unowned shared SHA for done task when no owned landed commit exists", async () => {
|
it("FN-3862: confirmed task with unreachable stored SHA is preserved with warning", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "FN-3814",
|
||||||
|
column: "done",
|
||||||
|
paused: false,
|
||||||
|
mergeDetails: {
|
||||||
|
commitSha: "gone1234",
|
||||||
|
mergeConfirmed: true,
|
||||||
|
filesChanged: 1,
|
||||||
|
insertions: 1,
|
||||||
|
deletions: 0,
|
||||||
|
mergeCommitMessage: "feat(FN-3814): landed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((command) => {
|
||||||
|
const cmd = String(command);
|
||||||
|
if (cmd.includes("merge-base --is-ancestor gone1234 HEAD")) {
|
||||||
|
const err = new Error("not ancestor");
|
||||||
|
throw err as any;
|
||||||
|
}
|
||||||
|
if (cmd.includes("Fusion-Task-Id: FN-3814")) {
|
||||||
|
return "fix-later\u001ffix(FN-3814): later\n" as any;
|
||||||
|
}
|
||||||
|
return "" as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const warn = getSelfHealingLogger().warn;
|
||||||
|
warn.mockClear();
|
||||||
|
|
||||||
|
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
||||||
|
|
||||||
|
expect(repaired).toBe(0);
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalled();
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("gone1234"));
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3862: unconfirmed task with multiple owned commits picks earliest via --reverse", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
|
||||||
|
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "FN-3829",
|
||||||
|
column: "done",
|
||||||
|
paused: false,
|
||||||
|
baseCommitSha: "base",
|
||||||
|
mergeDetails: { commitSha: "old", mergeConfirmed: false },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation((command) => {
|
||||||
|
const cmd = String(command);
|
||||||
|
if (cmd.includes("merge-base --is-ancestor old HEAD")) {
|
||||||
|
throw new Error("not ancestor") as any;
|
||||||
|
}
|
||||||
|
if (cmd.includes("--reverse") && cmd.includes("Fusion-Task-Id: FN-3829")) {
|
||||||
|
return "mergeSha\u001ffix(FN-3829): merge\nfixupSha\u001ffix(FN-3829): follow-up\n" as any;
|
||||||
|
}
|
||||||
|
if (cmd.includes("Fusion-Task-Id: FN-3829")) {
|
||||||
|
return "fixupSha\u001ffix(FN-3829): follow-up\nmergeSha\u001ffix(FN-3829): merge\n" as any;
|
||||||
|
}
|
||||||
|
if (cmd.includes("show --shortstat --format= mergeSha")) {
|
||||||
|
return "2 files changed, 4 insertions(+), 1 deletions(-)" as any;
|
||||||
|
}
|
||||||
|
return "" as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
||||||
|
|
||||||
|
expect(repaired).toBe(1);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-3829", {
|
||||||
|
mergeDetails: expect.objectContaining({
|
||||||
|
commitSha: "mergeSha",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("--reverse"),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FN-3862: unconfirmed task with no owned landed commit clears unowned stored SHA", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
|
||||||
|
|||||||
@@ -470,7 +470,10 @@ export class SelfHealingManager {
|
|||||||
return Date.now() - updatedAt >= timeoutMs;
|
return Date.now() - updatedAt >= timeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findLandedTaskCommit(task: Task): Promise<LandedTaskCommit | null> {
|
private async findLandedTaskCommit(
|
||||||
|
task: Task,
|
||||||
|
options?: { preferEarliestOwnedCommit?: boolean },
|
||||||
|
): Promise<LandedTaskCommit | null> {
|
||||||
// Search strategies, tried in order of reliability:
|
// Search strategies, tried in order of reliability:
|
||||||
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
// 1. mergeDetails.commitSha — already stored by the merger; verify it's
|
||||||
// reachable from HEAD before trusting it.
|
// reachable from HEAD before trusting it.
|
||||||
@@ -517,6 +520,7 @@ export class SelfHealingManager {
|
|||||||
"git log",
|
"git log",
|
||||||
"--format=%H%x1f%s",
|
"--format=%H%x1f%s",
|
||||||
"--max-count=20",
|
"--max-count=20",
|
||||||
|
...(options?.preferEarliestOwnedCommit ? ["--reverse"] : []),
|
||||||
...(fixedStrings ? ["--fixed-strings"] : ["-E"]),
|
...(fixedStrings ? ["--fixed-strings"] : ["-E"]),
|
||||||
`--grep=${grepArg}`,
|
`--grep=${grepArg}`,
|
||||||
shellQuote(range),
|
shellQuote(range),
|
||||||
@@ -1305,19 +1309,52 @@ export class SelfHealingManager {
|
|||||||
let repaired = 0;
|
let repaired = 0;
|
||||||
for (const task of candidates) {
|
for (const task of candidates) {
|
||||||
try {
|
try {
|
||||||
const landed = await this.findLandedTaskCommit(task);
|
const storedSha = task.mergeDetails?.commitSha;
|
||||||
if (!landed) {
|
if (!storedSha) continue;
|
||||||
if (task.mergeDetails?.mergeConfirmed === false) {
|
|
||||||
await this.store.updateTask(task.id, { mergeDetails: undefined });
|
if (task.mergeDetails?.mergeConfirmed === true) {
|
||||||
await this.store.logEntry(task.id, "Auto-recovered: cleared unowned done-task mergeDetails commitSha");
|
const landed = await this.findLandedTaskCommit(task);
|
||||||
repaired++;
|
if (!landed || landed.sha !== storedSha) {
|
||||||
|
log.warn(
|
||||||
|
`Refusing to overwrite confirmed mergeDetails.commitSha for ${task.id} — stored SHA ${storedSha.slice(0, 8)} no longer reachable; preserving canonical attribution`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsMetadataRepair =
|
||||||
|
task.mergeDetails?.filesChanged === undefined ||
|
||||||
|
task.mergeDetails?.insertions === undefined ||
|
||||||
|
task.mergeDetails?.deletions === undefined ||
|
||||||
|
task.mergeDetails?.mergeCommitMessage === undefined;
|
||||||
|
|
||||||
|
if (!needsMetadataRepair) continue;
|
||||||
|
|
||||||
|
await this.store.updateTask(task.id, {
|
||||||
|
mergeDetails: {
|
||||||
|
...task.mergeDetails,
|
||||||
|
filesChanged: task.mergeDetails?.filesChanged ?? landed.filesChanged,
|
||||||
|
insertions: task.mergeDetails?.insertions ?? landed.insertions,
|
||||||
|
deletions: task.mergeDetails?.deletions ?? landed.deletions,
|
||||||
|
mergeCommitMessage: task.mergeDetails?.mergeCommitMessage ?? landed.subject,
|
||||||
|
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||||
|
prNumber: task.prInfo?.number,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.store.logEntry(task.id, `Auto-recovered: reconciled done-task mergeDetails to owned commit ${landed.sha.slice(0, 8)}`);
|
||||||
|
repaired++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const landed = await this.findLandedTaskCommit(task, { preferEarliestOwnedCommit: true });
|
||||||
|
if (!landed) {
|
||||||
|
await this.store.updateTask(task.id, { mergeDetails: undefined });
|
||||||
|
await this.store.logEntry(task.id, "Auto-recovered: cleared unowned done-task mergeDetails commitSha");
|
||||||
|
repaired++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsRepair =
|
const needsRepair =
|
||||||
task.mergeDetails?.commitSha !== landed.sha ||
|
task.mergeDetails?.commitSha !== landed.sha ||
|
||||||
task.mergeDetails?.mergeConfirmed !== true ||
|
|
||||||
task.mergeDetails?.filesChanged === undefined;
|
task.mergeDetails?.filesChanged === undefined;
|
||||||
|
|
||||||
if (!needsRepair) continue;
|
if (!needsRepair) continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user