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:
Fusion
2026-05-05 13:42:39 -07:00
committed by gsxdsm
parent 511c0c5442
commit c67e786f48
11 changed files with 277 additions and 73 deletions

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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 } });

View File

@@ -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 ───────────────────────────────
/**