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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user