Skip to content

Commit 066375a

Browse files
centdixclaude
andauthored
fix: add auto-name timeout fallback (#205)
* fix: add auto-name timeout fallback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: tighten auto-name timeout fallback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e731f73 commit 066375a

4 files changed

Lines changed: 119 additions & 13 deletions

File tree

backend/src/__tests__/auto-name-service.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, expect, it } from "bun:test";
2+
import { chmod, mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25
import { AutoNameService } from "../services/auto-name-service";
36

47
async function getClaudeCliFlags(): Promise<Set<string>> {
@@ -103,6 +106,24 @@ describe("AutoNameService", () => {
103106
expect(calls[0]).toContain("gpt-4.1");
104107
});
105108

109+
it("passes the configured timeout to the spawn implementation", async () => {
110+
const timeouts: Array<number | undefined> = [];
111+
const service = new AutoNameService({
112+
timeoutMs: 1234,
113+
spawnImpl: async (_args, options) => {
114+
timeouts.push(options?.timeoutMs);
115+
return { exitCode: 0, stdout: "test-branch", stderr: "" };
116+
},
117+
});
118+
119+
await service.generateBranchName(
120+
{ provider: "claude" },
121+
"Test timeout wiring",
122+
);
123+
124+
expect(timeouts).toEqual([1234]);
125+
});
126+
106127
it("omits -m from codex when model is not specified", async () => {
107128
const { calls, spawnImpl } = fakeSpawn("add-bulk-actions");
108129
const service = new AutoNameService({ spawnImpl });
@@ -156,6 +177,42 @@ describe("AutoNameService", () => {
156177
).rejects.toThrow(/codex failed \(command: .*\): authentication required/);
157178
});
158179

180+
it("returns a change-prefixed fallback branch when the real spawn path times out", async () => {
181+
const tempDir = await mkdtemp(join(tmpdir(), "webmux-auto-name-"));
182+
const claudePath = join(tempDir, "claude");
183+
await Bun.write(
184+
claudePath,
185+
"#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 1; done\n",
186+
);
187+
await chmod(claudePath, 0o755);
188+
189+
const originalPath = Bun.env.PATH;
190+
const timeoutMs = 50;
191+
const deadlineMs = 1_000;
192+
Bun.env.PATH = originalPath ? `${tempDir}:${originalPath}` : tempDir;
193+
process.env.PATH = Bun.env.PATH;
194+
195+
try {
196+
const startedAt = Date.now();
197+
const branch = await Promise.race([
198+
new AutoNameService({ timeoutMs }).generateBranchName(
199+
{ provider: "claude" },
200+
"Fix bug",
201+
),
202+
Bun.sleep(deadlineMs).then(() => {
203+
throw new Error("timed out waiting for auto-name timeout fallback");
204+
}),
205+
]);
206+
207+
expect(Date.now() - startedAt).toBeLessThan(deadlineMs);
208+
expect(branch).toMatch(/^change-[a-f0-9]{8}$/);
209+
} finally {
210+
Bun.env.PATH = originalPath;
211+
process.env.PATH = originalPath;
212+
await rm(tempDir, { recursive: true, force: true });
213+
}
214+
});
215+
159216
it("throws on empty output", async () => {
160217
const { spawnImpl } = fakeSpawn("");
161218
const service = new AutoNameService({ spawnImpl });

backend/src/lib/branch-name.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { randomUUID } from "node:crypto";
2+
3+
export function generateFallbackBranchName(): string {
4+
return `change-${randomUUID().slice(0, 8)}`;
5+
}

backend/src/services/auto-name-service.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import type { AutoNameConfig } from "../domain/config";
22
import { isValidBranchName } from "../domain/policies";
3+
import { generateFallbackBranchName } from "../lib/branch-name";
4+
import { log } from "../lib/log";
35

46
interface SpawnResult {
57
exitCode: number;
68
stdout: string;
79
stderr: string;
810
}
911

10-
type SpawnLike = (args: string[]) => Promise<SpawnResult>;
12+
interface SpawnOptions {
13+
timeoutMs?: number;
14+
}
15+
16+
type SpawnLike = (args: string[], options?: SpawnOptions) => Promise<SpawnResult>;
1117

1218
const MAX_BRANCH_LENGTH = 40;
1319
const DEFAULT_AUTO_NAME_MODEL = "claude-haiku-4-5-20251001";
20+
const AUTO_NAME_TIMEOUT_MS = 10_000;
1421

1522
const DEFAULT_SYSTEM_PROMPT = [
1623
"Generate a concise git branch name from the task description.",
@@ -46,17 +53,50 @@ function getSystemPrompt(config: AutoNameConfig): string {
4653
return config.systemPrompt?.trim() || DEFAULT_SYSTEM_PROMPT;
4754
}
4855

49-
async function defaultSpawn(args: string[]): Promise<SpawnResult> {
56+
class AutoNameTimeoutError extends Error {
57+
constructor(readonly timeoutMs: number) {
58+
super(`Auto-name timed out after ${timeoutMs}ms`);
59+
}
60+
}
61+
62+
async function defaultSpawn(args: string[], options: SpawnOptions = {}): Promise<SpawnResult> {
5063
const proc = Bun.spawn(args, {
5164
stdout: "pipe",
5265
stderr: "pipe",
5366
});
54-
const [stdout, stderr, exitCode] = await Promise.all([
67+
const resultPromise = Promise.all([
5568
new Response(proc.stdout).text(),
5669
new Response(proc.stderr).text(),
5770
proc.exited,
58-
]);
59-
return { exitCode, stdout, stderr };
71+
]).then(([stdout, stderr, exitCode]) => ({ exitCode, stdout, stderr }));
72+
73+
if (options.timeoutMs === undefined) {
74+
return await resultPromise;
75+
}
76+
77+
return await new Promise<SpawnResult>((resolve, reject) => {
78+
let settled = false;
79+
const timeoutId = setTimeout(() => {
80+
if (settled) return;
81+
settled = true;
82+
try {
83+
proc.kill("SIGKILL");
84+
} catch {}
85+
reject(new AutoNameTimeoutError(options.timeoutMs!));
86+
}, options.timeoutMs);
87+
88+
void resultPromise.then((result) => {
89+
if (settled) return;
90+
settled = true;
91+
clearTimeout(timeoutId);
92+
resolve(result);
93+
}, (error) => {
94+
if (settled) return;
95+
settled = true;
96+
clearTimeout(timeoutId);
97+
reject(error);
98+
});
99+
});
60100
}
61101

62102
function buildClaudeArgs(model: string | undefined, systemPrompt: string, prompt: string): string[] {
@@ -97,6 +137,7 @@ function buildCodexArgs(model: string | undefined, systemPrompt: string, prompt:
97137

98138
export interface AutoNameServiceDependencies {
99139
spawnImpl?: SpawnLike;
140+
timeoutMs?: number;
100141
}
101142

102143
export interface AutoNameGenerator {
@@ -105,9 +146,11 @@ export interface AutoNameGenerator {
105146

106147
export class AutoNameService implements AutoNameGenerator {
107148
private readonly spawnImpl: SpawnLike;
149+
private readonly timeoutMs: number;
108150

109151
constructor(deps: AutoNameServiceDependencies = {}) {
110152
this.spawnImpl = deps.spawnImpl ?? defaultSpawn;
153+
this.timeoutMs = deps.timeoutMs ?? AUTO_NAME_TIMEOUT_MS;
111154
}
112155

113156
async generateBranchName(config: AutoNameConfig, task: string): Promise<string> {
@@ -127,8 +170,13 @@ export class AutoNameService implements AutoNameGenerator {
127170

128171
let result: SpawnResult;
129172
try {
130-
result = await this.spawnImpl(args);
131-
} catch {
173+
result = await this.spawnImpl(args, { timeoutMs: this.timeoutMs });
174+
} catch (error) {
175+
if (error instanceof AutoNameTimeoutError) {
176+
const fallback = generateFallbackBranchName();
177+
log.warn(`[auto-name] ${cli} timed out after ${this.timeoutMs}ms; using fallback branch ${fallback}`);
178+
return fallback;
179+
}
132180
throw new Error(`'${cli}' CLI not found. Install it or check your PATH.`);
133181
}
134182

backend/src/services/lifecycle-service.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { randomUUID } from "node:crypto";
21
import { mkdir } from "node:fs/promises";
32
import { dirname, join, resolve } from "node:path";
43
import { ensureAgentRuntimeArtifacts } from "../adapters/agent-runtime";
@@ -37,10 +36,7 @@ import {
3736
type InitializeManagedWorktreeResult,
3837
} from "./worktree-service";
3938
import { log } from "../lib/log";
40-
41-
function generateBranchName(): string {
42-
return `change-${randomUUID().slice(0, 8)}`;
43-
}
39+
import { generateFallbackBranchName } from "../lib/branch-name";
4440

4541
function toErrorMessage(error: unknown): string {
4642
return error instanceof Error ? error.message : String(error);
@@ -335,7 +331,7 @@ export class LifecycleService {
335331
const explicitBranch = rawBranch?.trim();
336332
const branch = mode === "existing"
337333
? explicitBranch
338-
: explicitBranch || await this.generateAutoName(prompt) || generateBranchName();
334+
: explicitBranch || await this.generateAutoName(prompt) || generateFallbackBranchName();
339335
if (!branch) {
340336
throw new LifecycleError("Existing branch is required", 400);
341337
}

0 commit comments

Comments
 (0)