feat(FN-3498): add self-healing and ownership-aware done-task merge reconci
The merge completes FN-3498 across three steps: adds ownership-aware done-task reconciliation to the merger, prevents branch-missing head SHA pollution during merge operations, and restores workspace typecheck compatibility. Core changes touch the merger (103 lines) and self-healing module (67 lines Fusion-Task-Id: FN-3498
This commit is contained in:
@@ -5362,38 +5362,62 @@ describe("aiMergeTask — merge details collection", () => {
|
||||
expect(mergeDetailsCall?.[1].mergeDetails.mergeCommitMessage).toBe("- feat: something");
|
||||
});
|
||||
|
||||
it("stores partial mergeDetails when branch is not found", async () => {
|
||||
it("recovers owned landed commit when branch is not found", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
{
|
||||
id: "FN-3469",
|
||||
worktree: "/tmp/root/.worktrees/FN-3469",
|
||||
baseCommitSha: "base3469",
|
||||
mergeDetails: { commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a" } as any,
|
||||
},
|
||||
[{ id: "FN-3469", worktree: "/tmp/root/.worktrees/FN-3469", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
// Branch verification fails → branch not found
|
||||
if (cmdStr.includes("rev-parse --verify")) throw new Error("not found");
|
||||
// But rev-parse HEAD still works → can capture commitSha (encoding: utf-8 → string)
|
||||
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD "))
|
||||
return "existingheadsha999";
|
||||
if (cmdStr.includes("merge-base --is-ancestor a47b1e5d78d626f8b480f1e90d3d64be2625ff6a HEAD")) return Buffer.from("");
|
||||
if (cmdStr.includes("log -1 --format=%H%x1f%s%x1f%b a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) {
|
||||
return "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a\u001ffix(FN-3469): title\u001fFusion-Task-Id: FN-3469" as any;
|
||||
}
|
||||
if (cmdStr.includes("show --shortstat --format= a47b1e5d78d626f8b480f1e90d3d64be2625ff6a")) {
|
||||
return "2 files changed, 84 insertions(+), 2 deletions(-)" as any;
|
||||
}
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-3469");
|
||||
expect(result.merged).toBe(false);
|
||||
expect(result.error).toContain("not found");
|
||||
|
||||
// Find the updateTask call that set mergeDetails
|
||||
const updateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const mergeDetailsCall = updateCalls.find(
|
||||
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: any[]) => call[1]?.mergeDetails !== undefined,
|
||||
);
|
||||
expect(mergeDetailsCall).toBeDefined();
|
||||
expect(mergeDetailsCall?.[1].mergeDetails).toEqual(expect.objectContaining({
|
||||
commitSha: "a47b1e5d78d626f8b480f1e90d3d64be2625ff6a",
|
||||
mergeCommitMessage: "fix(FN-3469): title",
|
||||
mergeConfirmed: true,
|
||||
}));
|
||||
});
|
||||
|
||||
const mergeDetails = mergeDetailsCall![1].mergeDetails;
|
||||
expect(mergeDetails.commitSha).toBe("existingheadsha999");
|
||||
expect(mergeDetails.mergedAt).toBeDefined();
|
||||
expect(mergeDetails.mergeConfirmed).toBe(false);
|
||||
it("does not persist misleading mergeDetails when branch is not found and no owned commit exists", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373" },
|
||||
[{ id: "FN-3373", worktree: "/tmp/root/.worktrees/FN-3373", column: "in-review" } as Task],
|
||||
);
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
if (cmdStr.includes("rev-parse --verify")) throw new Error("not found");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
const result = await aiMergeTask(store, "/tmp/root", "FN-3373");
|
||||
expect(result.merged).toBe(false);
|
||||
|
||||
const mergeDetailsCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(call: any[]) => call[1]?.mergeDetails !== undefined,
|
||||
);
|
||||
expect(mergeDetailsCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("completes merge even when git commands fail during merge details collection", async () => {
|
||||
|
||||
@@ -3006,6 +3006,82 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
// ── Maintenance cycle concurrency ──────────────────────────────────
|
||||
|
||||
describe("recoverDoneTaskMergeMetadata", () => {
|
||||
it("upgrades done task metadata to an owned landed commit", async () => {
|
||||
const store = createMockStore();
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-3469",
|
||||
column: "done",
|
||||
paused: false,
|
||||
baseCommitSha: "base",
|
||||
mergeDetails: { commitSha: "sharedsha", mergeConfirmed: false },
|
||||
modifiedFiles: ["AGENTS.md"],
|
||||
},
|
||||
]);
|
||||
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("merge-base --is-ancestor sharedsha HEAD")) return "" as any;
|
||||
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b sharedsha")) {
|
||||
return "sharedsha\u001ffix(FN-3468): other\u001fFusion-Task-Id: FN-3468" as any;
|
||||
}
|
||||
if (cmd.includes("Fusion-Task-Id: FN-3469")) {
|
||||
return "a47b1e5\u001ffix(FN-3469): correct lazy-loaded views\n" as any;
|
||||
}
|
||||
if (cmd.includes("show --shortstat --format= a47b1e5")) {
|
||||
return "2 files changed, 84 insertions(+), 2 deletions(-)" as any;
|
||||
}
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
||||
|
||||
expect(repaired).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3469", {
|
||||
mergeDetails: expect.objectContaining({
|
||||
commitSha: "a47b1e5",
|
||||
mergeConfirmed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("clears unowned shared SHA for done task when no owned landed commit exists", async () => {
|
||||
const store = createMockStore();
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-3373",
|
||||
column: "done",
|
||||
paused: false,
|
||||
mergeDetails: { commitSha: "196adbd", mergeConfirmed: false },
|
||||
modifiedFiles: ["packages/cli/src/extension.ts"],
|
||||
},
|
||||
]);
|
||||
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("merge-base --is-ancestor 196adbd HEAD")) return "" as any;
|
||||
if (cmd.includes("log -1 --format=%H%x1f%s%x1f%b 196adbd")) {
|
||||
return "196adbd\u001ffeat(FN-3372): add safety net\u001fFusion-Task-Id: FN-3372" as any;
|
||||
}
|
||||
return "" as any;
|
||||
});
|
||||
|
||||
const repaired = await manager.recoverDoneTaskMergeMetadata();
|
||||
|
||||
expect(repaired).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-3373", { mergeDetails: undefined });
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance cycle concurrency", () => {
|
||||
let store: TaskStore & EventEmitter;
|
||||
let manager: SelfHealingManager;
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type AgentPromptsConfig,
|
||||
type CanonicalMergeConflictStrategy,
|
||||
type TaskSourceIssue,
|
||||
type Task,
|
||||
} from "@fusion/core";
|
||||
import { describeModel, promptWithFallback } from "./pi.js";
|
||||
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
||||
@@ -250,6 +251,72 @@ interface InferredTestCommand {
|
||||
buildSource?: "explicit" | "inferred";
|
||||
}
|
||||
|
||||
interface OwnedLandedCommit {
|
||||
sha: string;
|
||||
subject?: string;
|
||||
filesChanged?: number;
|
||||
insertions?: number;
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
||||
return body.includes(`${FUSION_TASK_ID_TRAILER_KEY}: ${taskId}`) || subject.includes(taskId);
|
||||
}
|
||||
|
||||
async function findOwnedLandedCommitForTask(rootDir: string, task: Task): Promise<OwnedLandedCommit | null> {
|
||||
const tryHydrate = async (sha: string): Promise<OwnedLandedCommit | null> => {
|
||||
try {
|
||||
await execFileAsync("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: rootDir });
|
||||
const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%H%x1f%s%x1f%b", sha], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const [resolvedSha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
||||
if (!resolvedSha || !commitOwnedByTask(task.id, subject, body)) return null;
|
||||
const owned: OwnedLandedCommit = { sha: resolvedSha, subject };
|
||||
try {
|
||||
const { stdout: statsOut } = await execFileAsync("git", ["show", "--shortstat", "--format=", resolvedSha], {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
Object.assign(owned, parseDiffStat(statsOut));
|
||||
} catch {
|
||||
// stats optional
|
||||
}
|
||||
return owned;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (task.mergeDetails?.commitSha) {
|
||||
const ownedStored = await tryHydrate(task.mergeDetails.commitSha);
|
||||
if (ownedStored) return ownedStored;
|
||||
}
|
||||
|
||||
const trailer = `${FUSION_TASK_ID_TRAILER_KEY}: ${task.id}`;
|
||||
const searches: string[][] = [
|
||||
["log", "--format=%H%x1f%s", "--max-count=20", "--fixed-strings", `--grep=${trailer}`, "HEAD"],
|
||||
["log", "--format=%H%x1f%s", "--max-count=20", "--fixed-strings", `--grep=${task.id}`, "HEAD"],
|
||||
];
|
||||
|
||||
for (const args of searches) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", args, { cwd: rootDir, encoding: "utf-8" });
|
||||
const first = stdout.trim().split("\n").find(Boolean);
|
||||
if (!first) continue;
|
||||
const [sha] = first.split("\x1f");
|
||||
if (!sha) continue;
|
||||
const owned = await tryHydrate(sha);
|
||||
if (owned) return owned;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a default test command based on project files.
|
||||
* Returns the command and whether it was explicitly configured or inferred.
|
||||
@@ -2662,25 +2729,23 @@ export async function aiMergeTask(
|
||||
});
|
||||
} catch {
|
||||
result.error = `Branch '${branch}' not found — moving to done without merge`;
|
||||
// Best-effort: try to capture current HEAD commitSha even though branch is missing
|
||||
try {
|
||||
const commitSha = execSyncText("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
}).trim() || undefined;
|
||||
if (commitSha) {
|
||||
await store.updateTask(taskId, {
|
||||
mergeDetails: {
|
||||
commitSha,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: false,
|
||||
},
|
||||
});
|
||||
mergerLog.log(`${taskId}: branch not found but captured commitSha ${commitSha.slice(0, 8)}`);
|
||||
}
|
||||
} catch {
|
||||
// No commit SHA available — task will show summary fallback
|
||||
// Branch is gone; never infer ownership from raw HEAD. Only persist commit
|
||||
// metadata when we can prove a landed commit belongs to this task.
|
||||
const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task);
|
||||
if (ownedCommit) {
|
||||
await store.updateTask(taskId, {
|
||||
mergeDetails: {
|
||||
commitSha: ownedCommit.sha,
|
||||
filesChanged: ownedCommit.filesChanged,
|
||||
insertions: ownedCommit.insertions,
|
||||
deletions: ownedCommit.deletions,
|
||||
mergeCommitMessage: ownedCommit.subject,
|
||||
mergedAt: new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
prNumber: task.prInfo?.number,
|
||||
},
|
||||
});
|
||||
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`);
|
||||
}
|
||||
// Audit trail: record merge completion (FN-1404)
|
||||
await audit.database({ type: "task:move", target: taskId, metadata: { to: "done", merged: false } });
|
||||
|
||||
@@ -117,6 +117,10 @@ interface LandedTaskCommit {
|
||||
deletions?: number;
|
||||
}
|
||||
|
||||
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
||||
return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -193,6 +197,7 @@ export class SelfHealingManager {
|
||||
{ name: "stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks().then(() => undefined) },
|
||||
{ name: "failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps().then(() => undefined) },
|
||||
{ name: "interrupted-merging", fn: () => this.recoverInterruptedMergingTasks().then(() => undefined) },
|
||||
{ name: "done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata().then(() => undefined) },
|
||||
{ name: "misclassified-failures", fn: () => this.recoverMisclassifiedFailures().then(() => undefined) },
|
||||
{ name: "partial-progress-no-task-done", fn: () => this.recoverPartialProgressNoTaskDoneFailures().then(() => undefined) },
|
||||
{ name: "orphaned-executions", fn: () => this.recoverOrphanedExecutions().then(() => undefined) },
|
||||
@@ -467,18 +472,16 @@ export class SelfHealingManager {
|
||||
const storedSha = task.mergeDetails?.commitSha;
|
||||
if (storedSha) {
|
||||
try {
|
||||
// Reachable from HEAD? Use --quiet --exit-code on rev-list.
|
||||
await execAsync(
|
||||
`git merge-base --is-ancestor ${shellQuote(storedSha)} HEAD`,
|
||||
{ cwd: this.options.rootDir },
|
||||
);
|
||||
// Yes — fetch its subject + stats.
|
||||
const { stdout } = await execAsync(
|
||||
`git log -1 --format=%H%x1f%s ${shellQuote(storedSha)}`,
|
||||
`git log -1 --format=%H%x1f%s%x1f%b ${shellQuote(storedSha)}`,
|
||||
{ cwd: this.options.rootDir, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
const [sha, subject] = stdout.trim().split("\x1f");
|
||||
if (sha) {
|
||||
const [sha, subject = "", body = ""] = stdout.trim().split("\x1f");
|
||||
if (sha && commitOwnedByTask(task.id, subject, body)) {
|
||||
const commit: LandedTaskCommit = { sha, subject };
|
||||
try {
|
||||
const stats = await execAsync(`git show --shortstat --format= ${shellQuote(sha)}`, {
|
||||
@@ -644,6 +647,7 @@ export class SelfHealingManager {
|
||||
{ name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() },
|
||||
{ name: "recover-failed-pre-merge-steps", fn: () => this.recoverReviewTasksWithFailedPreMergeSteps() },
|
||||
{ name: "recover-interrupted-merging", fn: () => this.recoverInterruptedMergingTasks() },
|
||||
{ name: "recover-done-merge-metadata", fn: () => this.recoverDoneTaskMergeMetadata() },
|
||||
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
|
||||
{ name: "recover-merged-review", fn: () => this.recoverMergedReviewTasks() },
|
||||
{ name: "recover-misclassified-failures", fn: () => this.recoverMisclassifiedFailures() },
|
||||
@@ -1218,6 +1222,59 @@ export class SelfHealingManager {
|
||||
}
|
||||
}
|
||||
|
||||
async recoverDoneTaskMergeMetadata(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks({ column: "done", slim: true });
|
||||
const candidates = tasks.filter((task) => task.column === "done" && !task.paused && Boolean(task.mergeDetails?.commitSha));
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
let repaired = 0;
|
||||
for (const task of candidates) {
|
||||
try {
|
||||
const landed = await this.findLandedTaskCommit(task);
|
||||
if (!landed) {
|
||||
if (task.mergeDetails?.mergeConfirmed === false) {
|
||||
await this.store.updateTask(task.id, { mergeDetails: undefined });
|
||||
await this.store.logEntry(task.id, "Auto-recovered: cleared unowned done-task mergeDetails commitSha");
|
||||
repaired++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const needsRepair =
|
||||
task.mergeDetails?.commitSha !== landed.sha ||
|
||||
task.mergeDetails?.mergeConfirmed !== true ||
|
||||
task.mergeDetails?.filesChanged === undefined;
|
||||
|
||||
if (!needsRepair) continue;
|
||||
|
||||
await this.store.updateTask(task.id, {
|
||||
mergeDetails: {
|
||||
...task.mergeDetails,
|
||||
commitSha: landed.sha,
|
||||
filesChanged: landed.filesChanged,
|
||||
insertions: landed.insertions,
|
||||
deletions: landed.deletions,
|
||||
mergeCommitMessage: landed.subject,
|
||||
mergedAt: task.mergeDetails?.mergedAt ?? new Date().toISOString(),
|
||||
mergeConfirmed: true,
|
||||
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++;
|
||||
} catch (err: unknown) {
|
||||
log.error(`Failed done-task merge metadata recovery for ${task.id}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return repaired;
|
||||
} catch (err: unknown) {
|
||||
log.error(`Done-task merge metadata recovery failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misclassified failure recovery ───────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user