Skip to content
Merged
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
72 changes: 72 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,78 @@ describe("CodexAdapterV2 post-settle continuation", () => {
);
}

it.effect("unsubscribes from the native thread when it is unloaded", () =>
Effect.gen(function* () {
const nativeThreadId = "unload-thread";
const preamble = codexReplayPreamble({
nativeThreadId,
nativeTurnId: "unused",
prompt: "unused",
});
const transcript = makeCodexReplayTranscript({
scenario: "unload-thread",
entries: [
// initialize + thread/start only; no turn runs.
...preamble.slice(0, 5),
{
type: "expect_outbound",
label: "thread/unsubscribe",
frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } },
},
// Response shape recorded from codex app-server 0.156.1.
{
type: "emit_inbound",
label: "thread/unsubscribe",
frame: { id: 3, result: { status: "unsubscribed" } },
},
],
});
const requests: Array<string> = [];
const harness = yield* makeCodexReplayHarness(
transcript,
() => Effect.void,
(method) => Effect.sync(() => requests.push(method)),
);
assert.isDefined(harness.runtime.unloadThread);
yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread });
assert.deepEqual(requests, ["initialize", "thread/start", "thread/unsubscribe"]);
}).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))),
);

it.effect("keeps the app-server failure as the cause when an unload is rejected", () =>
Effect.gen(function* () {
const nativeThreadId = "unload-thread-rejected";
const preamble = codexReplayPreamble({
nativeThreadId,
nativeTurnId: "unused",
prompt: "unused",
});
const transcript = makeCodexReplayTranscript({
scenario: "unload-thread-rejected",
entries: [
...preamble.slice(0, 5),
{
type: "expect_outbound",
label: "thread/unsubscribe",
frame: { id: 3, method: "thread/unsubscribe", params: { threadId: nativeThreadId } },
},
{
type: "emit_inbound",
label: "thread/unsubscribe",
frame: { id: 3, error: { code: -32600, message: "invalid thread id" } },
},
],
});
const harness = yield* makeCodexReplayHarness(transcript);
const error = yield* harness.runtime.unloadThread!({
providerThread: harness.providerThread,
}).pipe(Effect.flip);
assert.equal(error._tag, "ProviderAdapterProtocolError");
const cause = error._tag === "ProviderAdapterProtocolError" ? error.cause : undefined;
assert.equal((cause as { _tag?: string } | undefined)?._tag, "CodexAppServerRequestError");
}).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))),
);

it.effect("waits for native start before interrupting an acknowledged queued turn", () =>
Effect.gen(function* () {
const nativeThreadId = "early-stop-thread";
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5589,6 +5589,24 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
}),
),
),
// Dropping this connection's subscription lets the shared app-server
// shut the native thread (and its MCP servers) down once it is idle.
// `notLoaded` / `notSubscribed` mean there is nothing left to unload.
unloadThread: (unloadInput) =>
Effect.gen(function* () {
const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread);
yield* client.request("thread/unsubscribe", { threadId: nativeThreadId });
}).pipe(
Effect.mapError((cause) =>
cause._tag === "ProviderAdapterProtocolError"
? cause
: new ProviderAdapterProtocolError({
driver: CODEX_PROVIDER,
detail: `Failed to unload Codex thread for provider thread ${unloadInput.providerThread.id}`,
cause: normalizeCodexCause(cause),
}),
),
),
interruptTurn: (turnInput) =>
Effect.gen(function* () {
const [activeTurnContexts, settledTurnContexts] =
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/orchestration-v2/ProviderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,14 @@ export interface ProviderAdapterV2SessionRuntime {
readonly interruptTurn: (
input: ProviderAdapterV2InterruptInput,
) => Effect.Effect<void, ProviderAdapterV2Error>;
/**
* Lets a runtime shared by several app threads unload one provider thread's
* native state (and its MCP servers) when that app thread detaches, while
* the runtime keeps serving the others. A later resume reloads it.
*/
readonly unloadThread?: (input: {
readonly providerThread: OrchestrationV2ProviderThread;
}) => Effect.Effect<void, ProviderAdapterV2Error>;
readonly respondToRuntimeRequest: (
input: ProviderAdapterV2RuntimeRequestResponseInput,
) => Effect.Effect<void, ProviderAdapterV2Error>;
Expand Down
129 changes: 129 additions & 0 deletions apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ interface TestProviderRuntimeState {
readonly closeCount: number;
readonly interruptCount: number;
readonly resumeCount: number;
readonly unloadedNativeThreadIds: ReadonlyArray<string>;
readonly eventQueues: ReadonlyMap<string, Queue.Queue<ProviderAdapterV2Event, Cause.Done>>;
}

Expand All @@ -108,6 +109,7 @@ const emptyState: TestProviderRuntimeState = {
closeCount: 0,
interruptCount: 0,
resumeCount: 0,
unloadedNativeThreadIds: [],
eventQueues: new Map(),
};

Expand Down Expand Up @@ -250,6 +252,7 @@ function makeProviderAdapter(
}) => Effect.Effect<void>;
readonly hasPendingBackgroundWork?: Effect.Effect<boolean>;
readonly hangSessionScopeClose?: boolean;
readonly beforeUnload?: Effect.Effect<void>;
} = {},
): ProviderAdapterV2Shape {
return {
Expand Down Expand Up @@ -327,6 +330,18 @@ function makeProviderAdapter(
...current,
interruptCount: current.interruptCount + 1,
})),
unloadThread: ({ providerThread }) =>
(options.beforeUnload ?? Effect.void).pipe(
Effect.andThen(
Ref.update(state, (current) => ({
...current,
unloadedNativeThreadIds: [
...current.unloadedNativeThreadIds,
providerThread.nativeThreadRef?.nativeId ?? "",
],
})),
),
),
respondToRuntimeRequest: () => Effect.void,
readThreadSnapshot: () => unimplemented("readThreadSnapshot unused in test"),
rollbackThread: () => unimplemented("rollbackThread unused in test"),
Expand All @@ -352,6 +367,7 @@ function makeTestLayer(input: {
readonly failReleaseEventWrites?: boolean;
readonly hasPendingBackgroundWork?: Effect.Effect<boolean>;
readonly hangSessionScopeClose?: boolean;
readonly beforeUnload?: Effect.Effect<void>;
readonly serverSettingsLayer?: ReturnType<typeof ServerSettings.layerTest>;
readonly projectServiceLayer?: Layer.Layer<ProjectService.ProjectService>;
}) {
Expand All @@ -370,6 +386,7 @@ function makeTestLayer(input: {
...(input.hangSessionScopeClose === undefined
? {}
: { hangSessionScopeClose: input.hangSessionScopeClose }),
...(input.beforeUnload === undefined ? {} : { beforeUnload: input.beforeUnload }),
}),
);
const providerEventIngestorTestLayer = providerEventIngestorLayer.pipe(
Expand Down Expand Up @@ -2811,10 +2828,16 @@ it.effect(
yield* resumeSecondThread;
assert.equal((yield* Ref.get(state)).resumeCount, 4);

// The second thread has no persisted provider thread, so nothing is unloaded.
assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, []);

yield* manager.detach({ providerSessionId, threadId: firstThreadId });
assert.isTrue(Option.isSome(yield* manager.get(providerSessionId)));
assert.equal((yield* Ref.get(state)).closeCount, 0);
assert.equal((yield* Ref.get(state)).interruptCount, 1);
// The runtime stays up for the second thread; the first thread's
// native state is unloaded after its turn is interrupted.
assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, ["native-thread"]);

