authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 23:27:12-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log913d0409f6b38b7652435f581f1e38768e4cb27a
tree609d8c74414e07559a8be703fcfad3dd40c9529a
parentac0e720de19e452b75cd1a13cb576b0dec242721

Handle branch-name generation failures as typed errors

- Make branch-name generation return a required string instead of nullable output - Propagate invalid Codex branch payloads as `TextGenerationError` instead of silently falling back - Update provider command reactor to catch generation failures, log warnings, and skip rename safely

5 files changed, 114 insertions(+), 113 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.test.ts+17-6
......@@ -374,7 +374,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
374374 ),
375375 );
376376
377 it.effect("returns null branch when codex returns the wrong object shape", () =>
377 it.effect("fails with typed TextGenerationError when codex returns wrong branch payload shape", () =>
378378 withFakeCodexEnv(
379379 {
380380 output: JSON.stringify({
......@@ -384,12 +384,23 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
384384 Effect.gen(function* () {
385385 const textGeneration = yield* TextGeneration;
386386
387 const generated = yield* textGeneration.generateBranchName({
388 cwd: process.cwd(),
389 message: "Fix websocket reconnect flake",
390 });
387 const result = yield* textGeneration
388 .generateBranchName({
389 cwd: process.cwd(),
390 message: "Fix websocket reconnect flake",
391 })
392 .pipe(
393 Effect.match({
394 onFailure: (error) => ({ _tag: "Left" as const, left: error }),
395 onSuccess: (value) => ({ _tag: "Right" as const, right: value }),
396 }),
397 );
391398
392 expect(generated.branch).toBeNull();
399 expect(result._tag).toBe("Left");
400 if (result._tag === "Left") {
401 expect(result.left).toBeInstanceOf(TextGenerationError);
402 expect(result.left.message).toContain("Codex returned invalid structured output");
403 }
393404 }),
394405 ),
395406 );
apps/server/src/git/Layers/CodexTextGeneration.ts+2-12
......@@ -552,20 +552,10 @@ const makeCodexTextGeneration = Effect.gen(function* () {
552552 outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
553553 imagePaths,
554554 parse: (raw) => parseBranchNameOutput(raw),
555 }).pipe(
556 Effect.catch((error) =>
557 Effect.gen(function* () {
558 yield* Effect.logWarning("branch-name generation failed, skipping rename", {
559 operation: "generateBranchName",
560 reason: error instanceof Error ? error.message : String(error),
561 });
562 return { branch: null };
563 }),
564 ),
565 );
555 });
566556
567557 return {
568 branch: generated.branch ? sanitizeBranchName(generated.branch) : null,
558 branch: sanitizeBranchName(generated.branch),
569559 } satisfies BranchNameGenerationResult;
570560 });
571561 };
apps/server/src/git/Services/TextGeneration.ts+1-1
......@@ -45,7 +45,7 @@ export interface BranchNameGenerationInput {
4545}
4646
4747export interface BranchNameGenerationResult {
48 branch: string | null;
48 branch: string;
4949}
5050
5151export interface TextGenerationService {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+10-6
......@@ -18,6 +18,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe
1818import { afterEach, describe, expect, it, vi } from "vitest";
1919
2020import { type ServerConfigShape, ServerConfig } from "../../config.ts";
21import { TextGenerationError } from "../../git/Errors.ts";
2122import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2223import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
2324import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -123,7 +124,7 @@ describe("ProviderCommandReactor", () => {
123124 );
124125 const generateBranchName = vi.fn((_: unknown) =>
125126 Effect.succeed({
126 branch: "generated-name" as string | null,
127 branch: "generated-name",
127128 }),
128129 );
129130
......@@ -447,13 +448,16 @@ describe("ProviderCommandReactor", () => {
447448 expect(harness.renameBranch.mock.calls.length).toBe(1);
448449 });
449450
450 it("skips worktree rename when generated branch is null", async () => {
451 it("skips worktree rename when branch-name generation fails", async () => {
451452 const harness = await createHarness();
452453 const now = new Date().toISOString();
453 harness.generateBranchName.mockImplementationOnce((_: unknown) =>
454 Effect.succeed({
455 branch: null,
456 }),
454 harness.generateBranchName.mockImplementationOnce(() =>
455 Effect.fail(
456 new TextGenerationError({
457 operation: "generateBranchName",
458 detail: "model returned invalid payload",
459 }),
460 ),
457461 );
458462
459463 await Effect.runPromise(
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+84-88
......@@ -17,7 +17,10 @@ import {
1717import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1818
1919import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
20import { resolveAttachmentRelativePath, resolveAttachmentRoutePath } from "../../attachmentPaths.ts";
20import {
21 resolveAttachmentRelativePath,
22 resolveAttachmentRoutePath,
23} from "../../attachmentPaths.ts";
2124import { ServerConfig } from "../../config.ts";
2225import { GitCore } from "../../git/Services/GitCore.ts";
2326import { TextGeneration } from "../../git/Services/TextGeneration.ts";
......@@ -229,7 +232,8 @@ const make = Effect.gen(function* () {
229232 const existingSessionId = thread.session?.providerSessionId;
230233 if (existingSessionId) {
231234 const approvalPolicyChanged =
232 options?.approvalPolicy !== undefined && options.approvalPolicy !== thread.session?.approvalPolicy;
235 options?.approvalPolicy !== undefined &&
236 options.approvalPolicy !== thread.session?.approvalPolicy;
233237 const sandboxModeChanged =
234238 options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode;
235239
......@@ -237,7 +241,9 @@ const make = Effect.gen(function* () {
237241 return existingSessionId;
238242 }
239243
240 const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null);
244 const restartedSession = yield* startProviderSession(
245 thread.session?.providerThreadId ?? null,
246 );
241247 yield* bindSessionToThread(restartedSession);
242248 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
243249 Effect.catchCause((cause) =>
......@@ -286,13 +292,11 @@ const make = Effect.gen(function* () {
286292 });
287293 });
288294
289 const resolvePersistedAttachments = Effect.fnUntraced(function* (
290 input: {
291 readonly threadId: ThreadId;
292 readonly messageId: string;
293 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
294 },
295 ) {
295 const resolvePersistedAttachments = Effect.fnUntraced(function* (input: {
296 readonly threadId: ThreadId;
297 readonly messageId: string;
298 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
299 }) {
296300 if (!input.attachments || input.attachments.length === 0) {
297301 return input.attachments ?? [];
298302 }
......@@ -303,54 +307,52 @@ const make = Effect.gen(function* () {
303307 const threadSegment = encodeURIComponent(input.threadId);
304308 const messageSegment = encodeURIComponent(input.messageId);
305309
306 return yield* Effect.forEach(
307 Array.from(input.attachments.entries()),
308 ([index, attachment]) =>
309 Effect.gen(function* () {
310 if (attachment.type !== "image") {
311 return attachment;
312 }
313
314 const resolvedRoutePath = resolveAttachmentRoutePath({
315 stateDir: serverConfig.value.stateDir,
316 dataUrl: attachment.dataUrl,
317 });
318 const resolvedMaterializedPath =
319 resolvedRoutePath ??
320 (() => {
321 const parsed = parseBase64DataUrl(attachment.dataUrl);
322 if (!parsed || !parsed.mimeType.startsWith("image/")) {
323 return null;
324 }
325 const extension = inferImageExtension({
326 mimeType: parsed.mimeType,
327 fileName: attachment.name,
328 });
329 return resolveAttachmentRelativePath({
330 stateDir: serverConfig.value.stateDir,
331 relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`,
332 });
333 })();
334
335 if (!resolvedMaterializedPath) {
336 return attachment;
337 }
310 return yield* Effect.forEach(Array.from(input.attachments.entries()), ([index, attachment]) =>
311 Effect.gen(function* () {
312 if (attachment.type !== "image") {
313 return attachment;
314 }
338315
339 const isFile = yield* Effect.sync(() => {
340 try {
341 return fs.statSync(resolvedMaterializedPath).isFile();
342 } catch {
343 return false;
316 const resolvedRoutePath = resolveAttachmentRoutePath({
317 stateDir: serverConfig.value.stateDir,
318 dataUrl: attachment.dataUrl,
319 });
320 const resolvedMaterializedPath =
321 resolvedRoutePath ??
322 (() => {
323 const parsed = parseBase64DataUrl(attachment.dataUrl);
324 if (!parsed || !parsed.mimeType.startsWith("image/")) {
325 return null;
344326 }
345 });
346 if (!isFile) {
347 return attachment;
327 const extension = inferImageExtension({
328 mimeType: parsed.mimeType,
329 fileName: attachment.name,
330 });
331 return resolveAttachmentRelativePath({
332 stateDir: serverConfig.value.stateDir,
333 relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`,
334 });
335 })();
336
337 if (!resolvedMaterializedPath) {
338 return attachment;
339 }
340
341 const isFile = yield* Effect.sync(() => {
342 try {
343 return fs.statSync(resolvedMaterializedPath).isFile();
344 } catch {
345 return false;
348346 }
349 return {
350 ...attachment,
351 dataUrl: resolvedMaterializedPath,
352 } satisfies ChatAttachment;
353 }),
347 });
348 if (!isFile) {
349 return attachment;
350 }
351 return {
352 ...attachment,
353 dataUrl: resolvedMaterializedPath,
354 } satisfies ChatAttachment;
355 }),
354356 );
355357 });
356358
......@@ -382,51 +384,45 @@ const make = Effect.gen(function* () {
382384 const oldBranch = input.branch;
383385 const cwd = input.worktreePath;
384386 const attachments = input.attachments ?? [];
385 const renameEffect = textGeneration
387 yield* textGeneration
386388 .generateBranchName({
387389 cwd,
388390 message: input.messageText,
389391 ...(attachments.length > 0 ? { attachments } : {}),
390392 })
391393 .pipe(
394 Effect.catch((error) =>
395 Effect.logWarning(
396 "provider command reactor failed to generate worktree branch name; skipping rename",
397 { threadId: input.threadId, cwd, oldBranch, reason: error.message },
398 ),
399 ),
392400 Effect.flatMap((generated) => {
393 if (!generated.branch) {
394 return Effect.void;
395 }
401 if (!generated) return Effect.void;
402
396403 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
397 if (targetBranch === oldBranch) {
398 return Effect.void;
399 }
400 return git
401 .renameBranch({
402 cwd,
403 oldBranch,
404 newBranch: targetBranch,
405 })
406 .pipe(
407 Effect.flatMap((renamed) =>
408 orchestrationEngine.dispatch({
409 type: "thread.meta.update",
410 commandId: serverCommandId("worktree-branch-rename"),
411 threadId: input.threadId,
412 branch: renamed.branch,
413 worktreePath: cwd,
414 }),
415 ),
416 Effect.asVoid,
417 );
404 if (targetBranch === oldBranch) return Effect.void;
405
406 return Effect.flatMap(
407 git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }),
408 (renamed) =>
409 orchestrationEngine.dispatch({
410 type: "thread.meta.update",
411 commandId: serverCommandId("worktree-branch-rename"),
412 threadId: input.threadId,
413 branch: renamed.branch,
414 worktreePath: cwd,
415 }),
416 );
418417 }),
419418 Effect.catchCause((cause) =>
420 Effect.logWarning("provider command reactor failed to generate or rename worktree branch", {
421 threadId: input.threadId,
422 cwd,
423 oldBranch,
424 cause: Cause.pretty(cause),
425 }),
419 Effect.logWarning(
420 "provider command reactor failed to generate or rename worktree branch",
421 { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) },
422 ),
426423 ),
424 Effect.forkScoped,
427425 );
428
429 yield* Effect.forkScoped(renameEffect);
430426 });
431427
432428 const processTurnStartRequested = Effect.fnUntraced(function* (