feat(FN-4526): complete Step 2 — repair stale done-task merge stats

Fusion-Task-Id: FN-4526
Fusion-Task-Lineage: 38dc93d6-3e56-4e22-aaaa-d09b2eed43e5
This commit is contained in:
Fusion
2026-05-14 14:25:56 -07:00
committed by gsxdsm
parent 3a9e0711c7
commit 37bbcccf2a
2 changed files with 171 additions and 6 deletions

View File

@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function parseShortstat(output: string): { filesChanged: number; insertions: number; deletions: number } {
const normalized = output.trim().replace(/\n/g, " ");
return {
filesChanged: Number.parseInt(normalized.match(/(\d+) files? changed/)?.[1] ?? "0", 10),
insertions: Number.parseInt(normalized.match(/(\d+) insertions?\(\+\)/)?.[1] ?? "0", 10),
deletions: Number.parseInt(normalized.match(/(\d+) deletions?\(-\)/)?.[1] ?? "0", 10),
};
}
function makeTask(id: string, repo: string, sha: string, mergeDetails: Task["mergeDetails"]): Task {
return {
id,
title: id,
description: id,
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
baseBranch: "main",
worktree: repo,
mergeDetails: { commitSha: sha, mergeConfirmed: true, mergeCommitMessage: "msg", ...mergeDetails },
} as Task;
}
function createStore(tasks: Map<string, Task>): TaskStore & EventEmitter {
const emitter = new EventEmitter();
const settings = { globalPause: false, enginePaused: false, maintenanceIntervalMs: 0, taskStuckTimeoutMs: 60_000, autoMerge: false } as Settings;
return Object.assign(emitter, {
getSettings: vi.fn(async () => settings),
listTasks: vi.fn(async ({ column }: { column?: string } = {}) => [...tasks.values()].filter((t) => !column || t.column === column)),
getTask: vi.fn(async (id: string) => tasks.get(id)),
updateTask: vi.fn(async (id: string, updates: Partial<Task>) => {
const cur = tasks.get(id)!;
const next = { ...cur, ...updates, mergeDetails: updates.mergeDetails ?? cur.mergeDetails, updatedAt: new Date().toISOString() } as Task;
tasks.set(id, next);
return next;
}),
logEntry: vi.fn(async (id: string, message: string) => {
const cur = tasks.get(id)!;
tasks.set(id, { ...cur, log: [...(cur.log ?? []), { timestamp: new Date().toISOString(), action: message }] as any });
}),
moveTask: vi.fn(),
walCheckpoint: vi.fn(() => ({ busy: 0, log: 0, checkpointed: 0 })),
clearStaleExecutionStartBranchReferences: vi.fn(() => []),
updateSettings: vi.fn(),
mergeTask: vi.fn(),
getRootDir: vi.fn(() => ""),
recordRunAuditEvent: vi.fn(),
}) as unknown as TaskStore & EventEmitter;
}
describeIfGit("SelfHealingManager recoverDoneTaskMergeMetadata stale stats", () => {
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
function setupRepo() {
const repo = mkdtempSync(path.join(os.tmpdir(), "fn-4526-"));
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"');
writeFileSync(path.join(repo, "a.ts"), "const a = 1;\n", "utf-8");
writeFileSync(path.join(repo, "b.ts"), "const b = 1;\n", "utf-8");
git(repo, "git add a.ts b.ts && git commit -m 'init'");
writeFileSync(path.join(repo, "a.ts"), "const a = 2;\nconst c = 3;\n", "utf-8");
writeFileSync(path.join(repo, "b.ts"), "const b = 2;\n", "utf-8");
git(repo, "git add a.ts b.ts && git commit -m 'landed' -m 'Fusion-Task-Id: FN-4526-STATS'");
return { repo, sha: git(repo, "git rev-parse HEAD") };
}
it("repairs stale confirmed stats from live shortstat without changing SHA", async () => {
const { repo, sha } = setupRepo();
const expected = parseShortstat(git(repo, `git show --shortstat --format= ${sha}`));
const task = makeTask("FN-4526-STATS", repo, sha, { filesChanged: 99, insertions: 999, deletions: 999 });
const tasks = new Map([[task.id, task]]);
const store = createStore(tasks);
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
await manager.recoverDoneTaskMergeMetadata();
const repaired = tasks.get(task.id)!;
expect(repaired.mergeDetails?.commitSha).toBe(sha);
expect(repaired.mergeDetails?.filesChanged).toBe(expected.filesChanged);
expect(repaired.mergeDetails?.insertions).toBe(expected.insertions);
expect(repaired.mergeDetails?.deletions).toBe(expected.deletions);
expect((store.logEntry as any).mock.calls.some((call: any[]) => String(call[1]).includes("stale mergeDetails stats repaired"))).toBe(true);
});
it("does not rewrite when stats are already correct", async () => {
const { repo, sha } = setupRepo();
const expected = parseShortstat(git(repo, `git show --shortstat --format= ${sha}`));
const task = makeTask("FN-4526-OK", repo, sha, expected);
const tasks = new Map([[task.id, task]]);
const store = createStore(tasks);
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
await manager.recoverDoneTaskMergeMetadata();
expect((store.updateTask as any).mock.calls).toHaveLength(0);
});
});

