test(FN-5083): add case-insensitive task-done branch canonicalization

This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 16:55:35 -07:00
committed by gsxdsm
parent c56e0cf814
commit a2f4b99ecc
3 changed files with 119 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import * as worktreePool from "../worktree-pool.js";
import * as branchAutocorrect from "../branch-autocorrect.js";
import { createMockStore, mockedCreateFnAgent, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
function baseTask() {
return {
id: "FN-5083",
title: "Branch case check",
description: "",
column: "in-progress",
worktree: "/repo/.worktrees/amber-hawk",
branch: "fusion/fn-5083",
baseCommitSha: "abc123",
taskDoneRetryCount: 0,
steps: [{ name: "Step 1", status: "in-progress" as const }],
currentStep: 0,
dependencies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
async function setup() {
const store = createMockStore();
let task: any = baseTask();
let tool: any;
store.getTask.mockImplementation(async () => ({ ...task, steps: task.steps.map((s: any) => ({ ...s })) }));
store.moveTask.mockImplementation(async (id: string, column: string) => {
task = { ...task, id, column, paused: false, pausedByAgentId: null, status: null, error: null };
});
mockedCreateFnAgent.mockImplementation(async ({ customTools }: any) => {
tool = customTools.find((t: any) => t.name === "fn_task_done");
return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } } as any;
});
const executor = new TaskExecutor(store as any, "/repo");
await executor.execute(baseTask() as any);
return { tool, store };
}
describe("executor fn_task_done case-insensitive branch handling", () => {
beforeEach(() => {
resetExecutorMocks();
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
vi.spyOn(branchAutocorrect, "attemptBranchAutocorrect").mockResolvedValue({ status: "renamed" });
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/amber-hawk\n");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/fn-5083\n");
if (cmd.includes("rev-list --count")) return Buffer.from("1\n");
if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n");
return Buffer.from("");
});
});
it("allows exact branch matches", async () => {
const { tool } = await setup();
const result = await tool.execute("id", {});
expect(result.content[0].text).toContain("Task marked complete");
});
it("auto-canonicalizes case-only mismatches", async () => {
const { tool } = await setup();
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/amber-hawk\n");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("fusion/FN-5083\n");
if (cmd.includes("rev-list --count")) return Buffer.from("1\n");
if (cmd.includes("rev-parse HEAD")) return Buffer.from("def456\n");
return Buffer.from("");
});
const result = await tool.execute("id", {});
expect(result.content[0].text).toContain("Task marked complete");
expect(branchAutocorrect.attemptBranchAutocorrect).toHaveBeenCalled();
});
it("still refuses genuine wrong branches", async () => {
const { tool } = await setup();
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("rev-parse --show-toplevel")) return Buffer.from("/repo/.worktrees/amber-hawk\n");
if (cmd.includes("rev-parse --abbrev-ref HEAD")) return Buffer.from("main\n");
return Buffer.from("");
});
const result = await tool.execute("id", {});
expect(result.content[0].text).toContain("fn_task_done refused: wrong_branch");
});
});

View File

@@ -44,6 +44,7 @@ import type { SandboxBackend } from "./sandbox/types.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
import { RemovalReason, classifyTaskWorktree, describeRegisteredWorktrees, getRegisteredWorktreePaths, isGitRepository, isInsideWorktreesDir, isRegisteredGitWorktree, removeWorktree, type WorktreePool } from "./worktree-pool.js";
import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
import {
@@ -5358,6 +5359,31 @@ export class TaskExecutor {
});
const observedBranch = stdout.trim();
if (observedBranch && observedBranch !== branchName) {
if (observedBranch.toLowerCase() === branchName.toLowerCase()) {
executorLog.log(`${task.id}: branch case-mismatch detected; canonicalizing observed=${observedBranch} expected=${branchName}`);
const autocorrectResult = await attemptBranchAutocorrect({
worktreePath,
observedBranch,
expectedBranch: branchName,
rootDir: this.rootDir,
});
if (autocorrectResult.status !== "failed") {
const auditor = createRunAuditor(this.store, this.getRunContextFor(task.id));
await auditor.git({
type: "branch:auto-canonicalize-case",
target: worktreePath,
metadata: {
taskId: task.id,
observed: observedBranch,
expected: branchName,
worktreePath,
mode: autocorrectResult.status,
},
});
return { ok: true };
}
executorLog.warn(`${task.id}: failed to canonicalize branch case mismatch: ${autocorrectResult.reason ?? "unknown"}`);
}
return {
ok: false,
reason: "wrong_branch",

View File

@@ -157,6 +157,7 @@ export type GitMutationType =
| "merge:layer3:scope-override-bypass"
| "merge:audit-failure"
| "branch:auto-reclaim"
| "branch:auto-canonicalize-case"
| "branch:stale-active-reclaim"
| "branch:stale-active-reclaim-deferred"
| "branch:orphan-prune"