fix(server): suffix-fallback auto-named branches and rotate opencode waterfall

Auto-namer now probes refs/heads/<name> and appends -2, -3, … (up to 50)
when the desired slug is taken, instead of failing with "branch already
exists" and leaving the worktree on its placeholder name.

Structured-generation waterfall replaces opencode/gpt-5-nano with
opencode/minimax-m2.5-free and opencode/nemotron-3-super-free so users
without nano configured fall through to free models they actually have.
This commit is contained in:
Mohamed Boudra
2026-05-09 20:44:03 +07:00
parent 73f35537f9
commit d88af28e93
4 changed files with 83 additions and 4 deletions

View File

@@ -96,7 +96,8 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
{ provider: "opencode", model: "opencode/minimax-m2.5-free" },
{ provider: "opencode", model: "opencode/nemotron-3-super-free" },
] as const;
interface SchemaValidator<T> {

View File

@@ -173,6 +173,44 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
expect(branchAfterSecond).toBe("renamed-from-agent-context");
});
test("falls back to a numeric suffix when the desired branch name already exists", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
execFileSync("git", ["branch", "renamed-from-agent-context"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "renamed-from-agent-context-2"], { cwd: repoDir, stdio: "pipe" });
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
const result = await attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Build the agent context name" },
generateBranchNameFromContext: async () => "renamed-from-agent-context",
});
expect(result).toEqual({
attempted: true,
renamed: true,
branchName: "renamed-from-agent-context-3",
});
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("renamed-from-agent-context-3");
});
test("renames the branch even when the app supplies a random placeholder slug", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);

View File

@@ -13,7 +13,7 @@ import {
type CreateWorktreeCoreInput,
} from "./worktree-core.js";
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, renameCurrentBranch } from "../utils/checkout-git.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
readPaseoWorktreeMetadata,
@@ -85,6 +85,7 @@ export async function attemptFirstAgentBranchAutoName(options: {
}) => Promise<string | null>;
getCurrentBranch?: typeof getCurrentBranch;
renameCurrentBranch?: typeof renameCurrentBranch;
localBranchExists?: typeof localBranchExists;
}): Promise<AttemptFirstAgentBranchAutoNameResult> {
const firstAgentContext = options.firstAgentContext;
if (!firstAgentContext || !buildAgentBranchNameSeed(firstAgentContext)) {
@@ -129,15 +130,50 @@ export async function attemptFirstAgentBranchAutoName(options: {
return { attempted: true, renamed: false, branchName: null };
}
const localBranchExistsImpl = options.localBranchExists ?? localBranchExists;
const targetName = await findAvailableBranchName({
cwd: options.cwd,
desiredName: branchName,
placeholderBranchName,
localBranchExists: localBranchExistsImpl,
});
if (!targetName) {
return { attempted: true, renamed: false, branchName: null };
}
const renameCurrentBranchImpl = options.renameCurrentBranch ?? renameCurrentBranch;
const renamedBranch = await renameCurrentBranchImpl(options.cwd, branchName);
const renamedBranch = await renameCurrentBranchImpl(options.cwd, targetName);
return {
attempted: true,
renamed: true,
branchName: renamedBranch.currentBranch ?? branchName,
branchName: renamedBranch.currentBranch ?? targetName,
};
}
const MAX_BRANCH_NAME_SUFFIX_ATTEMPTS = 50;
async function findAvailableBranchName(options: {
cwd: string;
desiredName: string;
placeholderBranchName: string;
localBranchExists: (cwd: string, branchName: string) => Promise<boolean>;
}): Promise<string | null> {
const { cwd, desiredName, placeholderBranchName } = options;
if (!(await options.localBranchExists(cwd, desiredName))) {
return desiredName;
}
for (let suffix = 2; suffix <= MAX_BRANCH_NAME_SUFFIX_ATTEMPTS; suffix++) {
const candidate = `${desiredName}-${suffix}`;
if (candidate === placeholderBranchName) {
continue;
}
if (!(await options.localBranchExists(cwd, candidate))) {
return candidate;
}
}
return null;
}
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
}): void {

View File

@@ -870,6 +870,10 @@ async function getWorktreePathForBranch(cwd: string, branchName: string): Promis
}
}
export async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
return doesGitRefExist(cwd, `refs/heads/${branchName}`);
}
export async function renameCurrentBranch(
cwd: string,
newName: string,