A stale base is an optimization miss, not an execution failure. FN-8693's dispatch-time refresh refused dirty checkouts and own-commit rebase conflicts with executionSafe:false, and the refusal threw out of acquireTaskWorktree into execute()'s generic terminal sink — parking the task `failed` and paging the operator. Run-audit for 2026-08-01..09: 99 of 136 execution failures were these refusals (74 dirty-worktree, 25 stale-base-conflict), and the bounded non-parking lane built for them fired 0 times because it only ever saw refusals published as typed graph node values and no code node enables refreshStaleBase. 82 of the 99 landed within five minutes of "Task marked done by agent": they were code-review-remediation re-entries into execute() on the task's own warm worktree — exactly the checkout the refresh must leave alone. Dispatch-time rebase has no conflict resolution, so on a busy main it could only ever fail; the merge lane already rebases with AI arbitration before landing and deliberately leaves refreshStaleBase off. - refreshReusedWorktreeBase: dirty tree, own-commit conflict, unresolvable base and compensated persistence failures now return skipped/executionSafe — keep the local base and run. Only an unproven tree (failed compensation, so a half-rebased checkout may be on disk) still refuses. - Check whether a mutation is needed before consulting the working tree: a worktree already on the current base was refused just for carrying WIP. - executor: catch WorktreeBaseRefreshError first and route it into holdForWorktreeBaseRefresh, one shared non-parking lane the graph path now uses too, so the two entry points cannot drift. - run-audit: worktree:base-refresh-skipped separates a declined refresh from a genuine block. reset-to-base — the actual FN-8693 requirement — is preserved and tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
10 KiB
TypeScript
197 lines
10 KiB
TypeScript
import { execSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { refreshReusedWorktreeBase } from "../worktree-base-refresh.js";
|
|
|
|
const paths: string[] = [];
|
|
const git = (cwd: string, command: string) => execSync(`git ${command}`, { cwd, encoding: "utf8" }).trim();
|
|
function fixture() {
|
|
const root = mkdtempSync(join(tmpdir(), "fn-8693-base-"));
|
|
paths.push(root);
|
|
git(root, "init -b main");
|
|
git(root, 'config user.email "test@example.com"');
|
|
git(root, 'config user.name "Test"');
|
|
writeFileSync(join(root, "README.md"), "C0\n");
|
|
git(root, "add README.md && git commit -m C0");
|
|
const c0 = git(root, "rev-parse HEAD");
|
|
const worktree = join(root, "task");
|
|
git(root, `worktree add -b fusion/fn-1 ${JSON.stringify(worktree)} ${c0}`);
|
|
writeFileSync(join(root, "scaffold.ts"), "export const scaffold = true;\n");
|
|
git(root, "add scaffold.ts && git commit -m C1");
|
|
return { root, worktree, c0, c1: git(root, "rev-parse HEAD") };
|
|
}
|
|
afterEach(() => paths.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })));
|
|
|
|
/*
|
|
FNXC:WorktreeBaseRefreshTests 2026-08-01-16:04:
|
|
These temp-git fixtures reproduce a dependency task planned at C0 while main advances to C1.
|
|
They prove execution refresh stores integration C1, never stale C0, before a session caller may proceed.
|
|
*/
|
|
describe("refreshReusedWorktreeBase", () => {
|
|
it("resets a clean planning branch to C1 and persists C1", async () => {
|
|
const { root, worktree, c0, c1 } = fixture();
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
expect(result).toMatchObject({ kind: "reset-to-base", executionSafe: true, baseSha: c1 });
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c1);
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { baseCommitSha: c1 });
|
|
});
|
|
|
|
it("rebases own commit C2 onto C1 while storing C1, not C2", async () => {
|
|
const { root, worktree, c0, c1 } = fixture();
|
|
writeFileSync(join(worktree, "implementation.ts"), "export const implementation = true;\n");
|
|
git(worktree, "add implementation.ts && git commit -m C2");
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
const c2 = git(worktree, "rev-parse HEAD");
|
|
expect(result).toMatchObject({ kind: "rebased", executionSafe: true, baseSha: c1, observedHead: c2 });
|
|
expect(c2).not.toBe(c1);
|
|
expect(git(worktree, `merge-base --is-ancestor ${c1} ${c2}; echo $?`)).toBe("0");
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { baseCommitSha: c1 });
|
|
});
|
|
|
|
it("returns the compensated persistence failure after restoring C0", async () => {
|
|
const { root, worktree, c0 } = fixture();
|
|
const store = { updateTask: vi.fn().mockRejectedValue(new Error("database unavailable")) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
expect(result.kind).toBe("base-persistence-failed-compensated");
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c0);
|
|
});
|
|
|
|
it("statelessly reconciles stale durable C0 when clean HEAD is already C1", async () => {
|
|
const { root, worktree, c0, c1 } = fixture();
|
|
git(worktree, `reset --hard ${c1}`);
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
expect(result).toMatchObject({ kind: "up-to-date", executionSafe: true, baseSha: c1 });
|
|
expect(store.updateTask).toHaveBeenCalledWith("FN-1", { baseCommitSha: c1 });
|
|
});
|
|
|
|
it("skips a dirty checkout without changing the durable baseline, and stays execution-safe", async () => {
|
|
const { root, worktree, c0 } = fixture();
|
|
writeFileSync(join(worktree, "dirty.txt"), "keep me\n");
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
expect(result).toMatchObject({ kind: "dirty-worktree", executionSafe: true, skipped: true });
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c0);
|
|
expect(git(worktree, "status --porcelain")).toContain("dirty.txt");
|
|
});
|
|
|
|
/*
|
|
FNXC:WorktreeBaseRefreshTests 2026-08-09-23:49:
|
|
The invariant, asserted across every surface that produced an execution refusal in production: a refresh that
|
|
cannot proceed leaves the checkout untouched and lets the session run. Between 2026-08-01 and 2026-08-09 the
|
|
blocking form parked 99 tasks `failed` — 74 dirty-worktree, 25 own-commit rebase conflicts — for a base that
|
|
the merge lane rebases with conflict resolution anyway. Only an UNPROVEN tree may still refuse.
|
|
*/
|
|
describe("never blocks execution for a recoverable base state", () => {
|
|
it("keeps the local base when rebasing own commits onto a moved base conflicts", async () => {
|
|
const { root, worktree, c0, c1 } = fixture();
|
|
// Same file edited on both sides of the fork → the rebase onto C1 must conflict.
|
|
writeFileSync(join(worktree, "scaffold.ts"), "export const scaffold = 'task-side';\n");
|
|
git(worktree, "add scaffold.ts && git commit -m C2");
|
|
const c2 = git(worktree, "rev-parse HEAD");
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
|
|
expect(result).toMatchObject({ kind: "stale-base-conflict", executionSafe: true, skipped: true });
|
|
// Compensated back to the task's own tip, with no rebase left in progress and no work lost.
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c2);
|
|
expect(git(worktree, "status --porcelain")).toBe("");
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
expect(c2).not.toBe(c1);
|
|
});
|
|
|
|
it("runs on the existing base when the base commit cannot be resolved", async () => {
|
|
const { worktree, c0 } = fixture();
|
|
// A repo with no `main` commit to resolve: `rev-parse main^{commit}` fails, so the probe itself is unusable.
|
|
const emptyRoot = mkdtempSync(join(tmpdir(), "fn-8693-empty-"));
|
|
paths.push(emptyRoot);
|
|
git(emptyRoot, "init -b main");
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any,
|
|
rootDir: emptyRoot,
|
|
worktreePath: worktree,
|
|
store,
|
|
settings: {},
|
|
});
|
|
|
|
expect(result).toMatchObject({ kind: "base-unresolvable", executionSafe: true, skipped: true });
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c0);
|
|
expect(store.updateTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats a worktrunk-managed checkout as safe rather than refusing execution", async () => {
|
|
const { root, worktree, c0 } = fixture();
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any,
|
|
rootDir: root,
|
|
worktreePath: worktree,
|
|
store,
|
|
settings: { worktrunk: { enabled: true } } as any,
|
|
});
|
|
expect(result).toMatchObject({ kind: "worktrunk-refresh-unsupported", executionSafe: true, skipped: true });
|
|
});
|
|
|
|
/*
|
|
The ordering defect on its own: HEAD already contains the integration tip, so no git command would run and
|
|
uncommitted work is irrelevant — yet the dirty check ran first and refused execution anyway.
|
|
*/
|
|
it("reports up-to-date for a dirty checkout that already sits on the current base", async () => {
|
|
const { root, worktree, c1 } = fixture();
|
|
git(worktree, `reset --hard ${c1}`);
|
|
writeFileSync(join(worktree, "wip.ts"), "export const wip = true;\n");
|
|
const store = { updateTask: vi.fn().mockResolvedValue(undefined) } as any;
|
|
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c1 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
|
|
expect(result).toMatchObject({ kind: "up-to-date", executionSafe: true });
|
|
expect(result.skipped).toBeUndefined();
|
|
expect(git(worktree, "status --porcelain")).toContain("wip.ts");
|
|
});
|
|
|
|
it("still proceeds when only the durable baseline write fails on an already-current head", async () => {
|
|
const { root, worktree, c0, c1 } = fixture();
|
|
git(worktree, `reset --hard ${c1}`);
|
|
const store = { updateTask: vi.fn().mockRejectedValue(new Error("database unavailable")) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
// No git command ran, so HEAD is the verified-current tip — a failed baseline write must not block a session.
|
|
expect(result).toMatchObject({ executionSafe: true, skipped: true });
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c1);
|
|
});
|
|
|
|
it("keeps the compensated persistence failure execution-safe", async () => {
|
|
const { root, worktree, c0 } = fixture();
|
|
const store = { updateTask: vi.fn().mockRejectedValue(new Error("database unavailable")) } as any;
|
|
const result = await refreshReusedWorktreeBase({
|
|
task: { id: "FN-1", baseCommitSha: c0 } as any, rootDir: root, worktreePath: worktree, store, settings: {},
|
|
});
|
|
expect(result).toMatchObject({ kind: "base-persistence-failed-compensated", executionSafe: true, skipped: true });
|
|
expect(git(worktree, "rev-parse HEAD")).toBe(c0);
|
|
});
|
|
});
|
|
});
|