fix(FN-1997): bind stranded AI merge recovery to reviewed commit
This commit is contained in:
7
.changeset/fuzzy-ai-merge-recovery.md
Normal file
7
.changeset/fuzzy-ai-merge-recovery.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Make stranded AI merge recovery bind to the reviewed clean-room commit.
|
||||||
|
category: fix
|
||||||
|
dev: Avoids ambiguous same-task clean-room recovery and honors cancellation before pre-prune landing.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterAll } from "vitest";
|
import { describe, it, expect, vi, afterAll } from "vitest";
|
||||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
@@ -277,27 +277,39 @@ describe("runAiMerge", () => {
|
|||||||
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
|
expect(emitted.some((e) => e.event === "task:merged")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers an approved pre-existing clean-room commit before pruning and re-merging", async () => {
|
it.each([
|
||||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
["modern repo-local root", "FN-1", (dir: string) => join(dir, ".worktrees", ".ai-merge")],
|
||||||
|
["legacy .fusion root", "FN-2", (dir: string) => join(dir, ".fusion", "ai-merge")],
|
||||||
|
["direct tmpdir root", "FN-3", (_dir: string) => tmpdir()],
|
||||||
|
])("recovers an approved pre-existing clean-room commit from the %s before pruning and re-merging", async (_label, taskId, resolveRoot) => {
|
||||||
|
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||||
|
const { dir } = initRepoWithBranch({ branch });
|
||||||
const mainBefore = git(dir, "rev-parse main");
|
const mainBefore = git(dir, "rev-parse main");
|
||||||
const aiMergeRoot = join(dir, ".fusion", "ai-merge");
|
const aiMergeRoot = resolveRoot(dir);
|
||||||
mkdirSync(aiMergeRoot, { recursive: true });
|
mkdirSync(aiMergeRoot, { recursive: true });
|
||||||
const strandedRoot = mkdtempSync(join(aiMergeRoot, "fusion-ai-merge-fn-1-"));
|
if (aiMergeRoot === tmpdir()) {
|
||||||
|
for (const entry of readdirSync(aiMergeRoot).filter((name) => name.startsWith(`fusion-ai-merge-${taskId.toLowerCase()}-`))) {
|
||||||
|
rmSync(join(aiMergeRoot, entry), RM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strandedRoot = mkdtempSync(join(aiMergeRoot, `fusion-ai-merge-${taskId.toLowerCase()}-`));
|
||||||
tracked.add(strandedRoot);
|
tracked.add(strandedRoot);
|
||||||
git(dir, `worktree add --detach ${strandedRoot} ${mainBefore}`);
|
git(dir, `worktree add --detach ${strandedRoot} ${mainBefore}`);
|
||||||
execSync("git merge --squash fusion/fn-1", { cwd: strandedRoot, stdio: "pipe" });
|
execSync(`git merge --squash ${branch}`, { cwd: strandedRoot, stdio: "pipe" });
|
||||||
execSync("git add -A", { cwd: strandedRoot, stdio: "pipe" });
|
execSync("git add -A", { cwd: strandedRoot, stdio: "pipe" });
|
||||||
execSync('git commit -q -m "FN-1: recovered clean-room" -m "Fusion-Task-Id: FN-1"', { cwd: strandedRoot, stdio: "pipe" });
|
execSync(`git commit -q -m "${taskId}: recovered clean-room" -m "Fusion-Task-Id: ${taskId}"`, { cwd: strandedRoot, stdio: "pipe" });
|
||||||
const strandedSha = git(strandedRoot, "rev-parse HEAD");
|
const strandedSha = git(strandedRoot, "rev-parse HEAD");
|
||||||
const { store, logs } = makeStore(dir, {
|
const { store, logs } = makeStore(dir, {
|
||||||
|
id: taskId,
|
||||||
|
branch,
|
||||||
log: [
|
log: [
|
||||||
{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 20 * 60_000).toISOString() },
|
{ action: "Task marked done by agent", timestamp: new Date(Date.now() - 20 * 60_000).toISOString() },
|
||||||
{ action: "AI merge review (pass 1): approved", timestamp: new Date(Date.now() - 12 * 60_000).toISOString() },
|
{ action: `AI merge review (pass 1): approved squash ${strandedSha}`, timestamp: new Date(Date.now() - 12 * 60_000).toISOString() },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const mergeAgent = vi.fn(async () => { throw new Error("should not re-merge"); });
|
const mergeAgent = vi.fn(async () => { throw new Error("should not re-merge"); });
|
||||||
|
|
||||||
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
const result = await runAiMerge(store, dir, taskId, { manual: true }, {
|
||||||
mergeAgent,
|
mergeAgent,
|
||||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10729,8 +10729,8 @@ describe("stranded AI merge clean-room recovery", () => {
|
|||||||
if (command.includes("git show -s --format")) {
|
if (command.includes("git show -s --format")) {
|
||||||
return Buffer.from("FN-5858: render headings\x1fFusion-Task-Id: FN-5858\nFusion-Task-Lineage: lineage-5858\n");
|
return Buffer.from("FN-5858: render headings\x1fFusion-Task-Id: FN-5858\nFusion-Task-Lineage: lineage-5858\n");
|
||||||
}
|
}
|
||||||
if (command.includes("git rev-parse --verify refs/heads/'main'")) return Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n");
|
if (command.includes("git rev-parse --verify 'refs/heads/main'")) return Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n");
|
||||||
if (command.includes("git merge-base --is-ancestor 'dddddddddddddddddddddddddddddddddddddddd' refs/heads/'main'")) {
|
if (command.includes("git merge-base --is-ancestor 'dddddddddddddddddddddddddddddddddddddddd' 'refs/heads/main'")) {
|
||||||
throw new Error("not already landed");
|
throw new Error("not already landed");
|
||||||
}
|
}
|
||||||
if (command.includes("git merge-base --is-ancestor 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 'dddddddddddddddddddddddddddddddddddddddd'")) {
|
if (command.includes("git merge-base --is-ancestor 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 'dddddddddddddddddddddddddddddddddddddddd'")) {
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ function short(sha: string): string {
|
|||||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
|
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha.slice(0, 8) : sha;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getApprovedAiMergeReviewShas(task: Task | undefined): Set<string> {
|
||||||
|
const shas = new Set<string>();
|
||||||
|
for (const entry of task?.log ?? []) {
|
||||||
|
if (typeof entry.action !== "string") continue;
|
||||||
|
const match = entry.action.match(/AI merge review \(pass \d+\): approved(?:\s+(?:squash|commit)\s+([0-9a-f]{7,40}))?/i);
|
||||||
|
if (match?.[1]) shas.add(match[1].toLowerCase());
|
||||||
|
}
|
||||||
|
return shas;
|
||||||
|
}
|
||||||
|
|
||||||
function taskHasApprovedAiMergeReview(task: Task | undefined): boolean {
|
function taskHasApprovedAiMergeReview(task: Task | undefined): boolean {
|
||||||
return (task?.log ?? []).some((entry) =>
|
return (task?.log ?? []).some((entry) =>
|
||||||
typeof entry.action === "string"
|
typeof entry.action === "string"
|
||||||
@@ -138,9 +148,32 @@ function taskHasApprovedAiMergeReview(task: Task | undefined): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesApprovedAiMergeSha(squashSha: string, approvedShas: Set<string>): boolean {
|
||||||
|
if (approvedShas.size === 0) return true;
|
||||||
|
const normalized = squashSha.toLowerCase();
|
||||||
|
return Array.from(approvedShas).some((approved) => normalized === approved || normalized.startsWith(approved) || approved.startsWith(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreexistingAiMergeRecoveryCandidate = {
|
||||||
|
mergeRoot: string;
|
||||||
|
squashSha: string;
|
||||||
|
tipSha: string;
|
||||||
|
alreadyLanded: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function listAiMergeWorktreeCandidates(taskId: string, projectRootDir: string, settings?: Settings): string[] {
|
function listAiMergeWorktreeCandidates(taskId: string, projectRootDir: string, settings?: Settings): string[] {
|
||||||
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
||||||
const roots = Array.from(new Set([resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()]));
|
const roots = Array.from(new Set([resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()]));
|
||||||
|
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
|
||||||
|
if (testWorkerRoot) {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(testWorkerRoot)) {
|
||||||
|
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort for the test harness' bounded temp-dir redirection root.
|
||||||
|
}
|
||||||
|
}
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
@@ -159,50 +192,70 @@ async function recoverApprovedPreexistingAiMergeWorktree(
|
|||||||
integrationBranch: string,
|
integrationBranch: string,
|
||||||
ctx: LandRepoContext,
|
ctx: LandRepoContext,
|
||||||
): Promise<LandOneRepoResult | null> {
|
): Promise<LandOneRepoResult | null> {
|
||||||
const { taskId, settings, store, audit, log, allowDirtyLocalCheckoutSync, stashResolveAgent } = ctx;
|
const { taskId, settings, store, audit, log, allowDirtyLocalCheckoutSync, stashResolveAgent, signal } = ctx;
|
||||||
|
throwIfAborted(signal, taskId);
|
||||||
const task = await store.getTask(taskId).catch(() => undefined);
|
const task = await store.getTask(taskId).catch(() => undefined);
|
||||||
if (!taskHasApprovedAiMergeReview(task)) return null;
|
if (!taskHasApprovedAiMergeReview(task)) return null;
|
||||||
|
|
||||||
|
const approvedShas = getApprovedAiMergeReviewShas(task);
|
||||||
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir);
|
const tipSha = await git(["rev-parse", "--verify", `refs/heads/${integrationBranch}`], repoRootDir);
|
||||||
|
const recoverableCandidates: PreexistingAiMergeRecoveryCandidate[] = [];
|
||||||
for (const candidate of listAiMergeWorktreeCandidates(taskId, repoRootDir, settings)) {
|
for (const candidate of listAiMergeWorktreeCandidates(taskId, repoRootDir, settings)) {
|
||||||
let mergeRoot = candidate;
|
let mergeRoot = candidate;
|
||||||
try { mergeRoot = realpathSync(candidate); } catch { /* keep original */ }
|
try { mergeRoot = realpathSync(candidate); } catch { /* keep original */ }
|
||||||
if (activeSessionRegistry.isPathActive(candidate) || activeSessionRegistry.isPathActive(mergeRoot)) continue;
|
if (activeSessionRegistry.isPathActive(candidate) || activeSessionRegistry.isPathActive(mergeRoot)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
throwIfAborted(signal, taskId);
|
||||||
const squashSha = await git(["rev-parse", "--verify", "HEAD"], mergeRoot);
|
const squashSha = await git(["rev-parse", "--verify", "HEAD"], mergeRoot);
|
||||||
if (!squashSha || squashSha === tipSha) continue;
|
if (!squashSha || squashSha === tipSha) continue;
|
||||||
|
if (!matchesApprovedAiMergeSha(squashSha, approvedShas)) continue;
|
||||||
const show = await git(["show", "-s", "--format=%s%x1f%b", squashSha], mergeRoot);
|
const show = await git(["show", "-s", "--format=%s%x1f%b", squashSha], mergeRoot);
|
||||||
const [subject = "", body = ""] = show.split("\x1f");
|
const [subject = "", body = ""] = show.split("\x1f");
|
||||||
if (!getCommitTaskOwnership(taskId, task?.lineageId, subject, body).owned) continue;
|
if (!getCommitTaskOwnership(taskId, task?.lineageId, subject, body).owned) continue;
|
||||||
if (!(await gitOk(["merge-base", "--is-ancestor", tipSha, squashSha], repoRootDir))) continue;
|
|
||||||
|
|
||||||
const alreadyLanded = await gitOk(["merge-base", "--is-ancestor", squashSha, `refs/heads/${integrationBranch}`], repoRootDir);
|
const alreadyLanded = await gitOk(["merge-base", "--is-ancestor", squashSha, `refs/heads/${integrationBranch}`], repoRootDir);
|
||||||
if (!alreadyLanded) {
|
const tipIsAncestor = await gitOk(["merge-base", "--is-ancestor", tipSha, squashSha], repoRootDir);
|
||||||
const land = await landSquash({
|
if (!alreadyLanded && !tipIsAncestor) continue;
|
||||||
projectRootDir: repoRootDir,
|
recoverableCandidates.push({ mergeRoot, squashSha, tipSha, alreadyLanded });
|
||||||
mergeRoot,
|
|
||||||
integrationBranch,
|
|
||||||
tipSha,
|
|
||||||
squashSha,
|
|
||||||
taskId,
|
|
||||||
audit,
|
|
||||||
resolveConflicts: stashResolveAgent,
|
|
||||||
allowDirtyLocalCheckoutSync,
|
|
||||||
});
|
|
||||||
if (land.outcome !== "advanced") continue;
|
|
||||||
await log(`AI merge: recovered approved pre-existing clean-room commit ${short(squashSha)} before pruning`);
|
|
||||||
await audit.git({ type: "merge:ai-landed", target: integrationBranch, metadata: { taskId, landedSha: squashSha, source: "pre-prune-clean-room-recovery", mergeRoot } }).catch(() => undefined);
|
|
||||||
return { outcome: "landed", squashSha, localSync: land.localSync, tipSha, integrationBranch };
|
|
||||||
}
|
|
||||||
|
|
||||||
await log(`AI merge: recovered already-landed clean-room commit ${short(squashSha)} before pruning`);
|
|
||||||
return { outcome: "landed", squashSha, localSync: "skipped-other-branch", tipSha, integrationBranch };
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
await log(`AI merge: skipped pre-existing clean-room recovery candidate ${mergeRoot}: ${getErrorMessage(err)}`);
|
await log(`AI merge: skipped pre-existing clean-room recovery candidate ${mergeRoot}: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
/*
|
||||||
|
FNXC:AIMergeRecovery 2026-07-10-23:06:
|
||||||
|
Approved clean-room recovery must bind the candidate commit to the reviewed squash. New review logs carry the squash SHA; legacy logs without a SHA can recover only when exactly one same-task candidate is possible, otherwise recovery defers to the normal merge path rather than finalizing the wrong clean room.
|
||||||
|
*/
|
||||||
|
if (recoverableCandidates.length !== 1) {
|
||||||
|
if (recoverableCandidates.length > 1) {
|
||||||
|
await log(`AI merge: skipped pre-existing clean-room recovery because ${recoverableCandidates.length} same-task approved candidates were ambiguous`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = recoverableCandidates[0];
|
||||||
|
throwIfAborted(signal, taskId);
|
||||||
|
if (!selected.alreadyLanded) {
|
||||||
|
const land = await landSquash({
|
||||||
|
projectRootDir: repoRootDir,
|
||||||
|
mergeRoot: selected.mergeRoot,
|
||||||
|
integrationBranch,
|
||||||
|
tipSha: selected.tipSha,
|
||||||
|
squashSha: selected.squashSha,
|
||||||
|
taskId,
|
||||||
|
audit,
|
||||||
|
resolveConflicts: stashResolveAgent,
|
||||||
|
allowDirtyLocalCheckoutSync,
|
||||||
|
});
|
||||||
|
if (land.outcome !== "advanced") return null;
|
||||||
|
await log(`AI merge: recovered approved pre-existing clean-room commit ${short(selected.squashSha)} before pruning`);
|
||||||
|
await audit.git({ type: "merge:ai-landed", target: integrationBranch, metadata: { taskId, landedSha: selected.squashSha, source: "pre-prune-clean-room-recovery", mergeRoot: selected.mergeRoot } }).catch(() => undefined);
|
||||||
|
return { outcome: "landed", squashSha: selected.squashSha, localSync: land.localSync, tipSha: selected.tipSha, integrationBranch };
|
||||||
|
}
|
||||||
|
|
||||||
|
await log(`AI merge: recovered already-landed clean-room commit ${short(selected.squashSha)} before pruning`);
|
||||||
|
return { outcome: "landed", squashSha: selected.squashSha, localSync: "skipped-other-branch", tipSha: selected.tipSha, integrationBranch };
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -1620,7 +1673,7 @@ async function mergeAndReview(input: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (verdict.verdict === "approve") {
|
if (verdict.verdict === "approve") {
|
||||||
await log(`AI merge review (pass ${attempt + 1}): approved`);
|
await log(`AI merge review (pass ${attempt + 1}): approved squash ${head}`);
|
||||||
return head;
|
return head;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8980,6 +8980,16 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getApprovedAiMergeReviewShas(task: Task): Set<string> {
|
||||||
|
const shas = new Set<string>();
|
||||||
|
for (const entry of task.log ?? []) {
|
||||||
|
if (typeof entry.action !== "string") continue;
|
||||||
|
const match = entry.action.match(/AI merge review \(pass \d+\): approved(?:\s+(?:squash|commit)\s+([0-9a-f]{7,40}))?/i);
|
||||||
|
if (match?.[1]) shas.add(match[1].toLowerCase());
|
||||||
|
}
|
||||||
|
return shas;
|
||||||
|
}
|
||||||
|
|
||||||
private hasApprovedAiMergeReview(task: Task): boolean {
|
private hasApprovedAiMergeReview(task: Task): boolean {
|
||||||
return (task.log ?? []).some((entry) =>
|
return (task.log ?? []).some((entry) =>
|
||||||
typeof entry.action === "string"
|
typeof entry.action === "string"
|
||||||
@@ -8987,12 +8997,28 @@ export class SelfHealingManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private matchesApprovedAiMergeSha(squashSha: string, approvedShas: Set<string>): boolean {
|
||||||
|
if (approvedShas.size === 0) return true;
|
||||||
|
const normalized = squashSha.toLowerCase();
|
||||||
|
return Array.from(approvedShas).some((approved) => normalized === approved || normalized.startsWith(approved) || approved.startsWith(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
private async listAiMergeWorktreeCandidates(taskId: string, settings: Settings): Promise<string[]> {
|
private async listAiMergeWorktreeCandidates(taskId: string, settings: Settings): Promise<string[]> {
|
||||||
const roots = Array.from(new Set([
|
const roots = Array.from(new Set([
|
||||||
resolveRepoLocalAiMergeRoot(this.options.rootDir, settings),
|
resolveRepoLocalAiMergeRoot(this.options.rootDir, settings),
|
||||||
resolveLegacyAiMergeRootPath(this.options.rootDir),
|
resolveLegacyAiMergeRootPath(this.options.rootDir),
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
]));
|
]));
|
||||||
|
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
|
||||||
|
if (testWorkerRoot) {
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(testWorkerRoot)) {
|
||||||
|
if (entry.startsWith("redir-")) roots.push(join(testWorkerRoot, entry));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort for the test harness' bounded temp-dir redirection root.
|
||||||
|
}
|
||||||
|
}
|
||||||
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
||||||
const paths: string[] = [];
|
const paths: string[] = [];
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
@@ -9026,6 +9052,16 @@ export class SelfHealingManager {
|
|||||||
phase: "recover-stranded-ai-merge-commit",
|
phase: "recover-stranded-ai-merge-commit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const approvedShas = this.getApprovedAiMergeReviewShas(task);
|
||||||
|
const refName = `refs/heads/${integrationBranch}`;
|
||||||
|
const recoverableCandidates: Array<{
|
||||||
|
canonicalCandidate: string;
|
||||||
|
strandedSha: string;
|
||||||
|
tipSha: string;
|
||||||
|
alreadyAncestor: boolean;
|
||||||
|
landedFiles: string[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
let canonicalCandidate = candidate;
|
let canonicalCandidate = candidate;
|
||||||
try { canonicalCandidate = realpathSync(candidate); } catch { /* keep original */ }
|
try { canonicalCandidate = realpathSync(candidate); } catch { /* keep original */ }
|
||||||
@@ -9034,7 +9070,7 @@ export class SelfHealingManager {
|
|||||||
try {
|
try {
|
||||||
const { stdout: headStdout } = await execAsync("git rev-parse --verify HEAD", { cwd: canonicalCandidate, timeout: 30_000 });
|
const { stdout: headStdout } = await execAsync("git rev-parse --verify HEAD", { cwd: canonicalCandidate, timeout: 30_000 });
|
||||||
const strandedSha = headStdout.trim();
|
const strandedSha = headStdout.trim();
|
||||||
if (!strandedSha) continue;
|
if (!strandedSha || !this.matchesApprovedAiMergeSha(strandedSha, approvedShas)) continue;
|
||||||
|
|
||||||
const { stdout: showStdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(strandedSha)}`, {
|
const { stdout: showStdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(strandedSha)}`, {
|
||||||
cwd: canonicalCandidate,
|
cwd: canonicalCandidate,
|
||||||
@@ -9045,14 +9081,14 @@ export class SelfHealingManager {
|
|||||||
const ownership = getCommitTaskOwnership(task.id, task.lineageId, subject, body);
|
const ownership = getCommitTaskOwnership(task.id, task.lineageId, subject, body);
|
||||||
if (!ownership.owned) continue;
|
if (!ownership.owned) continue;
|
||||||
|
|
||||||
const { stdout: tipStdout } = await execAsync(`git rev-parse --verify refs/heads/${shellQuote(integrationBranch)}`, {
|
const { stdout: tipStdout } = await execAsync(`git rev-parse --verify ${shellQuote(refName)}`, {
|
||||||
cwd: this.options.rootDir,
|
cwd: this.options.rootDir,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
const tipSha = tipStdout.trim();
|
const tipSha = tipStdout.trim();
|
||||||
if (!tipSha) continue;
|
if (!tipSha) continue;
|
||||||
|
|
||||||
const alreadyAncestor = await execAsync(`git merge-base --is-ancestor ${shellQuote(strandedSha)} refs/heads/${shellQuote(integrationBranch)}`, {
|
const alreadyAncestor = await execAsync(`git merge-base --is-ancestor ${shellQuote(strandedSha)} ${shellQuote(refName)}`, {
|
||||||
cwd: this.options.rootDir,
|
cwd: this.options.rootDir,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
}).then(() => true, () => false);
|
}).then(() => true, () => false);
|
||||||
@@ -9067,71 +9103,85 @@ export class SelfHealingManager {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
maxBuffer: 1024 * 1024,
|
maxBuffer: 1024 * 1024,
|
||||||
}).then(({ stdout }) => stdout.split("\n").map((line) => line.trim()).filter(Boolean), () => []);
|
}).then(({ stdout }) => stdout.split("\n").map((line) => line.trim()).filter(Boolean), () => []);
|
||||||
|
recoverableCandidates.push({ canonicalCandidate, strandedSha, tipSha, alreadyAncestor, landedFiles });
|
||||||
if (!alreadyAncestor) {
|
|
||||||
const currentBranch = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.options.rootDir, timeout: 30_000 })
|
|
||||||
.then(({ stdout }) => stdout.trim(), () => "");
|
|
||||||
if (currentBranch === integrationBranch) {
|
|
||||||
const head = await execAsync("git rev-parse HEAD", { cwd: this.options.rootDir, timeout: 30_000 })
|
|
||||||
.then(({ stdout }) => stdout.trim(), () => "");
|
|
||||||
const dirty = await execAsync("git status --porcelain", { cwd: this.options.rootDir, timeout: 30_000 })
|
|
||||||
.then(({ stdout }) => stdout.trim().length > 0, () => true);
|
|
||||||
if (head !== tipSha || dirty) continue;
|
|
||||||
await execAsync(`git merge --ff-only ${shellQuote(strandedSha)}`, { cwd: this.options.rootDir, timeout: 120_000 });
|
|
||||||
} else {
|
|
||||||
const advanced = await advanceIntegrationBranchRef({
|
|
||||||
rootDir: canonicalCandidate,
|
|
||||||
projectRootDir: this.options.rootDir,
|
|
||||||
integrationBranch,
|
|
||||||
newSha: strandedSha,
|
|
||||||
expectedCurrentSha: tipSha,
|
|
||||||
taskId: task.id,
|
|
||||||
audit: auditor,
|
|
||||||
});
|
|
||||||
if (!advanced.advanced) continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: MergeResult = {
|
|
||||||
task,
|
|
||||||
branch: task.branch ?? resolveTaskWorkingBranch(task),
|
|
||||||
merged: true,
|
|
||||||
noOp: false,
|
|
||||||
ok: true,
|
|
||||||
commitSha: strandedSha,
|
|
||||||
landedFiles,
|
|
||||||
mergeConfirmed: true,
|
|
||||||
worktreeRemoved: false,
|
|
||||||
branchDeleted: false,
|
|
||||||
};
|
|
||||||
const finalized = await finalizeProvenAutoMergeTask({
|
|
||||||
store: this.store,
|
|
||||||
taskId: task.id,
|
|
||||||
result,
|
|
||||||
audit: auditor,
|
|
||||||
auditAgentId: "self-healing",
|
|
||||||
auditPhase: "recover-stranded-ai-merge-commit",
|
|
||||||
source: "self-healing",
|
|
||||||
log: async (message) => {
|
|
||||||
await this.store.logEntry(task.id, message).catch(() => undefined);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (finalized.outcome === "done" || finalized.outcome === "already-done") {
|
|
||||||
await this.store.logEntry(
|
|
||||||
task.id,
|
|
||||||
`Auto-recovered stranded AI merge clean-room commit ${strandedSha.slice(0, 8)} — advanced ${integrationBranch} and finalized task`,
|
|
||||||
);
|
|
||||||
await auditor.git({
|
|
||||||
type: "merge:ai-landed",
|
|
||||||
target: integrationBranch,
|
|
||||||
metadata: { taskId: task.id, landedSha: strandedSha, source: "self-healing-stranded-clean-room", path: canonicalCandidate },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
log.warn(`recoverApprovedStrandedAiMergeCommit: ${task.id} candidate ${candidate} skipped: ${getErrorMessage(err)}`);
|
log.warn(`recoverApprovedStrandedAiMergeCommit: ${task.id} candidate ${candidate} skipped: ${getErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:AIMergeRecovery 2026-07-10-23:06:
|
||||||
|
Self-healing finalization must recover the exact reviewed clean-room commit. When historical approval logs do not include a SHA, only a single eligible same-task candidate is safe; multiple candidates are left for normal merge/review instead of guessing by filesystem order.
|
||||||
|
*/
|
||||||
|
if (recoverableCandidates.length !== 1) {
|
||||||
|
if (recoverableCandidates.length > 1) {
|
||||||
|
await this.store.logEntry(task.id, `Skipped stranded AI merge recovery: ${recoverableCandidates.length} approved clean-room candidates were ambiguous`).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = recoverableCandidates[0];
|
||||||
|
if (!selected) return false;
|
||||||
|
if (!selected.alreadyAncestor) {
|
||||||
|
const currentBranch = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: this.options.rootDir, timeout: 30_000 })
|
||||||
|
.then(({ stdout }) => stdout.trim(), () => "");
|
||||||
|
if (currentBranch === integrationBranch) {
|
||||||
|
const head = await execAsync("git rev-parse HEAD", { cwd: this.options.rootDir, timeout: 30_000 })
|
||||||
|
.then(({ stdout }) => stdout.trim(), () => "");
|
||||||
|
const dirty = await execAsync("git status --porcelain", { cwd: this.options.rootDir, timeout: 30_000 })
|
||||||
|
.then(({ stdout }) => stdout.trim().length > 0, () => true);
|
||||||
|
if (head !== selected.tipSha || dirty) return false;
|
||||||
|
await execAsync(`git merge --ff-only ${shellQuote(selected.strandedSha)}`, { cwd: this.options.rootDir, timeout: 120_000 });
|
||||||
|
} else {
|
||||||
|
const advanced = await advanceIntegrationBranchRef({
|
||||||
|
rootDir: this.options.rootDir,
|
||||||
|
projectRootDir: this.options.rootDir,
|
||||||
|
integrationBranch,
|
||||||
|
newSha: selected.strandedSha,
|
||||||
|
expectedCurrentSha: selected.tipSha,
|
||||||
|
taskId: task.id,
|
||||||
|
audit: auditor,
|
||||||
|
});
|
||||||
|
if (!advanced.advanced) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: MergeResult = {
|
||||||
|
task,
|
||||||
|
branch: task.branch ?? resolveTaskWorkingBranch(task),
|
||||||
|
merged: true,
|
||||||
|
noOp: false,
|
||||||
|
ok: true,
|
||||||
|
commitSha: selected.strandedSha,
|
||||||
|
landedFiles: selected.landedFiles,
|
||||||
|
mergeConfirmed: true,
|
||||||
|
worktreeRemoved: false,
|
||||||
|
branchDeleted: false,
|
||||||
|
};
|
||||||
|
const finalized = await finalizeProvenAutoMergeTask({
|
||||||
|
store: this.store,
|
||||||
|
taskId: task.id,
|
||||||
|
result,
|
||||||
|
audit: auditor,
|
||||||
|
auditAgentId: "self-healing",
|
||||||
|
auditPhase: "recover-stranded-ai-merge-commit",
|
||||||
|
source: "self-healing",
|
||||||
|
log: async (message) => {
|
||||||
|
await this.store.logEntry(task.id, message).catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (finalized.outcome === "done" || finalized.outcome === "already-done") {
|
||||||
|
await this.store.logEntry(
|
||||||
|
task.id,
|
||||||
|
`Auto-recovered stranded AI merge clean-room commit ${selected.strandedSha.slice(0, 8)} — advanced ${integrationBranch} and finalized task`,
|
||||||
|
);
|
||||||
|
await auditor.git({
|
||||||
|
type: "merge:ai-landed",
|
||||||
|
target: integrationBranch,
|
||||||
|
metadata: { taskId: task.id, landedSha: selected.strandedSha, source: "self-healing-stranded-clean-room", path: selected.canonicalCandidate },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user