View File

@@ -2542,6 +2542,20 @@ export class SelfHealingManager {
}
}
private async readShortstatForSha(
sha: string,
): Promise<{ filesChanged: number; insertions: number; deletions: number } | null> {
try {
const stats = await execAsync(`git show --shortstat --format= ${shellQuote(sha)}`, {
cwd: this.options.rootDir,
maxBuffer: 1024 * 1024,
});
return parseShortstat(stats.stdout);
} catch {
return null;
}
}
async recoverDoneTaskMergeMetadata(): Promise<number> {
try {
const tasks = await this.store.listTasks({ column: "done", slim: true });
@@ -2563,26 +2577,47 @@ export class SelfHealingManager {
continue;
}
const liveShortstat = await this.readShortstatForSha(storedSha);
const statsMismatch = Boolean(
liveShortstat && (
task.mergeDetails?.filesChanged !== liveShortstat.filesChanged ||
task.mergeDetails?.insertions !== liveShortstat.insertions ||
task.mergeDetails?.deletions !== liveShortstat.deletions
),
);
const needsMetadataRepair =
task.mergeDetails?.filesChanged === undefined ||
task.mergeDetails?.insertions === undefined ||
task.mergeDetails?.deletions === undefined ||
task.mergeDetails?.mergeCommitMessage === undefined;
task.mergeDetails?.mergeCommitMessage === undefined ||
statsMismatch;
if (!needsMetadataRepair) continue;
const nextFilesChanged = liveShortstat?.filesChanged ?? task.mergeDetails?.filesChanged ?? landed.filesChanged;
const nextInsertions = liveShortstat?.insertions ?? task.mergeDetails?.insertions ?? landed.insertions;
const nextDeletions = liveShortstat?.deletions ?? task.mergeDetails?.deletions ?? landed.deletions;
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,
filesChanged: nextFilesChanged,
insertions: nextInsertions,
deletions: nextDeletions,
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)}`);
if (statsMismatch && liveShortstat) {
await this.store.logEntry(
task.id,
`Auto-recovered: stale mergeDetails stats repaired (was ${task.mergeDetails?.filesChanged ?? "?"}/${task.mergeDetails?.insertions ?? "?"}/${task.mergeDetails?.deletions ?? "?"}, now ${liveShortstat.filesChanged}/${liveShortstat.insertions}/${liveShortstat.deletions}) — sha unchanged ${storedSha.slice(0, 8)}`,
);
} else {
await this.store.logEntry(task.id, `Auto-recovered: reconciled done-task mergeDetails to owned commit ${landed.sha.slice(0, 8)}`);
}
repaired++;
continue;
}
@@ -2597,7 +2632,15 @@ export class SelfHealingManager {
const needsRepair =
task.mergeDetails?.commitSha !== landed.sha ||
task.mergeDetails?.filesChanged === undefined;
task.mergeDetails?.filesChanged === undefined ||
task.mergeDetails?.insertions === undefined ||
task.mergeDetails?.deletions === undefined || (
task.mergeDetails?.commitSha === landed.sha && (
task.mergeDetails?.filesChanged !== landed.filesChanged ||
task.mergeDetails?.insertions !== landed.insertions ||
task.mergeDetails?.deletions !== landed.deletions
)
);
if (!needsRepair) continue;