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