feat(FN-5083): complete Step 2 — add in-review branch rebind stage

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 16:39:39 -07:00
committed by gsxdsm
parent af7367ab99
commit 77342c03fb
2 changed files with 325 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { logger } = vi.hoisted(() => ({
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => logger),
}));
import { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
function git(cwd: string, command: string): string {
return execSync(`git ${command}`, { cwd, encoding: "utf8" }).trim();
}
describe("reconcileInReviewBranchRebind", () => {
let rootDir = "";
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-5083-rebind-"));
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'init'");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
});
afterEach(() => {
try { store?.close(); } catch {}
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
vi.clearAllMocks();
});
async function createInReviewTask(title: string) {
const task = await store.createTask({ title, description: title });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
return task.id;
}
it("returns no-live-branch when no candidate exists", async () => {
const id = await createInReviewTask("no branch");
await store.updateTask(id, { branch: null, worktree: null });
const manager = new SelfHealingManager(store, { rootDir });
const result = await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([id]) });
expect(result.repaired).toBe(0);
expect(result.outcomes).toContainEqual({ taskId: id, result: "skipped", reason: "no-live-branch" });
});
it("applies rebind when one candidate has unique work", async () => {
const id = await createInReviewTask("single candidate");
const branch = `fusion/${id.toLowerCase()}`;
git(rootDir, `checkout -b ${branch}`);
writeFileSync(join(rootDir, `${id}.txt`), "feature\n");
git(rootDir, `add ${id}.txt`);
git(rootDir, `commit -m 'feat(${id}): change'`);
git(rootDir, "checkout main");
await store.updateTask(id, { branch: null, worktree: null, baseCommitSha: null });
const manager = new SelfHealingManager(store, { rootDir });
const result = await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([id]) });
expect(result.repaired).toBe(1);
expect(result.outcomes).toEqual(
expect.arrayContaining([
expect.objectContaining({ taskId: id, result: "applied", branch }),
]),
);
});
it("skips no-unique-work when candidates are not ahead", async () => {
const id = await createInReviewTask("no unique work");
const branch = `fusion/${id.toLowerCase()}`;
git(rootDir, `branch ${branch}`);
await store.updateTask(id, { branch: null, worktree: null });
const manager = new SelfHealingManager(store, { rootDir });
const result = await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([id]) });
expect(result.repaired).toBe(0);
expect(result.outcomes).toContainEqual({ taskId: id, result: "skipped", reason: "no-unique-work" });
});
it("records binding-intact when current branch exists", async () => {
const id = await createInReviewTask("binding intact");
const branch = `fusion/${id.toLowerCase()}`;
git(rootDir, `branch ${branch}`);
await store.updateTask(id, { branch });
const manager = new SelfHealingManager(store, { rootDir });
const result = await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([id]) });
expect(result.repaired).toBe(0);
expect(result.outcomes).toContainEqual({ taskId: id, result: "skipped", reason: "binding-intact" });
});
});

View File

