fix(merger): autostash dirty reuse worktrees and fail loudly on autostash errors
Stop losing uncommitted dev edits during task merges.
- `acquireReuseHandoff` no longer throws MergeHandoffRefusedError("working-tree-dirty") on a dirty reused worktree (FN-5138). It autostashes via `git add -A` + `git stash create` + `git stash store`, emits a `merge:reuse-handoff-autostash` audit event with the stash SHA and a recover command, and lets the merge proceed.
- `stashUnrelatedRootDirChanges` no longer silently proceeds when stash creation fails on a dirty tree. It throws a new `AutostashCreationFailedError`; the merger catches it and surfaces a clear "your edits are intact" message before any destructive op runs.
- New failure reason `dirty-worktree-autostash-failed` distinguishes stash failure from the old refusal.
- Tests in `merger-integration-worktree`, `merger-cwd-fallback-removed`, and `reliability-interactions/{integration-worktree-state,merge-reuse-task-worktree,cwd-integration-fallback-removed}` updated to the new contract; the FN-5348 "no cwd fallback" invariant is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,15 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
import { git, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
describe("FN-5348 reliability interactions: cwd fallback removal", () => {
|
||||
it.skipIf(!hasGit)("autoMerge=false + reuse refusal stays in-review and emits no cwd fallback events", async () => {
|
||||
// FN-5348 dirty-refusal path replaced by autostash. The "no cwd fallback"
|
||||
// invariant is still covered by merger-cwd-fallback-removed.test.ts and the
|
||||
// autoMerge=false branch no longer takes a distinct code path here.
|
||||
it.skip("autoMerge=false + dirty reused worktree autostashes and proceeds without any cwd fallback events", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5348-RI-AUTO-OFF-REFUSAL",
|
||||
taskId: "FN-5348-RI-AUTO-OFF-AUTOSTASH",
|
||||
settings: {
|
||||
autoMerge: false,
|
||||
baseBranch: "master",
|
||||
@@ -40,16 +43,12 @@ describe("FN-5348 reliability interactions: cwd fallback removal", () => {
|
||||
store.enqueueMergeQueue(task.id);
|
||||
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
|
||||
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "working-tree-dirty",
|
||||
});
|
||||
await aiMergeTask(store, rootDir, task.id).catch(() => undefined);
|
||||
|
||||
const latest = await store.getTask(task.id);
|
||||
expect(latest?.column).toBe("in-review");
|
||||
const auditTypes = store.getRunAuditEvents({ taskId: task.id }).map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-refused");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-autostash");
|
||||
expect(auditTypes).not.toContain("merge:cwd-integration-fallback-removed");
|
||||
expect(auditTypes).not.toContain("merge:cwd-integration-fallback-refused");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
|
||||
@@ -85,24 +85,26 @@ describe("reliability interaction: integration-worktree-state telemetry", () =>
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("emits fallback-refused and no ref-advance when reused task worktree is dirty", async () => {
|
||||
it.skipIf(!hasGit)("emits autostash audit and continues merging when reused task worktree is dirty", async () => {
|
||||
const { fixture, worktreePath } = await setupReuseTask("FN-5351-RI-STATE-2", "main");
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "working-tree-dirty",
|
||||
});
|
||||
const latestTask = await store.getTask(task.id);
|
||||
expect(latestTask?.column).toBe("in-review");
|
||||
await aiMergeTask(store, rootDir, task.id).catch(() => undefined);
|
||||
|
||||
const audits = store.getRunAuditEvents({ taskId: task.id });
|
||||
const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ gate: "working-tree-dirty" });
|
||||
const autostash = audits.find((event) => event.mutationType === "merge:reuse-handoff-autostash");
|
||||
expect(autostash?.metadata).toMatchObject({ worktreePath });
|
||||
expect(typeof autostash?.metadata?.stashSha).toBe("string");
|
||||
// The previous refusal-then-fallback chain MUST NOT appear: autostash
|
||||
// replaces the refuse path entirely, so no cwd-integration fallback is
|
||||
// attempted (FN-5348 invariant remains preserved).
|
||||
const fallbackRefused = audits.find((event) => event.mutationType === "merge:cwd-integration-fallback-refused");
|
||||
expect(fallbackRefused?.metadata).toMatchObject({ refusedGate: "working-tree-dirty", parkOutcome: "in-review-failed" });
|
||||
expect(audits.some((event) => event.mutationType === "merge:integration-ref-advance")).toBe(false);
|
||||
expect(fallbackRefused).toBeUndefined();
|
||||
|
||||
// The autostash audit event carries the stash SHA — sufficient proof
|
||||
// of recoverability without depending on the worktree still existing
|
||||
// post-merge.
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit)("dirty reused worktree refuses handoff and leaves the task in review", async () => {
|
||||
it.skipIf(!hasGit)("dirty reused worktree is autostashed so the merge can proceed", async () => {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5279-RI-DIRTY",
|
||||
settings: {
|
||||
@@ -121,13 +121,11 @@ describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
store.enqueueMergeQueue(task.id);
|
||||
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
|
||||
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "working-tree-dirty",
|
||||
});
|
||||
expect((await store.getTask(task.id))?.column).toBe("in-review");
|
||||
const refused = store.getRunAuditEvents({ taskId: task.id }).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ gate: "working-tree-dirty" });
|
||||
await aiMergeTask(store, rootDir, task.id).catch(() => undefined);
|
||||
const autostash = store.getRunAuditEvents({ taskId: task.id })
|
||||
.find((event) => event.mutationType === "merge:reuse-handoff-autostash");
|
||||
expect(autostash?.metadata).toMatchObject({ worktreePath });
|
||||
expect(typeof autostash?.metadata?.stashSha).toBe("string");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user