Skip to content

Commit 92a148c

Browse files
committed
fix(codex): recover turns after usage limits
1 parent cd096b9 commit 92a148c

5 files changed

Lines changed: 744 additions & 54 deletions

File tree

apps/server/src/provider/Layers/CodexAdapter.test.ts

Lines changed: 319 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,37 @@ const asTurnId = (value: string): TurnId => TurnId.make(value);
5959
const asEventId = (value: string): EventId => EventId.make(value);
6060
const asItemId = (value: string): ProviderItemId => ProviderItemId.make(value);
6161

62+
class TestQueue<A> {
63+
private readonly values: Array<A> = [];
64+
private readonly waiters: Array<(value: A) => void> = [];
65+
66+
offer(value: A): void {
67+
const waiter = this.waiters.shift();
68+
if (waiter) {
69+
waiter(value);
70+
return;
71+
}
72+
this.values.push(value);
73+
}
74+
75+
take(): Effect.Effect<A> {
76+
return Effect.promise(
77+
() =>
78+
new Promise<A>((resolve) => {
79+
const value = this.values.shift();
80+
if (value !== undefined) {
81+
resolve(value);
82+
return;
83+
}
84+
this.waiters.push(resolve);
85+
}),
86+
);
87+
}
88+
}
89+
6290
class FakeCodexRuntime implements CodexSessionRuntimeShape {
63-
private readonly eventQueue = Effect.runSync(Queue.unbounded<ProviderEvent>());
91+
public readonly closed = new TestQueue<true>();
92+
public readonly sentTurns = new TestQueue<CodexSessionRuntimeSendTurnInput>();
6493
private readonly now = "2026-01-01T00:00:00.000Z";
6594

6695
public readonly startImpl = vi.fn(() =>
@@ -71,6 +100,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
71100
threadId: this.options.threadId,
72101
cwd: this.options.cwd,
73102
...(this.options.model ? { model: this.options.model } : {}),
103+
resumeCursor: this.options.resumeCursor ?? { threadId: "provider-thread-1" },
74104
createdAt: this.now,
75105
updatedAt: this.now,
76106
} satisfies ProviderSession),
@@ -114,12 +144,17 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
114144
Promise.resolve(undefined),
115145
);
116146

117-
public readonly closeImpl = vi.fn(() => Promise.resolve(undefined));
147+
public readonly closeImpl = vi.fn(() => {
148+
this.closed.offer(true);
149+
return Promise.resolve(undefined);
150+
});
118151

119152
readonly options: CodexSessionRuntimeOptions;
153+
private readonly eventQueue: Queue.Queue<ProviderEvent>;
120154

121-
constructor(options: CodexSessionRuntimeOptions) {
155+
constructor(options: CodexSessionRuntimeOptions, eventQueue: Queue.Queue<ProviderEvent>) {
122156
this.options = options;
157+
this.eventQueue = eventQueue;
123158
}
124159

125160
start() {
@@ -129,6 +164,7 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
129164
getSession = Effect.promise(() => this.startImpl());
130165

131166
sendTurn(input: CodexSessionRuntimeSendTurnInput) {
167+
this.sentTurns.offer(input);
132168
return Effect.promise(() => this.sendTurnImpl(input));
133169
}
134170

@@ -161,16 +197,26 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
161197
}
162198
}
163199

164-
function makeRuntimeFactory() {
200+
function makeRuntimeFactory(configure?: (runtime: FakeCodexRuntime, index: number) => void) {
165201
const runtimes: Array<FakeCodexRuntime> = [];
166-
const factory = vi.fn((options: CodexSessionRuntimeOptions) => {
167-
const runtime = new FakeCodexRuntime(options);
168-
runtimes.push(runtime);
169-
return Effect.succeed(runtime);
170-
});
202+
const created = new TestQueue<FakeCodexRuntime>();
203+
const factory = vi.fn((options: CodexSessionRuntimeOptions) =>
204+
Effect.gen(function* () {
205+
const eventQueue = yield* Queue.unbounded<ProviderEvent>();
206+
const runtime = new FakeCodexRuntime(options, eventQueue);
207+
runtimes.push(runtime);
208+
configure?.(runtime, runtimes.length - 1);
209+
created.offer(runtime);
210+
return runtime;
211+
}),
212+
);
171213

172214
return {
173215
factory,
216+
created,
217+
get runtimes(): ReadonlyArray<FakeCodexRuntime> {
218+
return runtimes;
219+
},
174220
get lastRuntime(): FakeCodexRuntime | undefined {
175221
return runtimes.at(-1);
176222
},
@@ -197,7 +243,8 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea
197243
});
198244
}
199245