@@ -395,6 +395,24 @@ interface OrphanBranchInspection {
registeredWorktreePath: string | null;
}
type RebindOutcome =
| {
taskId: string;
result: "applied";
branch: string;
aheadCount: number;
integrationBase: string;
previousBranch: string | null;
}
| {
taskId: string;
result: "skipped";
reason: "binding-intact" | "no-live-branch" | "ambiguous-candidates" | "no-unique-work";
candidates?: Array<{ branch: string; aheadCount: number }>;
};
export type RebindResult = { repaired: number; outcomes: RebindOutcome[] };
interface LandedTaskCommit {
sha: string;
subject?: string;
@@ -562,6 +580,12 @@ export class SelfHealingManager {
// In-memory only counter; resets on engine restart.
this.boardStallWindow.transitionsOutOfInProgressInWindow++;
}
if (to === "in-review") {
void this.reconcileInReviewBranchRebind({ includeTaskIds: new Set([task.id]) }).catch((err: unknown) => {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`[self-healing] task:moved in-review rebind failed for ${task.id}: ${errorMessage}`);
});
}
const shouldReconcile =
(from === "in-review" && to === "done") ||
(from === "done" && to === "archived");
@@ -630,6 +654,7 @@ export class SelfHealingManager {
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
// FN-4962 ordering invariant: metadata reconcile must run before stale-active reclaim.
{ name: "reconcile-task-worktree-metadata", fn: () => this.reconcileTaskWorktreeMetadata().then(() => undefined) },
{ name: "reconcile-in-review-branch-rebind", fn: () => this.reconcileInReviewBranchRebind().then(() => undefined) },
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches().then(() => undefined) },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews().then(() => undefined) },
@@ -1214,6 +1239,7 @@ export class SelfHealingManager {
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
// FN-4962 ordering invariant: metadata reconcile must run before stale-active reclaim.
{ name: "reconcile-task-worktree-metadata", fn: () => this.reconcileTaskWorktreeMetadata() },
{ name: "reconcile-in-review-branch-rebind", fn: () => this.reconcileInReviewBranchRebind().then(() => undefined) },
{ name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches() },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() },
@@ -2491,6 +2517,197 @@ export class SelfHealingManager {
}
}
private async emitBranchRebindAuditEvent(input: {
taskId: string;
mutationType: "task:auto-rebind-applied" | "task:auto-rebind-skipped";
metadata: Record<string, unknown>;
}): Promise<void> {
try {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", input.taskId),
agentId: "self-healing",
taskId: input.taskId,
phase: "in-review-branch-rebind",
});
await auditor.database({
type: input.mutationType,
target: input.taskId,
metadata: input.metadata,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
worktreeMetadataReconcileLog.warn(
`Failed to record ${input.mutationType} for ${input.taskId}: ${errorMessage}`,
);
}
}
async reconcileInReviewBranchRebind(options?: { includeTaskIds?: Set<string> }): Promise<RebindResult> {
const result: RebindResult = { repaired: 0, outcomes: [] };
try {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return result;
const allTasks = await this.store.listTasks({ slim: true, includeArchived: false });
const tasks = allTasks.filter((task) => task.column === "in-review");
const fusionRefOutput = await execAsync("git for-each-ref --format='%(refname:short)' refs/heads/fusion/", {
cwd: this.options.rootDir,
timeout: 30_000,
}).catch(() => ({ stdout: "" }));
const fusionBranches = fusionRefOutput.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
for (const task of tasks) {
if (options?.includeTaskIds && !options.includeTaskIds.has(task.id)) continue;
const existingBinding = task.branch;
if (existingBinding) {
try {
await execAsync(`git show-ref --verify --quiet ${shellQuote(`refs/heads/${existingBinding}`)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
result.outcomes.push({ taskId: task.id, result: "skipped", reason: "binding-intact" });
continue;
} catch {
// broken binding, evaluate candidates
}
}
const normalizedId = task.id.toLowerCase();
const candidates = new Set<string>([`fusion/${normalizedId}`, `fusion/${task.id}`]);
for (const branch of fusionBranches) {
const stem = branch.startsWith("fusion/") ? branch.slice("fusion/".length) : "";
if (stem.toLowerCase() === normalizedId) candidates.add(branch);
}
const integrationBase = task.baseBranch || "main";
const existingCandidatesByRef = new Map<string, { branch: string; aheadCount: number }>();
for (const branch of candidates) {
try {
await execAsync(`git show-ref --verify --quiet ${shellQuote(`refs/heads/${branch}`)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
} catch {
continue;
}
let comparisonBase = integrationBase;
try {
await execAsync(`git rev-parse --verify ${shellQuote(comparisonBase)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
} catch {
const originBase = `origin/${comparisonBase}`;
await execAsync(`git rev-parse --verify ${shellQuote(originBase)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
comparisonBase = originBase;
}
const mergeBase = (await execAsync(`git merge-base ${shellQuote(comparisonBase)} ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
})).stdout.trim();
const aheadCountRaw = await execAsync(`git rev-list --count ${shellQuote(mergeBase)}..${shellQuote(branch)}`, {
cwd: this.options.rootDir,
timeout: 30_000,
});
const aheadCount = Number.parseInt(aheadCountRaw.stdout.trim(), 10);
const normalizedBranchRef = branch.toLowerCase();
const existingCandidate = existingCandidatesByRef.get(normalizedBranchRef);
const normalizedCandidate = `fusion/${normalizedId}`;
if (!existingCandidate || branch === normalizedCandidate) {
existingCandidatesByRef.set(normalizedBranchRef, {
branch,
aheadCount: Number.isFinite(aheadCount) ? aheadCount : 0,
});
}
}
const existingCandidates = [...existingCandidatesByRef.values()];
if (existingCandidates.length === 0) {
await this.emitBranchRebindAuditEvent({
taskId: task.id,
mutationType: "task:auto-rebind-skipped",
metadata: { taskId: task.id, reason: "no-live-branch" },
});
result.outcomes.push({ taskId: task.id, result: "skipped", reason: "no-live-branch" });
continue;
}
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 };
if (!task.baseCommitSha) {
patch.baseCommitSha = (await execAsync(
`git merge-base ${shellQuote(integrationBase)} ${shellQuote(selected.branch)}`,
{ cwd: this.options.rootDir, timeout: 30_000 },
)).stdout.trim() || null;
}
// TODO(FN-5066): tighten composition once helper API is final.
try {
const maybeAsserting = this as unknown as { assertSafeToAutoMutate?: (opts: unknown) => Promise<void> };
await maybeAsserting.assertSafeToAutoMutate?.({ taskId: task.id, reason: "in-review-branch-rebind" });
} catch (assertErr: unknown) {
const message = assertErr instanceof Error ? assertErr.message : String(assertErr);
log.warn(`[self-healing] assertSafeToAutoMutate warning for ${task.id}: ${message}; continuing rebind`);
}
await this.store.updateTask(task.id, patch);
await this.emitBranchRebindAuditEvent({
taskId: task.id,
mutationType: "task:auto-rebind-applied",
metadata: {
taskId: task.id,
branch: selected.branch,
aheadCount: selected.aheadCount,
integrationBase,
source: "auto-rebind-in-review",
previousBranch: task.branch ?? null,
},
});
result.repaired++;
result.outcomes.push({
taskId: task.id,
result: "applied",
branch: selected.branch,
aheadCount: selected.aheadCount,
integrationBase,
previousBranch: task.branch ?? null,
});
continue;
}
if (withUniqueWork.length > 1) {
await this.emitBranchRebindAuditEvent({
taskId: task.id,
mutationType: "task:auto-rebind-skipped",
metadata: { taskId: task.id, reason: "ambiguous-candidates", candidates: withUniqueWork },
});
log.warn(`[self-healing] ambiguous branch rebind candidates for ${task.id}: ${JSON.stringify(withUniqueWork)}`);
result.outcomes.push({ taskId: task.id, result: "skipped", reason: "ambiguous-candidates", candidates: withUniqueWork });
continue;
}
await this.emitBranchRebindAuditEvent({
taskId: task.id,
mutationType: "task:auto-rebind-skipped",
metadata: { taskId: task.id, reason: "no-unique-work" },
});
result.outcomes.push({ taskId: task.id, result: "skipped", reason: "no-unique-work" });
}
return result;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
worktreeMetadataReconcileLog.error(`reconcileInReviewBranchRebind failed: ${errorMessage}`);
return result;
}
}
async reconcileTaskWorktreeMetadata(options?: { includeTaskIds?: Set<string> }): Promise<number> {
try {
const settings = await this.store.getSettings();