History

Handle null-exit and timeout failures in Codex and Git flows

- squash Effect causes to surface real Codex process error messages - normalize Git process results to error on signal/null exit and keep details - treat timed-out Codex health probes/auth checks as unavailable or warning with clear messages - add tests for cause extraction, Git signal termination, and provider timeout paths

Julius Marminge committed 8 months ago

Commit df5009c1 parent 454d97f7 files · +141 −37Browse files

Unified · Split

7 files
  1. apps/server/src/codexAppServerManager.test.ts +8 −0
  2. apps/server/src/codexAppServerManager.ts +7 −3
  3. apps/server/src/git/Layers/GitService.test.ts +28 −1
  4. apps/server/src/git/Layers/GitService.ts +54 −28
  5. apps/server/src/processRunner.ts +1 −1
  6. apps/server/src/provider/Layers/ProviderHealth.test.ts +32 −1
  7. apps/server/src/provider/Layers/ProviderHealth.ts +11 −3
apps/server/src/codexAppServerManager.test.ts+8−0
@@ -1,4 +1,5 @@
11 import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { Cause } from "effect";
23 import { ManagedRuntime, ServiceMap } from "effect";
34 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45 import { ProviderSessionId } from "@t3tools/contracts";
@@ -7,6 +8,7 @@ import { · 2 unmodified lines
78 CodexAppServerManager,
89 classifyCodexStderrLine,
910 isRecoverableThreadResumeError,
11+ messageFromCodexProcessCause,
1012 normalizeCodexModelSlug,
1113 } from "./codexAppServerManager";
1214
@@ -171,6 +173,12 @@ describe("isRecoverableThreadResumeError", () => { · 158 unmodified lines
171173 });
172174 });
173175
176+describe("messageFromCodexProcessCause", () => {
177+ it("extracts the underlying error message from an effect cause", () => {
178+ expect(messageFromCodexProcessCause(Cause.fail(new Error("boom")))).toBe("boom");
179+ });
180+});
181+
174182 describe("startSession", () => {
175183 it("emits session/startFailed when resolving cwd throws before process launch", async () => {
176184 const manager = createManager();
apps/server/src/codexAppServerManager.ts+7−3
@@ -17,7 +17,7 @@ import { · 16 unmodified lines
1717 type ProviderTurnStartResult,
1818 } from "@t3tools/contracts";
1919 import type * as NodeServices from "@effect/platform-node/NodeServices";
20−import { Effect, Exit, ServiceMap, Scope, Stream } from "effect";
20+import { Cause, Effect, Exit, ServiceMap, Scope, Stream } from "effect";
2121 import type { ChildProcessSpawner } from "effect/unstable/process";
2222 import { makeRuntimeCommand, spawnManagedCommand } from "./processRunner";
2323
@@ -153,6 +153,11 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { · 129 unmodified lines
153153 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
154154 }
155155
156+export function messageFromCodexProcessCause(cause: Cause.Cause<unknown>): string {
157+ const squashed = Cause.squash(cause);
158+ return squashed instanceof Error ? squashed.message : "codex app-server process errored.";
159+}
160+
156161 export interface CodexAppServerManagerEvents {
157162 event: [event: ProviderEvent];
158163 }
@@ -566,8 +571,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 407 unmodified lines
566571 return;
567572 }
568573
569− const message =
570− cause instanceof Error ? cause.message : "codex app-server process errored.";
574+ const message = messageFromCodexProcessCause(cause);
571575 this.updateSession(context, {
572576 status: "error",
573577 activeTurnId: undefined,
apps/server/src/git/Layers/GitService.test.ts+28−1
@@ -3,7 +3,7 @@ import { it, assert } from "@effect/vitest"; · 2 unmodified lines
33 import { Effect, Layer, Schema } from "effect";
44
55 import { GitCommandError } from "../Errors.ts";
6−import { GitServiceLive } from "./GitService.ts";
6+import { GitServiceLive, normalizeGitProcessResult } from "./GitService.ts";
77 import { GitService } from "../Services/GitService.ts";
88
99 const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
@@ -56,4 +56,31 @@ layer("GitServiceLive", (it) => { · 46 unmodified lines
5656 }
5757 }),
5858 );
59+
60+ it("normalizeGitProcessResult fails when git exits without a code", () => {
61+ try {
62+ void normalizeGitProcessResult(
63+ {
64+ operation: "GitProcess.test.signal",
65+ cwd: process.cwd(),
66+ args: ["status"],
67+ },
68+ {},
69+ {
70+ code: null,
71+ signal: "SIGTERM",
72+ stdout: "",
73+ stderr: "",
74+ timedOut: false,
75+ },
76+ );
77+ assert.fail("Expected normalizeGitProcessResult to throw.");
78+ } catch (error) {
79+ assert.equal(Schema.is(GitCommandError)(error), true);
80+ if (!Schema.is(GitCommandError)(error)) {
81+ return;
82+ }
83+ assert.equal(error.detail, "git status terminated by signal SIGTERM.");
84+ }
85+ });
5986 });
apps/server/src/git/Layers/GitService.ts+54−28
@@ -39,6 +39,59 @@ function toGitCommandError( · 38 unmodified lines
3939 });
4040 }
4141
42+export function normalizeGitProcessResult(
43+ commandInput: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
44+ input: Pick<ExecuteGitInput, "allowNonZeroExit">,
45+ result: {
46+ readonly stdout: string;
47+ readonly stderr: string;
48+ readonly code: number | null;
49+ readonly signal: NodeJS.Signals | null;
50+ readonly timedOut: boolean;
51+ },
52+): ExecuteGitResult {
53+ if (result.timedOut) {
54+ throw new GitCommandError({
55+ operation: commandInput.operation,
56+ command: quoteGitCommand(commandInput.args),
57+ cwd: commandInput.cwd,
58+ detail: `${quoteGitCommand(commandInput.args)} timed out.`,
59+ });
60+ }
61+
62+ if (result.code === null) {
63+ throw new GitCommandError({
64+ operation: commandInput.operation,
65+ command: quoteGitCommand(commandInput.args),
66+ cwd: commandInput.cwd,
67+ detail:
68+ result.signal !== null
69+ ? `${quoteGitCommand(commandInput.args)} terminated by signal ${result.signal}.`
70+ : `${quoteGitCommand(commandInput.args)} terminated before reporting an exit code.`,
71+ });
72+ }
73+
74+ const exitCode = result.code;
75+ if (!input.allowNonZeroExit && exitCode !== 0) {
76+ const trimmedStderr = result.stderr.trim();
77+ throw new GitCommandError({
78+ operation: commandInput.operation,
79+ command: quoteGitCommand(commandInput.args),
80+ cwd: commandInput.cwd,
81+ detail:
82+ trimmedStderr.length > 0
83+ ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
84+ : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
85+ });
86+ }
87+
88+ return {
89+ code: exitCode,
90+ stdout: result.stdout,
91+ stderr: result.stderr,
92+ } satisfies ExecuteGitResult;
93+}
94+
4295 const makeGitService = Effect.sync(() => {
4396 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
4497 const commandInput = {
@@ -59,34 +112,7 @@ const makeGitService = Effect.sync(() => { · 14 unmodified lines
59112 outputMode: "error",
60113 });
61114
62− if (result.timedOut) {
63− throw new GitCommandError({
64− operation: commandInput.operation,
65− command: quoteGitCommand(commandInput.args),
66− cwd: commandInput.cwd,
67− detail: `${quoteGitCommand(commandInput.args)} timed out.`,
68− });
69− }
70−
71− const exitCode = result.code ?? 0;
72− if (!input.allowNonZeroExit && exitCode !== 0) {
73− const trimmedStderr = result.stderr.trim();
74− throw new GitCommandError({
75− operation: commandInput.operation,
76− command: quoteGitCommand(commandInput.args),
77− cwd: commandInput.cwd,
78− detail:
79− trimmedStderr.length > 0
80− ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
81− : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
82− });
83− }
84−
85− return {
86− code: exitCode,
87− stdout: result.stdout,
88− stderr: result.stderr,
89− } satisfies ExecuteGitResult;
115+ return normalizeGitProcessResult(commandInput, input, result);
90116 },
91117 catch: toGitCommandError(commandInput, "failed to run."),
92118 });
apps/server/src/processRunner.ts+1−1
@@ -127,7 +127,7 @@ export const spawnManagedCommand = (command: ChildProcess.Command) => · 126 unmodified lines
127127 } satisfies ManagedChildProcess;
128128 });
129129
130−export function spawnProcess(
130+function spawnProcess(
131131 command: string,
132132 args: readonly string[],
133133 options: ProcessSpawnOptions = {},
apps/server/src/provider/Layers/ProviderHealth.test.ts+32−1
@@ -7,7 +7,12 @@ import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderH · 6 unmodified lines
77 // ── Test helpers ────────────────────────────────────────────────────
88
99 function mockRunner(
10− handler: (args: ReadonlyArray<string>) => { stdout: string; stderr: string; code: number },
10+ handler: (args: ReadonlyArray<string>) => {
11+ stdout: string;
12+ stderr: string;
13+ code: number | null;
14+ timedOut?: boolean;
15+ },
1116 ) {
1217 return (args: ReadonlyArray<string>) => Promise.resolve(handler(args));
1318 }
@@ -116,6 +121,32 @@ it.effect("returns warning when login status command is unsupported", () => · 102 unmodified lines
116121 }),
117122 );
118123
124+it.effect("returns unavailable when codex version probe times out", () =>
125+ Effect.gen(function* () {
126+ const status = yield* checkCodexProviderStatus(
127+ mockRunner((args) => {
128+ const joined = args.join(" ");
129+ if (joined === "--version") {
130+ return { stdout: "", stderr: "", code: null, timedOut: true };
131+ }
132+ throw new Error(`Unexpected args: ${joined}`);
133+ }),
134+ );
135+ assert.strictEqual(status.provider, "codex");
136+ assert.strictEqual(status.status, "error");
137+ assert.strictEqual(status.available, false);
138+ assert.strictEqual(status.authStatus, "unknown");
139+ assert.strictEqual(status.message, "Codex CLI is installed but failed to run. Timed out while running command.");
140+ }),
141+);
142+
143+it("parseAuthStatusFromOutput: timed out auth check is warning", () => {
144+ const parsed = parseAuthStatusFromOutput({ stdout: "", stderr: "", code: null, timedOut: true });
145+ assert.strictEqual(parsed.status, "warning");
146+ assert.strictEqual(parsed.authStatus, "unknown");
147+ assert.strictEqual(parsed.message, "Timed out while checking Codex authentication status.");
148+});
149+
119150 // ── Pure function tests ─────────────────────────────────────────────
120151
121152 it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => {
apps/server/src/provider/Layers/ProviderHealth.ts+11−3
@@ -31,7 +31,7 @@ class ProviderHealthCommandError extends Data.TaggedError("ProviderHealthCommand · 30 unmodified lines
3131 export interface CommandResult {
3232 readonly stdout: string;
3333 readonly stderr: string;
34− readonly code: number;
34+ readonly code: number | null;
3535 readonly timedOut?: boolean;
3636 }
3737
@@ -109,6 +109,14 @@ export function parseAuthStatusFromOutput(result: CommandResult): { · 71 unmodified lines
109109 readonly authStatus: ServerProviderAuthStatus;
110110 readonly message?: string;
111111 } {
112+ if (result.timedOut) {
113+ return {
114+ status: "warning",
115+ authStatus: "unknown",
116+ message: "Timed out while checking Codex authentication status.",
117+ };
118+ }
119+
112120 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
113121
114122 if (
@@ -199,7 +207,7 @@ const defaultRunCodexCommand: RunProviderHealthCommand = async (args) => { · 84 unmodified lines
199207 return {
200208 stdout: result.stdout,
201209 stderr: result.stderr,
202− code: result.code ?? 0,
210+ code: result.code,
203211 timedOut: result.timedOut,
204212 } satisfies CommandResult;
205213 };
@@ -237,7 +245,7 @@ export const checkCodexProviderStatus = ( · 31 unmodified lines
237245 }
238246
239247 const version = versionProbe.success;
240− if (version.code !== 0) {
248+ if (version.timedOut || version.code !== 0) {
241249 const detail = detailFromResult(version);
242250 return {
243251 provider: CODEX_PROVIDER,