Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,10 @@ import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutab
import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
import {
makeProviderFailure,
MAX_PROVIDER_FAILURE_MESSAGE_LENGTH,
} from "@t3tools/provider-core/server/failure";
import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts";
Expand Down Expand Up @@ -1778,6 +1781,60 @@ describe("ClaudeAdapterV2 MCP credential channel", () => {
);
});

describe("ClaudeAdapterV2 startup failure diagnostics", () => {
it.live.each([
{ label: "short", code: 1, paddingBytes: 0 },
{ label: "oversized", code: 23, paddingBytes: 512 * 1024 },
])("surfaces startup exit code and bounded $label stderr", ({ code, paddingBytes }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-startup-" });
const cliPath = path.join(directory, "cli.js");
const diagnostic = "error: An internal error occurred (EPERM): issue-5943 startup fixture";
yield* fileSystem.writeFileString(
cliPath,
`process.stderr.write("x".repeat(${paddingBytes}) + ${JSON.stringify(`\n${diagnostic}\n`)}, () => process.exit(${code}));`,
);
const exit = yield* Effect.gen(function* () {
const runner = yield* ClaudeAdapterV2.ClaudeAgentSdkQueryRunner;
const session = yield* runner.open({
threadId: ThreadId.make("thread-claude-startup-failure"),
providerSessionId: ProviderSessionId.make("provider-session-claude-startup-failure"),
options: {
...ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
nativeThreadId: "native-thread-claude-startup-failure",
resume: false,
cwd: directory,
environment: { PATH: process.env.PATH ?? "" },
}),
pathToClaudeCodeExecutable: cliPath,
},
});
yield* Stream.runDrain(session.messages).pipe(
Effect.ensuring(session.close.pipe(Effect.ignore)),
);
}).pipe(Effect.provide(ClaudeAdapterV2.layerQueryRunner), Effect.exit);

assert.isTrue(Exit.isFailure(exit));
if (!Exit.isFailure(exit)) return;
const failure = makeProviderFailure({ cause: exit.cause, class: "provider_error" });
assert.include(failure.message, `code ${code}`);
assert.include(failure.message, diagnostic);
assert.isAtMost(failure.message.length, MAX_PROVIDER_FAILURE_MESSAGE_LENGTH);
assert.notInclude(failure.message, "fromAsyncIterable");
assert.notInclude(failure.message, "setMcpServers");
}).pipe(
Effect.provideService(
ProviderEventLoggers.ProviderEventLoggers,
ProviderEventLoggers.NoOpProviderEventLoggers,
),
Effect.provide(NodeServices.layer),
),
);
});

