- 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
Unified · Split
| @@ -1,4 +1,5 @@ | |||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { Cause } from "effect"; | ||
| 2 | import { ManagedRuntime, ServiceMap } from "effect"; | 3 | import { ManagedRuntime, ServiceMap } from "effect"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | import { ProviderSessionId } from "@t3tools/contracts"; | 5 | import { 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 | ||
| 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 | |||
| 174 | describe("startSession", () => { | 182 | describe("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(); |
| @@ -17,7 +17,7 @@ import { · 16 unmodified lines | |||
| 17 | type ProviderTurnStartResult, | 17 | type ProviderTurnStartResult, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import type * as NodeServices from "@effect/platform-node/NodeServices"; | 19 | 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"; |
| 21 | import type { ChildProcessSpawner } from "effect/unstable/process"; | 21 | import type { ChildProcessSpawner } from "effect/unstable/process"; |
| 22 | import { makeRuntimeCommand, spawnManagedCommand } from "./processRunner"; | 22 | import { 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 | ||
| 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 | |||
| 156 | export interface CodexAppServerManagerEvents { | 161 | export 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, |
| @@ -3,7 +3,7 @@ import { it, assert } from "@effect/vitest"; · 2 unmodified lines | |||
| 3 | import { Effect, Layer, Schema } from "effect"; | 3 | import { Effect, Layer, Schema } from "effect"; |
| 4 | 4 | ||
| 5 | import { GitCommandError } from "../Errors.ts"; | 5 | import { GitCommandError } from "../Errors.ts"; |
| 6 | import { GitServiceLive } from "./GitService.ts"; | 6 | import { GitServiceLive, normalizeGitProcessResult } from "./GitService.ts"; |
| 7 | import { GitService } from "../Services/GitService.ts"; | 7 | import { GitService } from "../Services/GitService.ts"; |
| 8 | 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | 9 | const 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 | }); |
| @@ -39,6 +39,59 @@ function toGitCommandError( · 38 unmodified lines | |||
| 39 | }); | 39 | }); |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 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 | |||
| 42 | const makeGitService = Effect.sync(() => { | 95 | const 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 | }); |
| @@ -127,7 +127,7 @@ export const spawnManagedCommand = (command: ChildProcess.Command) => · 126 unmodified lines | |||
| 127 | } satisfies ManagedChildProcess; | 127 | } satisfies ManagedChildProcess; |
| 128 | }); | 128 | }); |
| 129 | 129 | ||
| 130 | export function spawnProcess( | 130 | function spawnProcess( |
| 131 | command: string, | 131 | command: string, |
| 132 | args: readonly string[], | 132 | args: readonly string[], |
| 133 | options: ProcessSpawnOptions = {}, | 133 | options: ProcessSpawnOptions = {}, |
| @@ -7,7 +7,12 @@ import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderH · 6 unmodified lines | |||
| 7 | // ── Test helpers ──────────────────────────────────────────────────── | 7 | // ── Test helpers ──────────────────────────────────────────────────── |
| 8 | 8 | ||
| 9 | function mockRunner( | 9 | 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 | }, | ||
| 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 | ||
| 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 | |||
| 119 | // ── Pure function tests ───────────────────────────────────────────── | 150 | // ── Pure function tests ───────────────────────────────────────────── |
| 120 | 151 | ||
| 121 | it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { | 152 | it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { |
| @@ -31,7 +31,7 @@ class ProviderHealthCommandError extends Data.TaggedError("ProviderHealthCommand · 30 unmodified lines | |||
| 31 | export interface CommandResult { | 31 | export 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, |