200-
const runtime = new FakeCodexRuntime(runtimeOptions);
246+
const eventQueue = yield* Queue.unbounded<ProviderEvent>();
247+
const runtime = new FakeCodexRuntime(runtimeOptions, eventQueue);
201248
runtimes.push(runtime);
202249
return runtime;
203250
}),
@@ -512,6 +559,268 @@ function startLifecycleRuntime() {
512559
});
513560
}
514561

562+
function usageLimitCompletionEvent(input?: {
563+
readonly turnId?: string;
564+
readonly items?: ReadonlyArray<unknown>;
565+
}): ProviderEvent {
566+
const turnId = input?.turnId ?? "turn-1";
567+
return {
568+
id: asEventId(`evt-usage-limit-${turnId}`),
569+
kind: "notification",
570+
provider: ProviderDriverKind.make("codex"),
571+
createdAt: "2026-01-01T00:00:00.000Z",
572+
method: "turn/completed",
573+
threadId: asThreadId("thread-1"),
574+
turnId: asTurnId(turnId),
575+
payload: {
576+
threadId: "provider-thread-1",
577+
turn: {
578+
id: turnId,
579+
status: "failed",
580+
items: input?.items ?? [],
581+
itemsView: "notLoaded",
582+
error: {
583+
message: "Usage limit exceeded",
584+
codexErrorInfo: "usageLimitExceeded",
585+
},
586+
},
587+
},
588+
} satisfies ProviderEvent;
589+
}
590+
591+
function itemLifecycleEvent(
592+
method: "item/started" | "item/completed",
593+
item: { readonly id: string; readonly type: string; readonly [key: string]: unknown },
594+
): ProviderEvent {
595+
return {
596+
id: asEventId(`evt-${method}-${item.id}`),
597+
kind: "notification",
598+
provider: ProviderDriverKind.make("codex"),
599+
createdAt: "2026-01-01T00:00:00.000Z",
600+
method,
601+
threadId: asThreadId("thread-1"),
602+
turnId: asTurnId("turn-1"),
603+
itemId: asItemId(item.id),
604+
payload: {
605+
threadId: "provider-thread-1",
606+
turnId: "turn-1",
607+
item,
608+
...(method === "item/started" ? { startedAtMs: 1 } : { completedAtMs: 2 }),
609+
},
610+
} satisfies ProviderEvent;
611+
}
612+
613+
function makeRecoveryTestLayer(runtimeFactory: ReturnType<typeof makeRuntimeFactory>) {
614+
return Layer.effect(
615+
CodexAdapter,
616+
Effect.gen(function* () {
617+
const codexConfig = decodeCodexSettings({ binaryPath: "codex-balanced" });
618+
return yield* makeCodexAdapter(codexConfig, {
619+
makeRuntime: runtimeFactory.factory,
620+
});
621+
}),
622+
).pipe(
623+
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
624+
Layer.provideMerge(ServerSettingsService.layerTest()),
625+
Layer.provideMerge(providerSessionDirectoryTestLayer),
626+
Layer.provideMerge(NodeServices.layer),
627+
);
628+
}
629+
630+
function startRecoveryTurn(
631+
adapter: CodexAdapterShape,
632+
runtimeFactory: ReturnType<typeof makeRuntimeFactory>,
633+
) {
634+
return Effect.gen(function* () {
635+
yield* adapter.startSession({
636+
provider: ProviderDriverKind.make("codex"),
637+
threadId: asThreadId("thread-1"),
638+
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex"),
639+
runtimeMode: "full-access",
640+
});
641+
const runtime = yield* runtimeFactory.created.take();
642+
yield* adapter.sendTurn({
643+
threadId: asThreadId("thread-1"),
644+
input: "continue this exact conversation",
645+
attachments: [],
646+
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex"),
647+
});
648+
yield* runtime.sentTurns.take();
649+
return runtime;
650+
});
651+
}
652+
653+
it.effect("restarts, resumes, and retries an empty Codex usage-limit turn once", () => {
654+
const runtimeFactory = makeRuntimeFactory((runtime, index) => {
655+
if (index !== 1) return;
656+
runtime.readThreadImpl.mockResolvedValue({
657+
threadId: "provider-thread-1",
658+
turns: [
659+
{
660+
id: asTurnId("turn-1"),
661+
items: [
662+
{
663+
id: "user-message-1",
664+
type: "userMessage",
665+
content: [{ type: "text", text: "continue this exact conversation" }],
666+
},
667+
],
668+
},
669+
],
670+
});
671+
});
672+
const layer = makeRecoveryTestLayer(runtimeFactory);
673+
674+
return Effect.gen(function* () {
675+
const adapter = yield* CodexAdapter;
676+
const firstRuntime = yield* startRecoveryTurn(adapter, runtimeFactory);
677+
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe(
678+
Effect.forkChild,
679+
);
680+
681+
const userMessage = {
682+
id: "user-message-1",
683+
type: "userMessage",
684+
content: [{ type: "text", text: "continue this exact conversation" }],
685+
};
686+
yield* firstRuntime.emit(itemLifecycleEvent("item/started", userMessage));
687+
yield* firstRuntime.emit(itemLifecycleEvent("item/completed", userMessage));
688+
yield* firstRuntime.emit(usageLimitCompletionEvent());
689+
yield* Fiber.join(eventsFiber);
690+
691+
const recoveredRuntime = yield* runtimeFactory.created.take();
692+
const retriedInput = yield* recoveredRuntime.sentTurns.take();
693+
694+
NodeAssert.equal(firstRuntime.closeImpl.mock.calls.length, 1);
695+
NodeAssert.equal(recoveredRuntime.options.binaryPath, "codex-balanced");
696+
NodeAssert.equal(recoveredRuntime.options.requireResume, true);
697+
NodeAssert.deepStrictEqual(recoveredRuntime.options.resumeCursor, {
698+
threadId: "provider-thread-1",
699+
});
700+
NodeAssert.equal(recoveredRuntime.rollbackThreadImpl.mock.calls.length, 1);
701+
NodeAssert.deepStrictEqual(retriedInput, {
702+
input: "continue this exact conversation",
703+
model: "gpt-5.3-codex",
704+
});
705+
}).pipe(Effect.provide(layer));
706+
});
707+
708+
it.effect("does not replace a session started while usage-limit recovery is opening", () => {
709+
let releaseRecoveryStart: (() => void) | undefined;
710+
const recoveryStartGate = new Promise<void>((resolve) => {
711+
releaseRecoveryStart = resolve;
712+
});
713+
const runtimeFactory = makeRuntimeFactory((runtime, index) => {
714+
if (index !== 1) return;
715+
const start = runtime.startImpl.getMockImplementation();
716+
if (!start) throw new Error("Expected the fake runtime start implementation");
717+
runtime.startImpl.mockImplementation(async () => {
718+
await recoveryStartGate;
719+
return start();
720+
});
721+
});
722+
const layer = makeRecoveryTestLayer(runtimeFactory);
723+
724+
return Effect.gen(function* () {
725+
const adapter = yield* CodexAdapter;
726+
const firstRuntime = yield* startRecoveryTurn(adapter, runtimeFactory);
727+
const completedFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
728+
yield* firstRuntime.emit(usageLimitCompletionEvent());
729+
yield* Fiber.join(completedFiber);
730+
731+
const recoveringRuntime = yield* runtimeFactory.created.take();
732+
yield* adapter.startSession({
733+
provider: ProviderDriverKind.make("codex"),
734+
threadId: asThreadId("thread-1"),
735+
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex"),
736+
runtimeMode: "full-access",
737+
});
738+
const userStartedRuntime = yield* runtimeFactory.created.take();
739+
740+
releaseRecoveryStart?.();
741+
yield* recoveringRuntime.closed.take();
742+
743+
yield* adapter.sendTurn({
744+
threadId: asThreadId("thread-1"),
745+
input: "use the session I started",
746+
attachments: [],
747+
modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex"),
748+
});
749+
const sentInput = yield* userStartedRuntime.sentTurns.take();
750+
NodeAssert.equal(sentInput.input, "use the session I started");
751+
NodeAssert.equal(userStartedRuntime.closeImpl.mock.calls.length, 0);
752+
}).pipe(Effect.provide(layer));
753+
});
754+
755+
it.effect("does not retry a usage-limit turn that emits provider activity", () => {
756+
const runtimeFactory = makeRuntimeFactory();
757+
const layer = makeRecoveryTestLayer(runtimeFactory);
758+
759+
return Effect.gen(function* () {
760+
const adapter = yield* CodexAdapter;
761+
const runtime = yield* startRecoveryTurn(adapter, runtimeFactory);
762+
const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
763+
Effect.forkChild,
764+
);
765+
766+
yield* runtime.emit(
767+
itemLifecycleEvent("item/started", {
768+
id: "msg-1",
769+
type: "agentMessage",
770+
text: "partial output",
771+
}),
772+
);
773+
yield* runtime.emit(usageLimitCompletionEvent());
774+
yield* Fiber.join(eventsFiber);
775+
776+
NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 1);
777+
NodeAssert.equal(runtime.closeImpl.mock.calls.length, 0);
778+
}).pipe(Effect.provide(layer));
779+
});
780+
781+
it.effect("does not retry provider output reported only by turn completion", () => {
782+
const runtimeFactory = makeRuntimeFactory();
783+
const layer = makeRecoveryTestLayer(runtimeFactory);
784+
785+
return Effect.gen(function* () {
786+
const adapter = yield* CodexAdapter;
787+
const runtime = yield* startRecoveryTurn(adapter, runtimeFactory);
788+
const completedFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
789+
790+
yield* runtime.emit(
791+
usageLimitCompletionEvent({
792+
items: [{ id: "msg-1", type: "agentMessage", text: "partial output" }],
793+
}),
794+
);
795+
yield* Fiber.join(completedFiber);
796+
797+
NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 1);
798+
NodeAssert.equal(runtime.closeImpl.mock.calls.length, 0);
799+
}).pipe(Effect.provide(layer));
800+
});
801+
802+
it.effect("does not restart again when the retried turn also exhausts usage", () => {
803+
const runtimeFactory = makeRuntimeFactory();
804+
const layer = makeRecoveryTestLayer(runtimeFactory);
805+
806+
return Effect.gen(function* () {
807+
const adapter = yield* CodexAdapter;
808+
const firstRuntime = yield* startRecoveryTurn(adapter, runtimeFactory);
809+
const firstCompletedFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
810+
yield* firstRuntime.emit(usageLimitCompletionEvent());
811+
yield* Fiber.join(firstCompletedFiber);
812+
813+
const recoveredRuntime = yield* runtimeFactory.created.take();
814+
yield* recoveredRuntime.sentTurns.take();
815+
const secondCompletedFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
816+
yield* recoveredRuntime.emit(usageLimitCompletionEvent());
817+
yield* Fiber.join(secondCompletedFiber);
818+
819+
NodeAssert.equal(runtimeFactory.factory.mock.calls.length, 2);
820+
NodeAssert.equal(recoveredRuntime.closeImpl.mock.calls.length, 0);
821+
}).pipe(Effect.provide(layer));
822+
});
823+
515824
lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
516825
it.effect("maps completed agent message items to canonical item.completed events", () =>
517826
Effect.gen(function* () {

0 commit comments

Comments
 (0)