describe("ClaudeAdapterV2 native session identity", () => {
const openTurnWithOrdinal = (providerTurnOrdinal: number, nativeThreadHasTurns?: boolean) =>
Effect.scoped(
Expand Down
74 changes: 63 additions & 11 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,23 @@ const BACKGROUND_MOVE_TIMEOUT_MS = 5_000;
* reads (#3089). Those stay in the CLI's group, so the rest of the group gets SIGKILL once the
* CLI exits for any reason.
*/
function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess {
function spawnClaudeProcessGroup(
options: ClaudeSpawnOptions,
platform: string,
onStderr: (chunk: string) => void,
) {
const child = NodeChildProcess.spawn(options.command, options.args, {
cwd: options.cwd,
env: options.env,
signal: options.signal,
stdio: ["pipe", "pipe", "ignore"],
detached: true,
stdio: ["pipe", "pipe", "pipe"],
detached: platform !== "win32",
...(platform === "win32" ? { windowsHide: true } : {}),
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", onStderr);
child.once("exit", () => {
if (platform === "win32") return;
try {
signalProcessGroup(child.pid ?? 0, "SIGKILL");
} catch {}
Expand Down Expand Up @@ -512,6 +520,23 @@ export class ClaudeAgentSdkQueryRunnerError extends Schema.TaggedError<ClaudeAge
}
}

class ClaudeProcessExitedError extends Schema.TaggedError<ClaudeProcessExitedError>()(
"ClaudeProcessExitedError",
{
code: Schema.NullOr(Schema.Number),
signal: Schema.NullOr(Schema.String),
stderr: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
const status = this.signal
? `Claude Code process terminated by signal ${this.signal}`
: `Claude Code process exited with code ${this.code}`;
return this.stderr ? `${status}. stderr: ${this.stderr}` : status;
}
}

export class ClaudeBackgroundWorkBlocksQueryReplacementError extends Schema.TaggedError<ClaudeBackgroundWorkBlocksQueryReplacementError>()(
"ClaudeBackgroundWorkBlocksQueryReplacementError",
{},
Expand Down Expand Up @@ -922,19 +947,43 @@ export const layerQueryRunner: Layer.Layer<
pathToClaudeCodeExecutable: launch.command,
executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])],
};
// Windows has no process groups, so there the SDK spawns the CLI itself.
const spawnClaudeCodeProcess =
options.spawnClaudeCodeProcess ??
(platform === "win32" ? undefined : spawnClaudeProcessGroup);
let child: ClaudeSpawnedProcess | undefined;
let stderr = "";
let stderrClosed = Promise.resolve();
const spawnClaudeCodeProcess = (spawnOptions: ClaudeSpawnOptions) => {
if (options.spawnClaudeCodeProcess) {
child = options.spawnClaudeCodeProcess(spawnOptions);
} else {
const process = spawnClaudeProcessGroup(spawnOptions, platform, (chunk) => {
stderr = (stderr + chunk).slice(-2_048);
});
stderrClosed = new Promise<void>((resolve) => process.stderr.once("close", resolve));
child = process;
}
return child;
};
const processError = Effect.fn("ClaudeAgentSdkQueryRunner.processExitFailure")(function* (
error: ClaudeAgentSdkQueryRunnerError,
) {
const code = child?.exitCode;
const signal = child?.signalCode ?? null;
// Node uses negative errno values when spawn fails, without a process exit.
if (code === undefined || ((code === null || code < 0) && signal === null)) {
return yield* error;
}
// Exit can precede the final stderr data; inherited pipes must not hold the turn open.
yield* Effect.promise(() => stderrClosed).pipe(Effect.timeoutOption("1 second"));
return yield* queryRunnerError(
new ClaudeProcessExitedError({ code, signal, stderr: stderr.trim(), cause: error }),
error.method,
);
});
const queryRuntime = yield* Effect.uninterruptibleMask((restore) =>
Effect.try({
try: () =>
query({
prompt,
options:
spawnClaudeCodeProcess === undefined
? options
: { ...options, spawnClaudeCodeProcess },
options: { ...options, spawnClaudeCodeProcess },
}),
catch: (cause) => queryRunnerError(cause, "query"),
}).pipe(
Expand Down Expand Up @@ -992,6 +1041,9 @@ export const layerQueryRunner: Layer.Layer<
messages: Stream.fromAsyncIterable(claudeQueryMessages(queryRuntime), (cause) =>
queryRunnerError(cause, "fromAsyncIterable"),
).pipe(
Stream.catchTags({
ClaudeAgentSdkQueryRunnerError: (error) => Stream.fromEffect(processError(error)),
}),
Stream.tap((message) =>
logProtocolEvent({
direction: "incoming",
Expand Down
35 changes: 35 additions & 0 deletions packages/effect-codex-app-server/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Cause from "effect/Cause";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
Expand Down Expand Up @@ -156,3 +157,37 @@ it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => {
}),
);
});

it.live.each([
{ label: "short", code: 1, paddingBytes: 0 },
{ label: "oversized", code: 23, paddingBytes: 512 * 1024 },
])(
"reports startup exit code and bounded $label stderr during initialize",
({ code, paddingBytes }) =>
Effect.gen(function* () {
const diagnostic = "error: An internal error occurred (EPERM): issue-5943 startup fixture";
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(process.execPath, [
"-e",
`process.stderr.write("x".repeat(${paddingBytes}) + ${JSON.stringify(`\n${diagnostic}\n`)}, () => process.exit(${code}));`,
]),
);
const exit = yield* Effect.gen(function* () {
const client = yield* CodexClient.CodexAppServerClient;
yield* client.request("initialize", {
clientInfo: { name: "startup-failure-test", version: "0.0.0" },
capabilities: null,
});
}).pipe(Effect.provide(CodexClient.layerChildProcess(handle)), Effect.exit);

assert.isTrue(Exit.isFailure(exit));
if (!Exit.isFailure(exit)) return;
const error = Cause.squash(exit.cause);
assert.instanceOf(error, Error);
if (!(error instanceof Error)) return;
assert.include(error.message, `code ${code}`);
assert.include(error.message, diagnostic);
assert.isAtMost(error.message.length, 8 * 1024);
}).pipe(Effect.provide(NodeServices.layer)),
);
34 changes: 32 additions & 2 deletions packages/effect-codex-app-server/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Fiber from "effect/Fiber";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Stdio from "effect/Stdio";
Expand Down Expand Up @@ -270,6 +271,35 @@ export const layerChildProcess = (
const makeChildProcessClient = Effect.fn(
"effect-codex-app-server/CodexAppServerClient.makeChildProcessClient",
)(function* (handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions) {
yield* Stream.runDrain(handle.stderr).pipe(Effect.ignore, Effect.forkScoped);
return yield* make(makeChildStdio(handle), options, makeTerminationError(handle));
let stderr = "";
const stderrReader = yield* handle.stderr.pipe(
Stream.decodeText(),
Stream.runForEach((chunk) =>
Effect.sync(() => {
stderr = (stderr + chunk).slice(-2_048);
}),
),
Effect.ignore,
Effect.forkScoped,
);
const terminationError = makeTerminationError(handle).pipe(
// A descendant can inherit stderr after the app server exits. Give buffered
// diagnostics time to drain without holding pending requests open forever.
Effect.tap((error) =>
error._tag === "CodexAppServerProcessExitedError"
? Fiber.join(stderrReader).pipe(Effect.timeoutOption("1 second"))
: Effect.void,
),
Effect.map((error) =>
error._tag === "CodexAppServerProcessExitedError"
? new CodexError.CodexAppServerProcessExitedError({
code: error.code,
pid: handle.pid,
cause: error.cause,
stderr: stderr.trim(),
})
: error,
),
);
return yield* make(makeChildStdio(handle), options, terminationError);
});
9 changes: 6 additions & 3 deletions packages/effect-codex-app-server/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,15 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedError<CodexAp
code: Schema.optional(Schema.Number),
pid: Schema.optionalKey(Schema.Int),
cause: Schema.optional(Schema.Defect()),
stderr: Schema.optionalKey(Schema.String),
},
) {
override get message() {
return this.code === undefined
? "Codex App Server process exited"
: `Codex App Server process exited with code ${this.code}`;
const status =
this.code === undefined
? "Codex App Server process exited"
: `Codex App Server process exited with code ${this.code}`;
return this.stderr ? `${status}. stderr: ${this.stderr}` : status;
}
}

Expand Down
26 changes: 26 additions & 0 deletions packages/provider-core/src/server/failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ it("redacts credentials and URL secrets from provider failures", () => {
assert.notInclude(failure.message, "sk-abcdefghijklmnop");
});

it.each(["ClaudeProcessExitedError", "CodexAppServerProcessExitedError"])(
"preserves and sanitizes wrapped %s diagnostics",
(_tag) => {
const failure = makeProviderFailure({
cause: Cause.fail({
_tag: "ProviderAdapterTurnStartError",
cause: {
_tag,
message:
'CLI process exited with code 23. stderr: EPERM Authorization: Bearer bearer-secret https://user:pass@example.test/path?token=url-secret {"token":"json-secret"}',
cause: new Error("private underlying defect"),
},
}),
});
assert.include(failure.message, "code 23");
assert.include(failure.message, "EPERM");
assert.include(failure.message, "[REDACTED]");
assert.include(failure.message, "https://example.test/path");
assert.notInclude(failure.message, "bearer-secret");
assert.notInclude(failure.message, "user:pass");
assert.notInclude(failure.message, "url-secret");
assert.notInclude(failure.message, "json-secret");
assert.notInclude(failure.message, "private underlying defect");
},
);

it("does not split a surrogate pair at the truncation boundary", () => {
const failure = makeProviderFailure({
message: `${"a".repeat(MAX_PROVIDER_FAILURE_MESSAGE_LENGTH - 2)}🚀tail`,
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function curatedCauseMessages(cause: unknown): {
switch (tag) {
case "ContextHandoffBudgetError":
return { known: new ContextHandoffBudgetError().message, category: categoryMessage() };
case "ClaudeProcessExitedError":
case "CodexAppServerProcessExitedError":
case "AcpModelSelectionError":
case "ClaudeBackgroundWorkBlocksQueryReplacementError":
case "ClaudeAppendSystemPromptFileError":
Expand Down
Loading