yield* manager.detach({ providerSessionId, threadId: secondThreadId });
yield* TestClock.adjust("1 second");
Expand All @@ -2826,6 +2849,112 @@ it.effect(
}),
);

it.effect(
"ProviderSessionManagerV2 re-attaching a thread waits for its in-flight unload, then reloads it",
() =>
Effect.gen(function* () {
const state = yield* Ref.make(emptyState);
const unloadStarted = yield* Deferred.make<void>();
const releaseUnload = yield* Deferred.make<void>();
// Resumes the provider had served when the unload actually reached it.
let resumesBeforeUnload: number | undefined;
// The unload parks after detach removed the attachment, leaving the
// window in which the same thread's next turn re-attaches it.
const beforeUnload = Effect.gen(function* () {
yield* Deferred.succeed(unloadStarted, undefined);
yield* Deferred.await(releaseUnload);
resumesBeforeUnload = (yield* Ref.get(state)).resumeCount;
});
const effect = Effect.gen(function* () {
const eventSink = yield* EventSinkV2;
const idAllocator = yield* IdAllocatorV2;
const manager = yield* ProviderSessionManagerV2;
const now = yield* DateTime.now;
const projectId = yield* idAllocator.allocate.project({
fixtureName: "provider-session-manager-unload-race",
});
const threadId = yield* idAllocator.allocate.thread({
fixtureName: "provider-session-manager-unload-race-a",
projectId,
});
const otherThreadId = yield* idAllocator.allocate.thread({
fixtureName: "provider-session-manager-unload-race-b",
projectId,
});
const providerSessionId = idAllocator.derive.providerSession({
providerInstanceId: modelSelection.instanceId,
});
const providerThread = makeProviderThread({
idAllocator,
threadId,
providerSessionId,
now,
});
yield* eventSink.write({
events: [
yield* makeThreadCreatedEvent({ idAllocator, threadId, now }),
yield* makeThreadCreatedEvent({ idAllocator, threadId: otherThreadId, now }),
{
id: yield* idAllocator.allocate.event({ threadId }),
type: "provider-thread.updated",
threadId,
driver: CODEX_DRIVER,
occurredAt: now,
payload: providerThread,
},
],
});
const runtime = yield* manager.open({
threadId,
providerSessionId,
modelSelection,
runtimePolicy,
});
// A second thread keeps the shared runtime up after the detach.
yield* manager.open({
threadId: otherThreadId,
providerSessionId,
modelSelection,
runtimePolicy,
});
// Resuming re-attaches the thread to the shared runtime.
const resume = runtime.resumeThread({
providerThread,
threadId,
modelSelection,
runtimePolicy,
});
yield* resume;

const detach = yield* manager
.detach({ providerSessionId, threadId })
.pipe(Effect.forkScoped);
yield* Deferred.await(unloadStarted);
// The same thread's next turn re-attaches while the unload is parked.
// Give it room to run: unfixed, it reaches the provider's resume
// here; serialized, it waits for the unload.
const reattach = yield* resume.pipe(Effect.forkScoped);
yield* Effect.yieldNow;
yield* Effect.yieldNow;
yield* Deferred.succeed(releaseUnload, undefined);
yield* Fiber.join(detach);
yield* Fiber.join(reattach);

// The unload reached the provider before the re-attached resume, so
// that resume reloads the thread instead of being torn down after it.
assert.equal(resumesBeforeUnload, 1);
assert.equal((yield* Ref.get(state)).resumeCount, 2);
assert.deepEqual((yield* Ref.get(state)).unloadedNativeThreadIds, ["native-thread"]);
assert.isTrue(Option.isSome(yield* manager.get(providerSessionId)));
});

yield* effect.pipe(
Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1000, beforeUnload })),
Effect.scoped,
);
}),
);

it.effect(
"ProviderSessionManagerV2 rejects a second thread when the provider runtime is exclusive",
() =>
Expand Down
Loading
Loading