Skip to content
Closed
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
11 changes: 10 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,16 @@ const readDelegatedTaskStatusTranscript = Effect.fn("readDelegatedTaskStatusTran
const text = yield* fs.readFileString(
decodeURIComponent(delegatedTaskStatusTranscriptFile.pathname),
);
return yield* decodeProviderReplayNdjson(text.replaceAll(queuedFollowupResult, followupResult));
const transcript = yield* decodeProviderReplayNdjson(
text.replaceAll(queuedFollowupResult, followupResult),
);
// The recording ends when the recorder closes the app-server while the parent
// turn is still open. This test keeps acting as that parent afterwards, and
// replaying the exit would kill the shared process and fail the parent run.
return {
...transcript,
entries: transcript.entries.filter((entry) => entry.type !== "runtime_exit"),
};
}, Effect.provide(NodeServices.layer));

function makeProviderSnapshot(input: {
Expand Down
160 changes: 160 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { assert, describe, it } from "@effect/vitest";
import * as CodexClient from "effect-codex-app-server/client";
import * as CodexReplay from "effect-codex-app-server/replay";
import * as CodexError from "effect-codex-app-server/errors";
import type * as CodexSchema from "effect-codex-app-server/schema";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
Expand All @@ -47,6 +48,11 @@ import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as Sink from "effect/Sink";
import * as Stdio from "effect/Stdio";
import * as Exit from "effect/Exit";
import * as Cause from "effect/Cause";
import * as PlatformError from "effect/PlatformError";
import { TestClock } from "effect/testing";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

Expand Down Expand Up @@ -575,6 +581,7 @@ function codexReplayPreamble(input: {
thread: {
id: input.nativeThreadId,
sessionId: input.nativeThreadId,
projectId: null,
forkedFromId: null,
preview: "",
ephemeral: false,
Expand Down Expand Up @@ -667,6 +674,159 @@ function withReplayRequestId(
: entry;
}

describe("CodexAdapterV2 process exit", () => {
it.effect.each([
{ exit: "numeric", active: false },
{ exit: "SIGKILL", active: false },
{ exit: "numeric", active: true },
{ exit: "SIGKILL", active: true },
] as const)("retires the shared session after $exit (active turn: $active)", ({ exit, active }) =>
Effect.gen(function* () {
const scenario = `process-exit-${exit}-${active}`;
const nativeThreadId = `native-${scenario}`;
const nativeTurnId = `turn-${scenario}`;
const input = yield* Queue.unbounded<Uint8Array, Cause.Done>();
const emit = (frame: unknown) =>
Queue.offer(input, new TextEncoder().encode(JSON.stringify(frame) + "\n"));
const outputEnded = yield* Deferred.make<void>();
const turnStarted = yield* Deferred.make<void>();
const turnCompleted = yield* Deferred.make<void>();
const events: Array<ProviderAdapter.ProviderAdapterV2Event> = [];
const preamble = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" });
const terminationError =
exit === "numeric"
? new CodexError.CodexAppServerProcessExitedError({ code: 17, pid: 52 })
: new CodexError.CodexAppServerTransportError({
operation: "read-process-exit-status",
pid: 52,
cause: PlatformError.systemError({
_tag: "Unknown",
module: "ChildProcess",
method: "exitCode",
cause: new Error("Process terminated by SIGKILL"),
}),
});
const client = yield* CodexClient.make(
Stdio.make({
args: Effect.succeed([]),
stdin: Stream.fromQueue(input),
stdout: () =>
Sink.forEach((chunk: string | Uint8Array) =>
Effect.gen(function* () {
const frame = JSON.parse(
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
) as { readonly id?: number; readonly method: string };
const response = preamble.find(
(entry) =>
entry.type === "emit_inbound" &&
entry.label === frame.method &&
Predicate.isObject(entry.frame) &&
"id" in entry.frame,
);
if (response?.type === "emit_inbound") {
yield* emit(response.frame);
}
}),
).pipe(Sink.ensuring(Deferred.succeed(outputEnded, undefined))),
stderr: () => Sink.drain,
}),
{},
Effect.succeed(terminationError),
);
const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({
instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CODEX_SETTINGS,
environment: {},
clientFactory: { open: () => Effect.succeed(client) },
crypto: yield* Crypto.Crypto,
fileSystem: yield* FileSystem.FileSystem,
idAllocator: yield* IdAllocator.IdAllocatorV2,
serverConfig: yield* makeReplayServerConfig(scenario).pipe(Effect.orDie),
});
const threadId = ThreadId.make(`thread-${scenario}`);
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make(`session-${scenario}`),
modelSelection: CODEX_TEST_MODEL_SELECTION,
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
});
const eventReader = yield* runtime.events.pipe(
Stream.runForEach((event) =>
Effect.sync(() => events.push(event)).pipe(
Effect.andThen(
event.type === "provider_turn.updated" && event.providerTurn.status === "running"
? Deferred.succeed(turnStarted, undefined)
: event.type === "turn.terminal"
? Deferred.succeed(turnCompleted, undefined)
: Effect.void,
),
),
),
Effect.forkScoped,
);
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CODEX_TEST_MODEL_SELECTION,
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
});
yield* runtime.startTurn(
makeCodexTestTurnInput({
threadId,
providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make(`attempt-${scenario}`),
text: "work",
}),
);
yield* emit({
method: "turn/started",
params: {
threadId: nativeThreadId,
turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }),
},
});
yield* Deferred.await(turnStarted);
if (!active) {
yield* emit({
method: "turn/completed",
params: {
threadId: nativeThreadId,
turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }),
},
});
yield* Deferred.await(turnCompleted);
assert.isTrue(
events.some((event) => event.type === "turn.terminal" && event.status === "completed"),
);
}

yield* Queue.end(input);
// Output closes only after the protocol records the process termination.
yield* Deferred.await(outputEnded);
const rpcError = yield* client.request("thread/list", {}).pipe(Effect.flip);
assert.strictEqual(rpcError, terminationError);
// Drain runnable lifecycle work before inspecting the reader; no wall-clock deadline.
yield* TestClock.adjust(0);
const readerExit = eventReader.pollUnsafe();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// A clean end of stream reads as manual_shutdown to the session manager, so
// only a stream failure retires the dead process as a runtime error.
assert.isTrue(
readerExit !== undefined &&
Exit.isFailure(readerExit) &&
Schema.is(ProviderAdapter.ProviderAdapterEventStreamError)(
Cause.squash(readerExit.cause),
),
"the session manager must receive process death so it can retire the shared process",
);
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);
});

