feat(FN-5083): complete Step 3 — canonicalize fusion branch naming

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 16:51:27 -07:00
committed by gsxdsm
parent 13d7195642
commit c56e0cf814
10 changed files with 52 additions and 30 deletions

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { canonicalFusionBranchName } from "../worktree-names.js";
describe("executor branch canonicalization", () => {
it("canonicalizes mixed-case task IDs to lowercase fusion branches", () => {
expect(canonicalFusionBranchName("FN-5083")).toBe("fusion/fn-5083");
expect(canonicalFusionBranchName("Fn-ABC-123")).toBe("fusion/fn-abc-123");
});
});

View File

@@ -1,6 +1,8 @@
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { canonicalFusionBranchName } from "./worktree-names.js";
const execAsync = promisify(exec);
export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal";
@@ -82,7 +84,7 @@ export async function findAlreadyMergedTaskCommit(
}
let branchTip: string | null = null;
const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
const branchName = taskBranch || canonicalFusionBranchName(taskId);
try {
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
cwd: repoDir,
@@ -182,7 +184,7 @@ export async function findAlreadyMergedTaskCommit(
}
try {
const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
const treeBranchName = taskBranch || canonicalFusionBranchName(taskId);
execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {
cwd: repoDir,
encoding: "utf-8",

View File

@@ -27,7 +27,7 @@ import {
VERIFICATION_LOG_MAX_CHARS,
type VerificationResult,
} from "./verification-utils.js";
import { generateWorktreeName } from "./worktree-names.js";
import { canonicalFusionBranchName, generateWorktreeName } from "./worktree-names.js";
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
@@ -5291,7 +5291,7 @@ export class TaskExecutor {
worktreePathOverride?: string,
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
const settings = await this.store.getSettings();
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null;
if (!worktreePath) {
@@ -6065,7 +6065,7 @@ export class TaskExecutor {
// Delete the branch — use stored branch name if available, fall back to convention
const task = await this.store.getTask(taskId);
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
const branch = task.branch || canonicalFusionBranchName(taskId);
let branchDeleted = false;
try {
await execAsync(`git branch -D "${branch}"`, { cwd: this.rootDir });
@@ -9502,7 +9502,7 @@ Backward compat fallback: if JSON is unavailable, you may still begin output wit
);
if (completedSteps.length === 0) return;
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
try {
// Check if the branch has any unique commits vs main

View File

@@ -34,6 +34,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from
import { createHash } from "node:crypto";
import { join } from "node:path";
import { resolveTaskWorktreePath } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import { hostname } from "node:os";
import {
buildTaskLineageTrailer,
@@ -523,7 +524,7 @@ export async function classifyOwnedLandedEvidence(
task: Task,
opts: { mergeTargetBranch: string },
): Promise<OwnedLandedClassification> {
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
const branch = task.branch || canonicalFusionBranchName(task.id);
const mergeTargetBranch = opts.mergeTargetBranch;
const ownedCommit = await findOwnedLandedCommitForTask(rootDir, task);
@@ -2572,7 +2573,7 @@ async function tryRecoverHardFailApply(params: {
task,
taskId,
rootDir,
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
branch: task.branch || canonicalFusionBranchName(taskId),
conflictFiles: threeWayConflicted,
auditor: undefined,
});
@@ -3001,7 +3002,7 @@ async function restoreUnrelatedRootDirChanges(
task,
taskId,
rootDir,
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
branch: task.branch || canonicalFusionBranchName(taskId),
conflictFiles: conflictedFiles,
auditor: undefined,
});
@@ -6172,7 +6173,7 @@ export async function aiMergeTask(
});
return {
task,
branch: task.branch || `fusion/${taskId.toLowerCase()}`,
branch: task.branch || canonicalFusionBranchName(taskId),
merged: false,
noOp: true,
ok: true,
@@ -6186,7 +6187,7 @@ export async function aiMergeTask(
throw new Error(`Cannot merge ${taskId}: ${mergeBlocker}`);
}
const branch = task.branch || `fusion/${taskId.toLowerCase()}`;
const branch = task.branch || canonicalFusionBranchName(taskId);
const requestedBaseRef = task.mergeDetails?.mergeTargetBranch || "main";
const resolveAheadCount = async (): Promise<{ aheadCount: number; baseRef: string } | null> => {
try {

View File

@@ -17,7 +17,7 @@ import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { AgentSemaphore } from "./concurrency.js";
import { planTaskWorktreePath } from "./worktree-names.js";
import { canonicalFusionBranchName, planTaskWorktreePath } from "./worktree-names.js";
import { schedulerLog } from "./logger.js";
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
@@ -770,7 +770,7 @@ export class Scheduler {
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (dep && dep.column === "in-review" && dep.worktree) {
return dep.branch || `fusion/${dep.id.toLowerCase()}`;
return dep.branch || canonicalFusionBranchName(dep.id);
}
}
@@ -778,7 +778,7 @@ export class Scheduler {
if (task.blockedBy) {
const blocker = allTasks.find((t) => t.id === task.blockedBy);
if (blocker && blocker.column === "in-review" && blocker.worktree) {
return blocker.branch || `fusion/${blocker.id.toLowerCase()}`;
return blocker.branch || canonicalFusionBranchName(blocker.id);
}
}

View File

@@ -46,6 +46,7 @@ import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { activeSessionRegistry } from "./active-session-registry.js";
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
import { resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import type { OwnedLandedClassification } from "./merger.js";
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
import {
@@ -438,7 +439,7 @@ export async function isBranchAheadOfBase(
rootDir: string,
preferredBaseRef?: string,
): Promise<{ aheadCount: number; baseRef: string } | null> {
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
try {
await execAsync(`git rev-parse --verify ${shellQuote(branchName)}`, {
@@ -868,7 +869,7 @@ export class SelfHealingManager {
);
if (completedSteps.length === 0) return;
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
try {
const { stdout: mergeBaseOut } = await execAsync(
@@ -1111,7 +1112,7 @@ export class SelfHealingManager {
}
}
const branch = task.branch || `fusion/${task.id.toLowerCase()}`;
const branch = task.branch || canonicalFusionBranchName(task.id);
try {
await execAsync(`git branch -D ${shellQuote(branch)}`, {
cwd: this.options.rootDir,
@@ -2410,7 +2411,7 @@ export class SelfHealingManager {
}
}
const branchName = task?.branch || `fusion/${taskId.toLowerCase()}`;
const branchName = task?.branch || canonicalFusionBranchName(taskId);
const hintedWorktreePath = options?.worktreeHint;
let worktreePath = hintedWorktreePath;
if (!worktreePath || !existsSync(worktreePath)) {
@@ -2530,7 +2531,7 @@ export class SelfHealingManager {
phase: "in-review-branch-rebind",
});
await auditor.database({
type: input.mutationType,
type: input.mutationType as unknown as Parameters<typeof auditor.database>[0]["type"],
target: input.taskId,
metadata: input.metadata,
});
@@ -2574,7 +2575,7 @@ export class SelfHealingManager {
}
const normalizedId = task.id.toLowerCase();
const candidates = new Set<string>([`fusion/${normalizedId}`, `fusion/${task.id}`]);
const candidates = new Set<string>([canonicalFusionBranchName(task.id), `fusion/`.concat(task.id)]);
for (const branch of fusionBranches) {
const stem = branch.startsWith("fusion/") ? branch.slice("fusion/".length) : "";
if (stem.toLowerCase() === normalizedId) candidates.add(branch);
@@ -2617,7 +2618,7 @@ export class SelfHealingManager {
const aheadCount = Number.parseInt(aheadCountRaw.stdout.trim(), 10);
const normalizedBranchRef = branch.toLowerCase();
const existingCandidate = existingCandidatesByRef.get(normalizedBranchRef);
const normalizedCandidate = `fusion/${normalizedId}`;
const normalizedCandidate = canonicalFusionBranchName(task.id);
if (!existingCandidate || branch === normalizedCandidate) {
existingCandidatesByRef.set(normalizedBranchRef, {
branch,
@@ -2641,12 +2642,15 @@ export class SelfHealingManager {
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 };
const patch: Partial<Task> = { branch: selected.branch, worktree: null as unknown as string };
if (!task.baseCommitSha) {
patch.baseCommitSha = (await execAsync(
const derivedBaseCommit = (await execAsync(
`git merge-base ${shellQuote(integrationBase)} ${shellQuote(selected.branch)}`,
{ cwd: this.options.rootDir, timeout: 30_000 },
)).stdout.trim() || null;
)).stdout.trim();
if (derivedBaseCommit) {
patch.baseCommitSha = derivedBaseCommit;
}
}
// TODO(FN-5066): tighten composition once helper API is final.
try {
@@ -2728,7 +2732,7 @@ export class SelfHealingManager {
if (executingIds.has(task.id)) continue;
if (activeSessionRegistry.isPathActive(task.worktree)) continue;
const normalizedBranch = `fusion/${task.id.toLowerCase()}`;
const normalizedBranch = canonicalFusionBranchName(task.id);
const canonicalTaskWorktree = resolve(task.worktree);
const stale = !existsSync(task.worktree) || !registeredPaths.has(canonicalTaskWorktree);
if (!stale) continue;
@@ -5933,7 +5937,7 @@ export class SelfHealingManager {
}
}
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
try {
await execAsync(`git rev-parse --verify "${branchName}"`, {
cwd: this.options.rootDir,

View File

@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { RunMutationContext, Settings, Task, TaskStore, SecretsStore } from "@fusion/core";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { canonicalFusionBranchName, generateWorktreeName, slugify } from "./worktree-names.js";
import { resolveTaskWorktreePathForBackend } from "./worktree-paths.js";
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
import { formatError } from "./logger.js";
@@ -202,7 +202,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
}
throw error;
}
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const branchName = task.branch || canonicalFusionBranchName(task.id);
const naming = settings.worktreeNaming || "random";
const allowSiblingBranchRename = settings.executorAllowSiblingBranchRename === true;
const baseBranch = task.executionStartBranch || null;

View File

@@ -28,6 +28,7 @@ if [ ! -f "$TASK_FILE" ]; then
fi
WORKTREE_TASK_ID=$(cat "$TASK_FILE")
# Keep this canonicalized in lockstep with canonicalFusionBranchName(taskId)
EXPECTED_BRANCH="fusion/${taskId.toLowerCase()}"
if ! HEAD_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null); then

View File

@@ -29,6 +29,10 @@ export const NOUNS = [
"thorn", "tiger", "trail", "trout", "wren",
];
export function canonicalFusionBranchName(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
/**
* Convert a string to a URL-friendly slug.
*

View File

@@ -6,6 +6,7 @@ import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } fro
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import {
resolveWorktrunkBinary,
} from "./worktrunk-installer.js";
@@ -838,7 +839,7 @@ const MERGER_MANAGED_COLUMNS: ReadonlySet<Column> = new Set(["in-review", "done"
*
* Lists all local branches matching the `fusion/*` pattern, then compares
* against branches stored on tasks (via `task.branch` or derived as
* `fusion/${taskId.toLowerCase()}`). Branches belonging to tasks in the
* canonicalFusionBranchName(taskId)). Branches belonging to tasks in the
* `in-review` or `done` columns are excluded because the merger is
* responsible for cleaning those up.
*
@@ -881,7 +882,7 @@ export async function scanOrphanedBranches(rootDir: string, store: TaskStore): P
activeBranches.add(task.branch);
}
// Always add the derived name too — the task may not have `branch` set yet
activeBranches.add(`fusion/${task.id.toLowerCase()}`);
activeBranches.add(canonicalFusionBranchName(task.id));
}
// Return branches not associated with any active task