feat(FN-3932): add orphaned finalize-reset autostash safeguard in merger

The merger gains a safeguard that automatically stashes work when a finalize-reset leaves orphaned records, preventing merge-state corruption. Core exports the new orphan record type, project-engine wires the safeguard, and documentation traces the provenance flow; tests were added for the recovery

Fusion-Task-Id: FN-3932
This commit is contained in:
Fusion
2026-05-10 14:19:53 -07:00
committed by gsxdsm
parent 63fe25e014
commit 05fe4208a7
11 changed files with 241 additions and 28 deletions

View File

@@ -55,6 +55,8 @@ type MockTaskStore = {
logEntry: ReturnType<typeof vi.fn>;
getActiveMergingTask: ReturnType<typeof vi.fn>;
createTask: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
};
const TASK_ID = "FN-2084";
@@ -110,6 +112,8 @@ function makeStore({
id: "FN-9999",
description: input.description,
})),
on: vi.fn(),
off: vi.fn(),
};
}
@@ -197,6 +201,57 @@ describe("ProjectEngine merge error recovery", () => {
vi.useRealTimers();
});
it("creates one recovery follow-up for live autostash orphans and dedupes by parent task", async () => {
const store = makeStore();
store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce([
{ id: "FN-9000", column: "todo", sourceType: "recovery", sourceParentTaskId: "FN-7777" },
]);
const engine = createEngine(store);
const privateEngine = engine as unknown as {
wireAutostashOrphanRecovery: (store: MockTaskStore) => void;
autostashOrphansHandler?: (data: { rootDir: string; records: Array<any> }) => Promise<void>;
};
privateEngine.wireAutostashOrphanRecovery(store);
await privateEngine.autostashOrphansHandler?.({
rootDir: "/tmp/project",
records: [
{
sha: "abcdef1234567",
ref: "stash@{0}",
label: "fusion-merger-autostash:FN-7777:finalize-reset:1",
sourceTaskId: "FN-7777",
createdAt: new Date().toISOString(),
changedPaths: ["a.ts"],
classification: "live",
sourcePhase: "finalize-reset",
detectedByTaskId: "FN-1234",
detectedAt: new Date().toISOString(),
},
],
});
await privateEngine.autostashOrphansHandler?.({
rootDir: "/tmp/project",
records: [
{
sha: "abcdef1234567",
ref: "stash@{0}",
label: "fusion-merger-autostash:FN-7777:finalize-reset:1",
sourceTaskId: "FN-7777",
createdAt: new Date().toISOString(),
changedPaths: ["a.ts"],
classification: "live",
sourcePhase: "finalize-reset",
detectedByTaskId: "FN-1234",
detectedAt: new Date().toISOString(),
},
],
});
expect(store.createTask).toHaveBeenCalledTimes(1);
});
it("uses default retry interval when interval settings retrieval fails", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");

View File

@@ -62,16 +62,19 @@ describe("autostash orphan surface", () => {
expect(records[0]?.label).toContain("fusion-merger-autostash:FN-2001");
});
it("parses sourceTaskId and createdAt; malformed labels return null fields", async () => {
it("parses sourceTaskId, createdAt, and source phase; malformed labels return null fields", async () => {
const ts = Date.now();
createAutostash(dir, `fusion-merger-autostash:FN-2002:${ts}`, "a\n");
createAutostash(dir, `fusion-merger-autostash:FN-2002:finalize-reset:${ts + 1}`, "phase\n");
createAutostash(dir, "fusion-merger-autostash:FN-2003:not-a-ts", "b\n");
const records = await listAutostashOrphans(dir);
const good = records.find((r) => r.sourceTaskId === "FN-2002");
const good = records.find((r) => r.sourceTaskId === "FN-2002" && r.sourcePhase === "pre-merge");
const bad = records.find((r) => r.label.includes("not-a-ts"));
const phased = records.find((r) => r.label.includes("finalize-reset"));
expect(good?.createdAt).toBe(new Date(ts).toISOString());
expect(phased?.sourcePhase).toBe("finalize-reset");
expect(bad?.sourceTaskId).toBe("FN-2003");
expect(bad?.createdAt).toBeNull();
});
@@ -113,11 +116,14 @@ describe("autostash orphan surface", () => {
expect(git(dir, 'git stash list --format="%H %s"')).toContain(sha);
});
it("emits merger:autostashOrphans event with records payload", async () => {
it("emits merger:autostashOrphans event with provenance payload", async () => {
createAutostash(dir, `fusion-merger-autostash:FN-2008:${Date.now()}`, "emit\n");
const store = { emit: vi.fn() } as any;
const records = await notifyAutostashOrphans(store, dir);
const records = await notifyAutostashOrphans(store, dir, { detectedByTaskId: "FN-MERGE" });
expect(records[0]?.detectedByTaskId).toBe("FN-MERGE");
expect(records[0]?.detectedAt).toMatch(/T/);
expect(records).toHaveLength(1);
expect(store.emit).toHaveBeenCalledWith("merger:autostashOrphans", {

View File

@@ -7776,6 +7776,54 @@ describe("commitOrAmendMergeWithFixes", () => {
expect(mockedExecSync.mock.calls.some((call) => String(call[0]) === "git merge-base --is-ancestor def456 abc123")).toBe(true);
});
it("persists dirty leftovers before finalize reset in no-content fallback path", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("git diff -z --cached --name-only")) return "" as any;
if (cmdStr.includes("git diff -z --name-only")) return "orphan.txt\0" as any;
if (cmdStr.includes("git diff --cached --name-only")) return "" as any;
if (cmdStr === "git diff --name-only") return "orphan.txt" as any;
if (cmdStr.includes("git status -z --porcelain")) return "" as any;
if (cmdStr === "git rev-parse HEAD") return "abc123" as any;
if (cmdStr === "git rev-parse fusion/fn-9999") return "def456" as any;
if (cmdStr === "git merge-base def456 abc123") return "zzz999" as any;
if (cmdStr === "git diff --stat abc123..fusion/fn-9999") return "" as any;
if (cmdStr === "git ls-files --others --exclude-standard") return "" as any;
if (cmdStr.includes("git log -1 --pretty=%B HEAD")) return "commit message without trailer" as any;
if (cmdStr === "git merge-base --is-ancestor def456 abc123") throw new Error("not ancestor");
if (cmdStr === "git add -A") return "" as any;
if (cmdStr === "git stash create") return "ff00aa" as any;
if (cmdStr.startsWith("git stash store -m")) return "" as any;
if (cmdStr === "git reset") return "" as any;
if (cmdStr === "git reset --hard abc123") return "" as any;
if (cmdStr === "git clean -fd") return "" as any;
if (cmdStr === "git merge --squash fusion/fn-9999") return "Already up to date." as any;
return "" as any;
});
const store = createMockStore();
const result = await commitOrAmendMergeWithFixes(
"/tmp/root",
"FN-9999",
"fusion/fn-9999",
"",
true,
"abc123",
"",
undefined,
DEFAULT_SETTINGS,
undefined,
null,
null,
new Set(),
store,
);
expect(result).toEqual({ ok: true, reason: "branch-already-merged" });
expect(mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("git stash store -m"))).toBe(true);
expect((store.logEntry as ReturnType<typeof vi.fn>).mock.calls.some((call: any[]) => String(call[1]).includes("before finalize reset/amend cleanup"))).toBe(true);
});
it("treats squash-restore 'Already up to date' with no staged changes as already-merged success", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);

View File

@@ -51,6 +51,7 @@ import {
type CanonicalMergeConflictStrategy,
type TaskSourceIssue,
type Task,
type AutostashOrphanRecord,
} from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -1042,7 +1043,7 @@ interface AutostashHandle {
}
const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:(?:[a-z0-9-]+:)?(?:\d+:)?)?(\d+)$/;
/** Return the set of paths a stash commit recorded as changed against its
* parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against
@@ -1274,15 +1275,6 @@ function parseAutostashTaskId(label: string): string | null {
return match?.[1] ?? null;
}
export interface AutostashOrphanRecord {
sha: string;
ref: string;
label: string;
sourceTaskId: string | null;
createdAt: string | null;
changedPaths: string[];
classification: "subsumed" | "live" | "unknown";
}
function parseAutostashCreatedAt(label: string): string | null {
const match = AUTOSTASH_TIMESTAMP_RE.exec(label.trim());
@@ -1292,6 +1284,15 @@ function parseAutostashCreatedAt(label: string): string | null {
return new Date(ts).toISOString();
}
function parseAutostashSourcePhase(label: string): string | null {
const trimmed = label.trim();
const phaseMatch = /^fusion-merger-autostash:[A-Za-z]+-\d+:([a-z-]+):\d+$/.exec(trimmed);
if (phaseMatch?.[1]) return phaseMatch[1];
if (/^fusion-merger-autostash:[A-Za-z]+-\d+:race-rescue-\d+:\d+$/.test(trimmed)) return "race-rescue";
if (/^fusion-merger-autostash:[A-Za-z]+-\d+:\d+$/.test(trimmed)) return "pre-merge";
return null;
}
async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> {
try {
const stashFiles = await listStashChangedPaths(rootDir, sha);
@@ -1320,13 +1321,25 @@ export async function listAutostashOrphans(rootDir: string): Promise<AutostashOr
createdAt: parseAutostashCreatedAt(orphan.label),
changedPaths,
classification: await classifyAutostashOrphan(rootDir, orphan.sha),
sourcePhase: parseAutostashSourcePhase(orphan.label),
detectedByTaskId: null,
detectedAt: null,
});
}
return records;
}
export async function notifyAutostashOrphans(store: TaskStore, rootDir: string): Promise<AutostashOrphanRecord[]> {
const records = await listAutostashOrphans(rootDir);
export async function notifyAutostashOrphans(
store: TaskStore,
rootDir: string,
options?: { detectedByTaskId?: string | null; detectedAt?: string },
): Promise<AutostashOrphanRecord[]> {
const detectedAt = options?.detectedAt ?? new Date().toISOString();
const records = (await listAutostashOrphans(rootDir)).map((record) => ({
...record,
detectedByTaskId: options?.detectedByTaskId ?? null,
detectedAt,
}));
store.emit("merger:autostashOrphans", { rootDir, records });
return records;
}
@@ -1539,7 +1552,7 @@ async function sweepAutostashOrphans(
.catch(() => undefined);
}
await notifyAutostashOrphans(store, rootDir).catch(() => undefined);
await notifyAutostashOrphans(store, rootDir, { detectedByTaskId: taskId }).catch(() => undefined);
}
export async function sweepStaleAutostashes(
@@ -1574,6 +1587,8 @@ export async function sweepStaleAutostashes(
}
}
export type { AutostashOrphanRecord };
export const __test__ = {
sweepAutostashOrphans,
parseAutostashTaskId,
@@ -2800,6 +2815,37 @@ type MergeFinalizeResult =
| { ok: true; reason: "completed" | "head-task-trailer" | "branch-already-merged" }
| { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" };
async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, store?: TaskStore): Promise<void> {
try {
const dirtyPaths = [...(await snapshotDirtyFiles(rootDir))];
if (dirtyPaths.length === 0) return;
await execAsync("git add -A", { cwd: rootDir });
const { stdout: createOut } = await execAsync("git stash create", { cwd: rootDir, encoding: "utf-8" });
const sha = String(createOut).trim();
if (!sha) {
await execAsync("git reset", { cwd: rootDir }).catch(() => undefined);
return;
}
const label = `${AUTOSTASH_LABEL_PREFIX}${taskId}:finalize-reset:${Date.now()}`;
await execAsync(`git stash store -m ${quoteArg(label)} ${sha}`, { cwd: rootDir });
await execAsync("git reset", { cwd: rootDir }).catch(() => undefined);
mergerLog.warn(
`${taskId}: persisted ${dirtyPaths.length} dirty rootDir path(s) before finalize reset as ${sha.slice(0, 7)} (${label})`,
);
if (store) {
await store.logEntry(
taskId,
`Persisted ${dirtyPaths.length} dirty rootDir path(s) before finalize reset/amend cleanup`,
`stash: ${sha}\nlabel: ${label}\nphase: finalize-reset\npaths:\n${dirtyPaths.join("\n")}`,
).catch(() => undefined);
await notifyAutostashOrphans(store, rootDir, { detectedByTaskId: taskId }).catch(() => undefined);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to persist dirty rootDir leftovers before finalize reset: ${msg}`);
}
}
export async function commitOrAmendMergeWithFixes(
rootDir: string,
taskId: string,
@@ -2814,6 +2860,7 @@ export async function commitOrAmendMergeWithFixes(
aiSummary?: string | null,
aiSubject?: string | null,
fixModifiedFiles: ReadonlySet<string> = new Set(),
store?: TaskStore,
): Promise<MergeFinalizeResult> {
try {
// Build an allowlist of paths we are permitted to stage.
@@ -2998,6 +3045,7 @@ export async function commitOrAmendMergeWithFixes(
// squash from branch -> preAttemptHeadSha and continue normally.
let squashRestoreReportedUpToDate = false;
try {
await persistFinalizeResetLeftovers(rootDir, taskId, store);
await execAsync(`git reset --hard ${preAttemptHeadSha}`, {
cwd: rootDir,
encoding: "utf-8",
@@ -5412,6 +5460,7 @@ export async function aiMergeTask(
aiMergeSummary,
aiMergeSubject,
verificationFixModifiedFiles,
store,
);
if (!finalized.ok) {
// Phantom-merge guard: refused to fabricate a commit. Reset
@@ -5533,6 +5582,7 @@ export async function aiMergeTask(
aiMergeSummary,
aiMergeSubject,
buildFixModifiedFiles,
store,
);
if (!finalized.ok) {
// Phantom-merge guard: the verification fix passed but no

View File

@@ -5,6 +5,7 @@ import type {
CentralCore,
Settings,
MergeResult,
AutostashOrphanRecord,
AutomationStore as AutomationStoreType,
ScheduledTask,
AutomationRunResult,
@@ -203,6 +204,7 @@ export class ProjectEngine {
private settingsHandlers: Array<(...args: any[]) => void> = [];
private taskMovedHandler?: (...args: any[]) => void;
private taskUpdatedHandler?: (...args: any[]) => void;
private autostashOrphansHandler?: (...args: any[]) => void;
constructor(
private config: ProjectRuntimeConfig,
@@ -438,6 +440,7 @@ export class ProjectEngine {
// 6. Wire auto-merge on task:moved and task:updated pause interruptions
this.wireAutoMerge(store, cwd);
this.wireTaskPauseMergeInterruption(store);
this.wireAutostashOrphanRecovery(store);
// 7. Auto-merge startup sweep
await this.startupMergeSweep(store);
@@ -513,6 +516,9 @@ export class ProjectEngine {
if (this.taskUpdatedHandler) {
store.off("task:updated", this.taskUpdatedHandler);
}
if (this.autostashOrphansHandler) {
store.off("merger:autostashOrphans", this.autostashOrphansHandler as any);
}
} catch {
// Store may not be initialized if start() failed partway
}
@@ -1832,6 +1838,38 @@ export class ProjectEngine {
store.on("task:moved", this.taskMovedHandler);
}
private wireAutostashOrphanRecovery(store: TaskStore): void {
this.autostashOrphansHandler = async ({ records }: { rootDir: string; records: AutostashOrphanRecord[] }) => {
const liveRecords = records.filter((record) => record.classification === "live");
for (const record of liveRecords) {
const parentTaskId = record.sourceTaskId;
if (!parentTaskId) continue;
try {
const existingFollowUp = await this.findActiveRecoveryFollowUp(store, parentTaskId);
if (existingFollowUp) continue;
const sourcePhase = record.sourcePhase ?? "unknown";
await store.createTask({
description:
`Investigate preserved merger autostash leftover from ${parentTaskId} (${record.sha.slice(0, 7)}). ` +
`Detected by ${record.detectedByTaskId ?? "merge sweep"} during ${sourcePhase}; ` +
`stash label: ${record.label}. Recover from stash-recovery before dropping.`,
sourceType: "recovery",
sourceParentTaskId: parentTaskId,
} as any);
await store.logEntry(
parentTaskId,
`Auto-created recovery follow-up for live autostash orphan ${record.sha.slice(0, 7)}`,
`detectedBy=${record.detectedByTaskId ?? "unknown"}; phase=${sourcePhase}; stash=${record.label}`,
).catch(() => undefined);
} catch (err: unknown) {
runtimeLog.warn(`Autostash orphan recovery follow-up failed for ${parentTaskId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
};
store.on("merger:autostashOrphans", this.autostashOrphansHandler as any);
}
private wireTaskPauseMergeInterruption(store: TaskStore): void {
this.taskUpdatedHandler = async (task: Task) => {
if (task.column !== "in-review") {