describe("CodexAdapterV2 session initialize", () => {
const openReplaySession = (
transcript: CodexReplay.CodexAppServerReplayTranscript,
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1838,7 +1838,25 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
model: input.modelSelection.model,
now,
});
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
const events = yield* Queue.unbounded<
ProviderAdapter.ProviderAdapterV2Event,
ProviderAdapter.ProviderAdapterEventStreamError
>();
// Drain final notifications before the session manager retires the dead process.
// Queue.fail lets readers take items already buffered before they see the failure.
yield* client.awaitTermination.pipe(
Effect.catch((cause) =>
Queue.fail(
events,
new ProviderAdapter.ProviderAdapterEventStreamError({
driver: CODEX_PROVIDER,
providerSessionId: input.providerSessionId,
cause,
}),
),
),
Effect.forkScoped,
);
const rateLimitSnapshot = yield* Ref.make<CodexRateLimitSnapshot | undefined>(undefined);
const limitedTurnItems = yield* Ref.make(
new Map<ProviderThreadId, Extract<OrchestrationV2TurnItem, { type: "error" }>>(),
Expand Down
5 changes: 5 additions & 0 deletions packages/effect-codex-app-server/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -40,6 +41,7 @@ export class CodexAppServerClient extends Context.Service<
CodexAppServerClient,
{
readonly raw: CodexAppServerClientRaw;
readonly awaitTermination: Effect.Effect<never, CodexError.CodexAppServerError>;
readonly request: <M extends CodexRpc.ClientRequestMethod>(
method: M,
payload: CodexRpc.ClientRequestParamsByMethod[M],
Expand Down Expand Up @@ -100,6 +102,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make
terminationError?: Effect.Effect<CodexError.CodexAppServerError>,
): Effect.fn.Return<CodexAppServerClient["Service"], never, Scope.Scope> {
const requestHandlers = new Map<string, ServerRequestHandler>();
const termination = yield* Deferred.make<never, CodexError.CodexAppServerError>();
const notificationHandlers = new Map<string, Array<ServerNotificationHandler>>();
let unknownRequestHandler:
| ((method: string, params: unknown) => Effect.Effect<unknown, CodexError.CodexAppServerError>)
Expand Down Expand Up @@ -203,6 +206,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make
...(options.logger ? { logger: options.logger } : {}),
onNotification: dispatchNotification,
onRequest: dispatchRequest,
onTermination: (error) => Deferred.fail(termination, error).pipe(Effect.asVoid),
});

const request = <M extends CodexRpc.ClientRequestMethod>(
Expand Down Expand Up @@ -230,6 +234,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make
);

return CodexAppServerClient.of({
awaitTermination: Deferred.await(termination),
raw: {
notifications: transport.incomingNotifications,
requests: transport.incomingRequests,
Expand Down
Loading