fix: preserve live worktree through in-review branch rebind and certify lifecycle hold
The in-review branch-rebind sweep unconditionally nulled task.worktree while repairing a broken branch binding, making the checkout invisible to scanIdleWorktrees' active set so the idle sweep reaped it — the reported "worktree lost between review and in-progress" incident. The rebind now keeps the pointer when the directory exists and is checked out on the rebound branch, and the applied audit event records preservedWorktree. Adds a reliability-lane certification suite pinning that worktree metadata and the on-disk directory survive in-progress ↔ in-review transitions and idle-in-review maintenance ticks, and that severed metadata is exactly what makes a directory reap-eligible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/worktree-rebind-preserve.md
Normal file
7
.changeset/worktree-rebind-preserve.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep a task's live worktree through in-review branch rebinds instead of losing it to the idle sweep.
|
||||
category: fix
|
||||
dev: "`task:auto-rebind-applied` now records `preservedWorktree`; adds the reliability-lane worktree lifecycle certification suite."
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
FNXC:WorktreeLifecycleCertification 2026-08-17-20:59:
|
||||
Certification suite for the invariant "a task's worktree is held across the full lifecycle".
|
||||
Motivating incident: a user's worktree was lost between in-review and in-progress. Root cause
|
||||
anatomy is a two-step: (1) a self-healing sweep nulls `task.worktree` while the card idles in
|
||||
review — the in-review branch rebind did this unconditionally as part of a *branch* repair —
|
||||
then (2) `scanIdleWorktrees` no longer counts the directory as active, so the idle sweep / cap
|
||||
enforcement `git worktree remove`s it, making the loss permanent.
|
||||
|
||||
This suite pins:
|
||||
- worktree + branch metadata and the on-disk directory survive every in-progress ↔ in-review
|
||||
transition, including idle-in-review maintenance ticks (branch rebind + metadata reconcile);
|
||||
- the branch-rebind sweep preserves a live checkout that is checked out on the rebound branch
|
||||
(and still clears a dangling pointer whose directory is gone — the legacy behavior);
|
||||
- a worktree referenced by an unfinished task is never classified idle/reapable, and nulling
|
||||
the metadata is precisely what makes it reapable (the two-step mechanism itself).
|
||||
|
||||
Real git + real PG; reliability lane (serialized) — deliberately NOT gate-eligible.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
git,
|
||||
hasGit,
|
||||
hasPg,
|
||||
makeReliabilityFixture,
|
||||
type ReliabilityFixture,
|
||||
} from "./_helpers.js";
|
||||
import { scanIdleWorktrees } from "../../worktree/worktree-pool.js";
|
||||
|
||||
const describeCertification = hasGit && hasPg ? describe : describe.skip;
|
||||
|
||||
type CertFixture = {
|
||||
fx: ReliabilityFixture;
|
||||
taskId: string;
|
||||
canonicalBranch: string;
|
||||
/* Canonical (realpath) project root: macOS tmpdirs are symlinks (/var → /private/var) and
|
||||
`getRegisteredWorktreePaths` canonicalizes, so scans must run against the real path or every
|
||||
"not idle" assertion passes vacuously against an empty registered set. */
|
||||
rootReal: string;
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
async function readTask(cert: CertFixture) {
|
||||
// Read the persisted row, not the write-through cache, so assertions certify storage.
|
||||
cert.fx.store.taskCache.clear();
|
||||
const task = await cert.fx.store.getTask(cert.taskId);
|
||||
if (!task) throw new Error(`certification task ${cert.taskId} disappeared`);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fixture whose task holds a REAL registered git worktree checked out on the
|
||||
* canonical fusion branch with one commit of unique work (aheadCount > 0), mirroring a
|
||||
* card that finished implementation and is moving through review.
|
||||
* The store owns id assignment, so everything derives from `fx.task.id`.
|
||||
*/
|
||||
async function makeCertFixture(input?: { column?: string; brokenBranchBinding?: boolean }): Promise<CertFixture> {
|
||||
const fx = await makeReliabilityFixture({
|
||||
task: { column: input?.column ?? "in-progress" },
|
||||
});
|
||||
const taskId = fx.task.id;
|
||||
const canonicalBranch = `fusion/${taskId.toLowerCase()}`;
|
||||
const rootReal = realpathSync(fx.rootDir);
|
||||
const worktreesDir = join(rootReal, ".worktrees");
|
||||
mkdirSync(worktreesDir, { recursive: true });
|
||||
git(fx.rootDir, `git branch ${canonicalBranch}`);
|
||||
const worktreePath = join(worktreesDir, taskId.toLowerCase());
|
||||
git(fx.rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${canonicalBranch}`);
|
||||
await writeFile(join(worktreePath, "work.txt"), "unique work\n", "utf-8");
|
||||
git(worktreePath, "git add work.txt");
|
||||
git(worktreePath, 'git commit -m "feat: unique work"');
|
||||
await fx.store.updateTask(taskId, {
|
||||
worktree: worktreePath,
|
||||
branch: input?.brokenBranchBinding ? `${canonicalBranch}-renamed-away` : canonicalBranch,
|
||||
} as never);
|
||||
return { fx, taskId, canonicalBranch, rootReal, worktreePath };
|
||||
}
|
||||
|
||||
async function expectHeld(cert: CertFixture, context: string): Promise<void> {
|
||||
const task = await readTask(cert);
|
||||
expect(task.worktree, `${context}: task.worktree`).toBe(cert.worktreePath);
|
||||
expect(task.branch, `${context}: task.branch`).toBe(cert.canonicalBranch);
|
||||
expect(existsSync(cert.worktreePath), `${context}: directory on disk`).toBe(true);
|
||||
const idle = await scanIdleWorktrees(cert.rootReal, cert.fx.store, cert.fx.settings);
|
||||
expect(idle, `${context}: not idle/reapable`).not.toContain(cert.worktreePath);
|
||||
}
|
||||
|
||||
describeCertification("worktree lifecycle certification", () => {
|
||||
let cert: CertFixture | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await cert?.fx.cleanup();
|
||||
cert = undefined;
|
||||
});
|
||||
|
||||
it("holds worktree metadata and directory across in-progress ↔ in-review transitions with idle-review sweeps", async () => {
|
||||
cert = await makeCertFixture({ column: "in-progress" });
|
||||
await expectHeld(cert, "seeded in-progress");
|
||||
|
||||
// Implementation done → review.
|
||||
await cert.fx.store.moveTask(cert.taskId, "in-review");
|
||||
await expectHeld(cert, "after move to in-review");
|
||||
|
||||
// The card idles in review while maintenance ticks run. An intact binding must be a no-op.
|
||||
const rebind = await cert.fx.manager.reconcileInReviewBranchRebind();
|
||||
expect(rebind.outcomes).toEqual([
|
||||
{ taskId: cert.taskId, result: "skipped", reason: "binding-intact" },
|
||||
]);
|
||||
await cert.fx.manager.reconcileTaskWorktreeMetadata();
|
||||
await expectHeld(cert, "after idle-in-review maintenance sweeps");
|
||||
|
||||
// The reported boundary: review rebounds back to in-progress. The checkout must survive.
|
||||
await cert.fx.store.moveTask(cert.taskId, "in-progress");
|
||||
await expectHeld(cert, "after rebound to in-progress");
|
||||
|
||||
// And forward into review again.
|
||||
await cert.fx.store.moveTask(cert.taskId, "in-review");
|
||||
await expectHeld(cert, "after re-entering in-review");
|
||||
});
|
||||
|
||||
it("branch rebind preserves a live checkout that is checked out on the rebound branch", async () => {
|
||||
// The incident shape: the card sits in review with a broken `branch` binding while the
|
||||
// canonical branch (with unique work) and its worktree are alive and well.
|
||||
cert = await makeCertFixture({ column: "in-review", brokenBranchBinding: true });
|
||||
|
||||
const result = await cert.fx.manager.reconcileInReviewBranchRebind();
|
||||
expect(result.repaired).toBe(1);
|
||||
expect(result.outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
taskId: cert.taskId,
|
||||
result: "applied",
|
||||
branch: cert.canonicalBranch,
|
||||
preservedWorktree: true,
|
||||
}),
|
||||
]);
|
||||
await expectHeld(cert, "after branch rebind");
|
||||
});
|
||||
|
||||
it("branch rebind still clears a dangling worktree pointer whose directory is gone", async () => {
|
||||
cert = await makeCertFixture({ column: "in-review", brokenBranchBinding: true });
|
||||
// Simulate the directory having been legitimately removed out-of-band.
|
||||
git(cert.fx.rootDir, `git worktree remove --force ${JSON.stringify(cert.worktreePath)}`);
|
||||
expect(existsSync(cert.worktreePath)).toBe(false);
|
||||
|
||||
const result = await cert.fx.manager.reconcileInReviewBranchRebind();
|
||||
expect(result.outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
taskId: cert.taskId,
|
||||
result: "applied",
|
||||
branch: cert.canonicalBranch,
|
||||
preservedWorktree: false,
|
||||
}),
|
||||
]);
|
||||
const task = await readTask(cert);
|
||||
expect(task.branch).toBe(cert.canonicalBranch);
|
||||
expect(task.worktree ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("nulling task.worktree is exactly what makes the directory reap-eligible (two-step mechanism)", async () => {
|
||||
cert = await makeCertFixture({ column: "in-review" });
|
||||
await expectHeld(cert, "metadata intact");
|
||||
|
||||
// Sever the metadata the way the pre-fix rebind did, and the directory becomes idle prey.
|
||||
await cert.fx.store.updateTask(cert.taskId, { worktree: null } as never);
|
||||
// The scan reads through the startup slim-list memo; drop it so the scan sees the severed row.
|
||||
cert.fx.store.clearStartupSlimListMemo();
|
||||
const idle = await scanIdleWorktrees(cert.rootReal, cert.fx.store, cert.fx.settings);
|
||||
expect(idle).toContain(cert.worktreePath);
|
||||
});
|
||||
});
|
||||
@@ -621,6 +621,8 @@ type RebindOutcome =
|
||||
aheadCount: number;
|
||||
integrationBase: string;
|
||||
previousBranch: string | null;
|
||||
/* FNXC:SelfHealingRebind 2026-08-17-20:59: true when the rebind kept `task.worktree` because the directory exists and is checked out on the rebound branch. */
|
||||
preservedWorktree: boolean;
|
||||
}
|
||||
| {
|
||||
taskId: string;
|
||||
@@ -5601,7 +5603,30 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const withUniqueWork = existingCandidates.filter((candidate) => candidate.aheadCount > 0);
|
||||
if (withUniqueWork.length === 1) {
|
||||
const selected = withUniqueWork[0];
|
||||
const patch: Partial<Task> = { branch: selected.branch, worktree: null as unknown as string };
|
||||
/*
|
||||
FNXC:SelfHealingRebind 2026-08-17-20:59:
|
||||
A branch rebind must not discard a live checkout. Nulling `task.worktree` here made the
|
||||
existing worktree directory invisible to `scanIdleWorktrees`' active set, so the next idle
|
||||
sweep / cap enforcement `git worktree remove`d it — the reported "worktree lost between
|
||||
review and in-progress" incident. Preserve the pointer when the directory still exists and
|
||||
is checked out on the branch being rebound to; only a missing or mismatched checkout keeps
|
||||
the legacy clear (worktree acquisition re-validates and recreates on next dispatch anyway).
|
||||
*/
|
||||
let preservedWorktree = false;
|
||||
if (task.worktree && existsSync(task.worktree)) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: task.worktree,
|
||||
timeout: 30_000,
|
||||
});
|
||||
preservedWorktree = stdout.trim() === selected.branch;
|
||||
} catch {
|
||||
// unreadable checkout — fall back to clearing metadata
|
||||
}
|
||||
}
|
||||
const patch: Partial<Task> = preservedWorktree
|
||||
? { branch: selected.branch }
|
||||
: { branch: selected.branch, worktree: null as unknown as string };
|
||||
if (!task.baseCommitSha) {
|
||||
const derivedBaseCommit = (await execAsync(
|
||||
`git merge-base ${shellQuote(integrationBase)} ${shellQuote(selected.branch)}`,
|
||||
@@ -5641,6 +5666,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
integrationBase,
|
||||
source: "auto-rebind-in-review",
|
||||
previousBranch: task.branch ?? null,
|
||||
preservedWorktree,
|
||||
},
|
||||
});
|
||||
result.repaired++;
|
||||
@@ -5651,6 +5677,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
aheadCount: selected.aheadCount,
|
||||
integrationBase,
|
||||
previousBranch: task.branch ?? null,
|
||||
preservedWorktree,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user