authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-03 20:57:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 20:57:59-03:00
log44bd4c9cafa61a415f76fbb261f321a158660577
tree5135a8fd2f8962531ea88f3acd3d31b5fbe68044
parenta976f8c74ca4eab6e16bb47b5fa446154b743371
signature Signed by PGP key B5690EEEBB952194

fix(codex): resume archived native sessions (#15389)


2 files changed, 185 insertions(+), 14 deletions(-)

apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+155
...@@ -2597,6 +2597,161 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2597,6 +2597,161 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2597 ),2597 ),
2598 );2598 );
25992599
2600 it.effect.each(
2601 (
2602 [
2603 ["recovers archived-session errors", "session saved-thread is archived", 0, ""],
2604 [
2605 "recovers unarchive hints",
2606 "Run `codex unarchive saved-thread` to unarchive it first.",
2607 0,
2608 "",
2609 ],
2610 ["preserves missing-thread errors", "thread not found", 3, "thread not found"],
2611 ["preserves missing-rollout errors", "no rollout found", 3, "no rollout found"],
2612 ["preserves authentication errors", "authentication failed", 3, "authentication failed"],
2613 [
2614 "preserves archived-workspace errors",
2615 "workspace is archived",
2616 3,
2617 "workspace is archived",
2618 ],
2619 [
2620 "preserves unrelated archive-path errors",
2621 "permission denied reading archived_sessions/saved-thread",
2622 3,
2623 "permission denied reading archived_sessions/saved-thread",
2624 ],
2625 [
2626 "propagates unarchive missing-thread errors",
2627 "session saved-thread is archived",
2628 4,
2629 "thread not found",
2630 ],
2631 [
2632 "propagates unarchive archived errors",
2633 "session saved-thread is archived",
2634 4,
2635 "session saved-thread is archived",
2636 ],
2637 [
2638 "propagates retry missing-thread errors",
2639 "session saved-thread is archived",
2640 5,
2641 "thread not found",
2642 ],
2643 [
2644 "does not retry archived errors twice",
2645 "session saved-thread is archived",
2646 5,
2647 "session saved-thread is archived",
2648 ],
2649 ] as const
2650 ).map(([name, resumeError, failAt, finalError]) => ({ name, resumeError, failAt, finalError })),
2651 )("$name", ({ name, resumeError, failAt, finalError }) =>
2652 Effect.scoped(
2653 Effect.gen(function* () {
2654 const nativeThreadId = "saved-thread";
2655 const params = {
2656 threadId: nativeThreadId,
2657 excludeTurns: true,
2658 cwd: CODEX_TEST_RUNTIME_POLICY.cwd,
2659 model: CODEX_TEST_MODEL_SELECTION.model,
2660 config: CodexAdapterV2.CODEX_THREAD_CONFIG,
2661 };
2662 const entries: Array<CodexReplay.CodexAppServerReplayEntry> = [
2663 ...codexReplayPreamble({
2664 nativeThreadId,
2665 nativeTurnId: "unused-turn",
2666 prompt: "unused-prompt",
2667 }).slice(0, 5),
2668 {
2669 type: "expect_outbound",
2670 label: "resume archived thread",
2671 frame: { id: 3, method: "thread/resume", params },
2672 },
2673 {
2674 type: "emit_inbound",
2675 label: "resume error",
2676 frame: { id: 3, error: { code: -32600, message: resumeError } },
2677 },
2678 ];
2679 if (failAt !== 3) {
2680 entries.push(
2681 {
2682 type: "expect_outbound",
2683 label: "unarchive same thread",
2684 frame: { id: 4, method: "thread/unarchive", params: { threadId: nativeThreadId } },
2685 },
2686 {
2687 type: "emit_inbound",
2688 label: "unarchive result",
2689 frame:
2690 failAt === 4
2691 ? { id: 4, error: { code: -32600, message: finalError } }
2692 : { id: 4, result: { thread: { turns: [{ type: "unknown-history-item" }] } } },
2693 },
2694 );
2695 }
2696 if (failAt === 0 || failAt === 5) {
2697 entries.push(
2698 {
2699 type: "expect_outbound",
2700 label: "retry identical resume",
2701 frame: { id: 5, method: "thread/resume", params },
2702 },
2703 {
2704 type: "emit_inbound",
2705 label: "retry result",
2706 frame:
2707 failAt === 5
2708 ? { id: 5, error: { code: -32600, message: finalError } }
2709 : {
2710 id: 5,
2711 result: {
2712 thread: {
2713 id: nativeThreadId,
2714 updatedAt: 1782622450,
2715 turns: [{ type: "unknown-history-item" }],
2716 },
2717 },
2718 },
2719 },
2720 );
2721 }
2722 const harness = yield* makeCodexReplayHarness(
2723 makeCodexReplayTranscript({ scenario: name, entries }),
2724 );
2725 const resume = harness.runtime.resumeThread({
2726 providerThread: harness.providerThread,
2727 modelSelection: CODEX_TEST_MODEL_SELECTION,
2728 runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
2729 });
2730 if (failAt !== 0) {
2731 const error = yield* Effect.flip(resume);
2732 assert.equal(error._tag, "ProviderAdapterResumeThreadError");
2733 assert.nestedPropertyVal(error, "cause.errorMessage", finalError);
2734 assert.nestedPropertyVal(
2735 error,
2736 "cause.method",
2737 failAt === 4 ? "thread/unarchive" : "thread/resume",
2738 );
2739 assert.nestedPropertyVal(error, "cause.requestId", String(failAt));
2740 return;
2741 }
2742 const resumed = yield* resume;
2743 assert.equal(resumed.id, harness.providerThread.id);
2744 assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId);
2745 assert.deepEqual(
2746 resumed.nativeConversationHeadRef,
2747 harness.providerThread.nativeConversationHeadRef,
2748 );
2749 assert.equal(resumed.status, "idle");
2750 assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);
2751 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
2752 ),
2753 );
2754
2600 it.effect("continues an interrupted native thread with empty input and reasoning summaries", () =>2755 it.effect("continues an interrupted native thread with empty input and reasoning summaries", () =>
2601 Effect.scoped(2756 Effect.scoped(
2602 Effect.gen(function* () {2757 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+30-14
...@@ -5429,23 +5429,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi...@@ -5429,23 +5429,39 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
5429 resumeThread: (threadInput) =>5429 resumeThread: (threadInput) =>
5430 Effect.gen(function* () {5430 Effect.gen(function* () {
5431 const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread);5431 const nativeThreadId = yield* getNativeThreadId(threadInput.providerThread);
54325432 // excludeTurns is not in the generated request schema yet.
5433 const resume = client.raw.request("thread/resume", {
5434 threadId: nativeThreadId,
5435 excludeTurns: true,
5436 ...codexThreadRuntimeParams({
5437 threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId,
5438 ...(threadInput.modelSelection === undefined
5439 ? {}
5440 : { modelSelection: threadInput.modelSelection }),
5441 ...(threadInput.runtimePolicy === undefined
5442 ? {}
5443 : { runtimePolicy: threadInput.runtimePolicy }),
5444 }),
5445 });
5433 const response = yield* ensureInitialized.pipe(5446 const response = yield* ensureInitialized.pipe(
5434 Effect.andThen(5447 Effect.andThen(
5435 // excludeTurns is not in the generated request schema yet.5448 resume.pipe(
5436 client.raw.request("thread/resume", {5449 Effect.catchTags({
5437 threadId: nativeThreadId,5450 CodexAppServerRequestError: (cause) => {
5438 excludeTurns: true,5451 if (
5439 ...codexThreadRuntimeParams({5452 !/\bsession \S+ is archived\b|\bcodex unarchive\b/i.test(
5440 threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId,5453 cause.errorMessage,
5441 ...(threadInput.modelSelection === undefined5454 )
5442 ? {}5455 ) {
5443 : { modelSelection: threadInput.modelSelection }),5456 return Effect.fail(cause);
5444 ...(threadInput.runtimePolicy === undefined5457 }
5445 ? {}5458 // Keep the session's history without decoding the unarchive response.
5446 : { runtimePolicy: threadInput.runtimePolicy }),5459 return client.raw
5460 .request("thread/unarchive", { threadId: nativeThreadId })
5461 .pipe(Effect.andThen(resume));
5462 },
5447 }),5463 }),
5448 }),5464 ),
5449 ),5465 ),
5450 Effect.flatMap(decodeCodexResumeMetadata),5466 Effect.flatMap(decodeCodexResumeMetadata),
5451 );5467 );