From a7b35243cb46b12978c393208a6dfeace24b8857 Mon Sep 17 00:00:00 2001 From: Ashvin Nihalani Date: Thu, 19 Mar 2026 14:03:42 -0700 Subject: [PATCH] fix(server): suppress teardown-only Codex session closures Allow CodexAppServerManager stopAll/stopSession to suppress session/closed lifecycle emission, and use that path from CodexAdapter stopAll and adapter finalization. Add manager and adapter coverage for silent teardown behavior. --- apps/server/src/codexAppServerManager.test.ts | 54 +++++++++++++++++++ apps/server/src/codexAppServerManager.ts | 14 +++-- .../src/provider/Layers/CodexAdapter.test.ts | 40 ++++++++++++-- .../src/provider/Layers/CodexAdapter.ts | 4 +- 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ab3b7a569d..09968b4700 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -470,6 +470,60 @@ describe("startSession", () => { manager.stopAll(); } }); + + it("supports silent shutdown without emitting session/closed lifecycle events", () => { + const manager = new CodexAppServerManager(); + const emitLifecycleEvent = vi.spyOn( + manager as unknown as { + emitLifecycleEvent: (...args: unknown[]) => void; + }, + "emitLifecycleEvent", + ); + const updateSession = vi.spyOn( + manager as unknown as { + updateSession: (...args: unknown[]) => void; + }, + "updateSession", + ); + const sessions = ( + manager as unknown as { + sessions: Map; + } + ).sessions; + + sessions.set("thread-1", { + session: { + provider: "codex", + status: "ready", + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + createdAt: "2026-03-19T00:00:00.000Z", + updatedAt: "2026-03-19T00:00:00.000Z", + }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, + child: { + killed: true, + }, + output: { + close: vi.fn(), + }, + pending: new Map(), + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + nextRequestId: 1, + stopping: false, + }); + + manager.stopAll({ emitLifecycleEvent: false }); + + expect(updateSession).toHaveBeenCalled(); + expect(emitLifecycleEvent).not.toHaveBeenCalled(); + expect(sessions.size).toBe(0); + }); }); describe("sendTurn", () => { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 230ba8e364..63190153c5 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -127,6 +127,10 @@ export interface CodexAppServerStartSessionInput { readonly runtimeMode: RuntimeMode; } +interface CodexAppServerStopOptions { + readonly emitLifecycleEvent?: boolean; +} + export interface CodexThreadTurnSnapshot { id: TurnId; items: unknown[]; @@ -895,7 +899,7 @@ export class CodexAppServerManager extends EventEmitter => undefined, ); - public stopAllImpl = vi.fn(() => undefined); + public stopAllImpl = vi.fn( + (_options?: Parameters[0]) => undefined, + ); override startSession(input: CodexAppServerStartSessionInput): Promise { return this.startSessionImpl(input); @@ -134,8 +136,8 @@ class FakeCodexManager extends CodexAppServerManager { return false; } - override stopAll(): void { - this.stopAllImpl(); + override stopAll(options?: Parameters[0]): void { + this.stopAllImpl(options); } } @@ -431,6 +433,18 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("suppresses lifecycle events when adapter stopAll tears down sessions", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + + yield* adapter.stopAll(); + + assert.deepEqual(lifecycleManager.stopAllImpl.mock.calls.at(-1), [ + { emitLifecycleEvent: false }, + ]); + }), + ); + it.effect("maps retryable Codex error notifications to runtime.warning", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -977,6 +991,26 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { ); }); +it.effect("suppresses lifecycle events when the adapter scope finalizes", () => + Effect.gen(function* () { + const manager = new FakeCodexManager(); + const layer = makeCodexAdapterLive({ manager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* CodexAdapter; + }).pipe(Effect.provide(layer)), + ); + + assert.deepEqual(manager.stopAllImpl.mock.calls, [[{ emitLifecycleEvent: false }]]); + }), +); + afterAll(() => { if (lifecycleManager.stopAllImpl.mock.calls.length === 0) { lifecycleManager.stopAll(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 60de91b79a..1f51a91834 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1366,7 +1366,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const manager = yield* Effect.acquireRelease(acquireManager(), (manager) => Effect.sync(() => { try { - manager.stopAll(); + manager.stopAll({ emitLifecycleEvent: false }); } catch { // Finalizers should never fail and block shutdown. } @@ -1565,7 +1565,7 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const stopAll: CodexAdapterShape["stopAll"] = () => Effect.sync(() => { - manager.stopAll(); + manager.stopAll({ emitLifecycleEvent: false }); }); const runtimeEventQueue = yield* Queue.unbounded();