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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { Cause } from "effect";
2import { ManagedRuntime, ServiceMap } from "effect";3import { ManagedRuntime, ServiceMap } from "effect";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4import { ProviderSessionId } from "@t3tools/contracts";5import { ProviderSessionId } from "@t3tools/contracts";
@@ -7,6 +8,7 @@ import { · 2 unmodified lines
7 CodexAppServerManager,8 CodexAppServerManager,
8 classifyCodexStderrLine,9 classifyCodexStderrLine,
9 isRecoverableThreadResumeError,10 isRecoverableThreadResumeError,
11 messageFromCodexProcessCause,
10 normalizeCodexModelSlug,12 normalizeCodexModelSlug,
11} from "./codexAppServerManager";13} from "./codexAppServerManager";
12 14
@@ -171,6 +173,12 @@ describe("isRecoverableThreadResumeError", () => { · 158 unmodified lines
171 });173 });
172});174});
173 175
176describe("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
174describe("startSession", () => {182describe("startSession", () => {
175 it("emits session/startFailed when resolving cwd throws before process launch", async () => {183 it("emits session/startFailed when resolving cwd throws before process launch", async () => {
176 const manager = createManager();184 const manager = createManager();
apps/server/src/codexAppServerManager.ts+7−3
@@ -17,7 +17,7 @@ import { · 16 unmodified lines
17 type ProviderTurnStartResult,17 type ProviderTurnStartResult,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import type * as NodeServices from "@effect/platform-node/NodeServices";19import type * as NodeServices from "@effect/platform-node/NodeServices";
20import { Effect, Exit, ServiceMap, Scope, Stream } from "effect";20import { Cause, Effect, Exit, ServiceMap, Scope, Stream } from "effect";
21import type { ChildProcessSpawner } from "effect/unstable/process";21import type { ChildProcessSpawner } from "effect/unstable/process";
22import { makeRuntimeCommand, spawnManagedCommand } from "./processRunner";22import { makeRuntimeCommand, spawnManagedCommand } from "./processRunner";
23 23
@@ -153,6 +153,11 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { · 129 unmodified lines
153 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));153 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
154}154}
155 155
156export 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
156export interface CodexAppServerManagerEvents {161export interface CodexAppServerManagerEvents {
157 event: [event: ProviderEvent];162 event: [event: ProviderEvent];
158}163}
@@ -566,8 +571,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 407 unmodified lines
566 return;571 return;
567 }572 }
568 573
569 const message =574 const message = messageFromCodexProcessCause(cause);
570 cause instanceof Error ? cause.message : "codex app-server process errored.";
571 this.updateSession(context, {575 this.updateSession(context, {
572 status: "error",576 status: "error",
573 activeTurnId: undefined,577 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
3import { Effect, Layer, Schema } from "effect";3import { Effect, Layer, Schema } from "effect";
4 4
5import { GitCommandError } from "../Errors.ts";5import { GitCommandError } from "../Errors.ts";
6import { GitServiceLive } from "./GitService.ts";6import { GitServiceLive, normalizeGitProcessResult } from "./GitService.ts";
7import { GitService } from "../Services/GitService.ts";7import { GitService } from "../Services/GitService.ts";
8 8
9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));9const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer));
@@ -56,4 +56,31 @@ layer("GitServiceLive", (it) => { · 46 unmodified lines
56 }56 }
57 }),57 }),
58 );58 );
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 });
59});86});
apps/server/src/git/Layers/GitService.ts+54−28
@@ -39,6 +39,59 @@ function toGitCommandError( · 38 unmodified lines
39 });39 });
40}40}
41 41
42export 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
42const makeGitService = Effect.sync(() => {95const makeGitService = Effect.sync(() => {
43 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {96 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
44 const commandInput = {97 const commandInput = {
@@ -59,34 +112,7 @@ const makeGitService = Effect.sync(() => { · 14 unmodified lines
59 outputMode: "error",112 outputMode: "error",
60 });113 });
61 114
62 if (result.timedOut) {115 return normalizeGitProcessResult(commandInput, input, result);
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;
90 },116 },
91 catch: toGitCommandError(commandInput, "failed to run."),117 catch: toGitCommandError(commandInput, "failed to run."),
92 });118 });
apps/server/src/processRunner.ts+1−1
@@ -127,7 +127,7 @@ export const spawnManagedCommand = (command: ChildProcess.Command) => · 126 unmodified lines
127 } satisfies ManagedChildProcess;127 } satisfies ManagedChildProcess;
128 });128 });
129 129
130export function spawnProcess(130function spawnProcess(
131 command: string,131 command: string,
132 args: readonly string[],132 args: readonly string[],
133 options: ProcessSpawnOptions = {},133 options: ProcessSpawnOptions = {},
apps/server/src/provider/Layers/ProviderHealth.test.ts+32−1
@@ -7,7 +7,12 @@ import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderH · 6 unmodified lines
7// ── Test helpers ────────────────────────────────────────────────────7// ── Test helpers ────────────────────────────────────────────────────
8 8
9function mockRunner(9function 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 },
11) {16) {
12 return (args: ReadonlyArray<string>) => Promise.resolve(handler(args));17 return (args: ReadonlyArray<string>) => Promise.resolve(handler(args));
13}18}
@@ -116,6 +121,32 @@ it.effect("returns warning when login status command is unsupported", () => · 102 unmodified lines
116 }),121 }),
117);122);
118 123
124it.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
143it("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
119// ── Pure function tests ─────────────────────────────────────────────150// ── Pure function tests ─────────────────────────────────────────────
120 151
121it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => {152it("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
31export interface CommandResult {31export interface CommandResult {
32 readonly stdout: string;32 readonly stdout: string;
33 readonly stderr: string;33 readonly stderr: string;
34 readonly code: number;34 readonly code: number | null;
35 readonly timedOut?: boolean;35 readonly timedOut?: boolean;
36}36}
37 37
@@ -109,6 +109,14 @@ export function parseAuthStatusFromOutput(result: CommandResult): { · 71 unmodified lines
109 readonly authStatus: ServerProviderAuthStatus;109 readonly authStatus: ServerProviderAuthStatus;
110 readonly message?: string;110 readonly message?: string;
111} {111} {
112 if (result.timedOut) {
113 return {
114 status: "warning",
115 authStatus: "unknown",
116 message: "Timed out while checking Codex authentication status.",
117 };
118 }
119
112 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();120 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
113 121
114 if (122 if (
@@ -199,7 +207,7 @@ const defaultRunCodexCommand: RunProviderHealthCommand = async (args) => { · 84 unmodified lines
199 return {207 return {
200 stdout: result.stdout,208 stdout: result.stdout,
201 stderr: result.stderr,209 stderr: result.stderr,
202 code: result.code ?? 0,210 code: result.code,
203 timedOut: result.timedOut,211 timedOut: result.timedOut,
204 } satisfies CommandResult;212 } satisfies CommandResult;
205};213};
@@ -237,7 +245,7 @@ export const checkCodexProviderStatus = ( · 31 unmodified lines
237 }245 }
238 246
239 const version = versionProbe.success;247 const version = versionProbe.success;
240 if (version.code !== 0) {248 if (version.timedOut || version.code !== 0) {
241 const detail = detailFromResult(version);249 const detail = detailFromResult(version);
242 return {250 return {
243 provider: CODEX_PROVIDER,251 provider: CODEX_PROVIDER,