Skip to content

Commit 2c388da

Browse files
fix(server): Codex rollback works after the app-server restarts (#13787)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4fafbbe commit 2c388da

7 files changed

Lines changed: 320 additions & 16 deletions

File tree

‎apps/server/scripts/record-codex-app-server-replay-fixture.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const SCENARIO_NAMES = [
102102
"turn_interrupt",
103103
"turn_interrupt_mid_tool",
104104
"thread_rollback",
105+
"thread_rollback_after_restart",
105106
"thread_fork_native_continue",
106107
"thread_fork_native_siblings",
107108
"thread_merge_back_continue",
@@ -804,6 +805,41 @@ function scenarios(): ReadonlyArray<ReplayScenario> {
804805
},
805806
],
806807
},
808+
{
809+
name: "thread_rollback_after_restart",
810+
fileName: "thread_rollback_after_restart.ndjson",
811+
description:
812+
"One thread completes two turns, the app-server restarts, then the thread rolls back its latest turn and starts another turn.",
813+
runs: [
814+
{
815+
name: "rollback-after-restart",
816+
description:
817+
"Two completed turns, a fresh app-server that has not loaded the thread, thread/revert before the second turn, then a post-rollback turn.",
818+
steps: [
819+
{
820+
type: "turn",
821+
label: "first-before-rollback",
822+
prompt: THREAD_ROLLBACK_FIRST_PROMPT,
823+
},
824+
{
825+
type: "turn",
826+
label: "second-before-rollback",
827+
prompt: THREAD_ROLLBACK_SECOND_PROMPT,
828+
},
829+
{
830+
type: "rollback",
831+
label: "rollback-latest-turn",
832+
numTurns: 1,
833+
},
834+
{
835+
type: "turn",
836+
label: "post-rollback",
837+
prompt: THREAD_ROLLBACK_AFTER_PROMPT,
838+
},
839+
],
840+
},
841+
],
842+
},
807843
{
808844
name: "thread_fork_native_continue",
809845
fileName: "thread_fork_native_continue.ndjson",
@@ -1416,6 +1452,52 @@ function runReplaySession({
14161452
return;
14171453
}
14181454

1455+
if (scenario.name === "thread_rollback_after_restart") {
1456+
const [first, second, rollback, after] = run.steps;
1457+
if (
1458+
first === undefined ||
1459+
second === undefined ||
1460+
rollback?.type !== "rollback" ||
1461+
after === undefined ||
1462+
first.type === "rollback" ||
1463+
first.type === "fork" ||
1464+
second.type === "rollback" ||
1465+
second.type === "fork" ||
1466+
after.type === "rollback" ||
1467+
after.type === "fork"
1468+
) {
1469+
throw new Error(
1470+
"thread_rollback_after_restart replay recording requires turn, turn, rollback, turn.",
1471+
);
1472+
}
1473+
1474+
const threadId = yield* Effect.gen(function* () {
1475+
const client = yield* initializeClient;
1476+
const thread = yield* client.request("thread/start", threadRuntimeParams);
1477+
yield* runTurnStep(client, thread.thread.id, first);
1478+
yield* runTurnStep(client, thread.thread.id, second);
1479+
return thread.thread.id;
1480+
}).pipe(Effect.provide(makeCodexLayer({ recorder })));
1481+
1482+
yield* recorder.writeRecord({
1483+
type: "runtime_exit",
1484+
status: "success",
1485+
});
1486+
1487+
yield* Effect.gen(function* () {
1488+
const client = yield* initializeClient;
1489+
const resumeParams = { threadId, excludeTurns: true, ...threadRuntimeParams };
1490+
// Like the adapter: the fresh app-server reports the thread notLoaded,
1491+
// so it is resumed before thread/revert, and the next turn resumes it again.
1492+
yield* client.request("thread/read", { threadId, includeTurns: false });
1493+
yield* client.request("thread/resume", resumeParams);
1494+
yield* revertCodexThread(client, threadId, rollback.numTurns);
1495+
yield* client.request("thread/resume", resumeParams);
1496+
yield* runTurnStep(client, threadId, after);
1497+
}).pipe(Effect.provide(makeCodexLayer({ recorder })));
1498+
return;
1499+
}
1500+
14191501
yield* Effect.gen(function* () {
14201502
const client = yield* initializeClient;
14211503
const thread = yield* client.request("thread/start", {

‎apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts‎

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -924,22 +924,32 @@ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBou
924924
const CodexThreadHistoryMetadata = Schema.Struct({
925925
thread: Schema.Struct({
926926
historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])),
927+
status: Schema.optionalKey(Schema.Struct({ type: Schema.String })),
927928
}),
928929
});
929930
const decodeCodexThreadHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata);
930931

931-
const readCodexThreadHistoryMode = Effect.fn("CodexAdapterV2.readThreadHistoryMode")(function* (
932-
raw: Pick<CodexClient.CodexAppServerClient["Service"]["raw"], "request">,
933-
threadId: string,
934-
) {
935-
const response = yield* raw.request("thread/read", { threadId, includeTurns: false });
936-
const metadata = yield* decodeCodexThreadHistoryMetadata(response).pipe(
937-
Effect.mapError((error) =>
938-
CodexErrors.CodexAppServerRequestError.invalidPayload("thread/read", "decode-payload", error),
939-
),
940-
);
941-
return metadata.thread.historyMode;
942-
});
932+
const readCodexThreadHistoryMetadata = Effect.fn("CodexAdapterV2.readThreadHistoryMetadata")(
933+
function* (
934+
raw: Pick<CodexClient.CodexAppServerClient["Service"]["raw"], "request">,
935+
threadId: string,
936+
) {
937+
const response = yield* raw.request("thread/read", { threadId, includeTurns: false });
938+
const metadata = yield* decodeCodexThreadHistoryMetadata(response).pipe(
939+
Effect.mapError((error) =>
940+
CodexErrors.CodexAppServerRequestError.invalidPayload(
941+
"thread/read",
942+
"decode-payload",
943+
error,
944+
),
945+
),
946+
);
947+
return {
948+
historyMode: metadata.thread.historyMode,
949+
loaded: metadata.thread.status?.type !== "notLoaded",
950+
};
951+
},
952+
);
943953

944954
export const resolveCodexRollbackTurnCount = Effect.fn("CodexAdapterV2.resolveRollbackTurnCount")(
945955
function* (input: ProviderAdapterV2RollbackThreadInput) {
@@ -6086,8 +6096,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
60866096
}
60876097
// Codex 0.156 can revert paginated history at a turn boundary.
60886098
// Legacy history no longer has a rollback endpoint.
6089-
const historyMode = yield* ensureInitialized.pipe(
6090-
Effect.andThen(readCodexThreadHistoryMode(client.raw, threadId)),
6099+
const { historyMode, loaded } = yield* ensureInitialized.pipe(
6100+
Effect.andThen(readCodexThreadHistoryMetadata(client.raw, threadId)),
60916101
);
60926102
if (historyMode !== "paginated") {
60936103
return yield* new ProviderAdapterRollbackThreadError({
@@ -6096,6 +6106,20 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
60966106
cause: `Cannot roll back Codex thread ${threadId}: the thread uses legacy history, which Codex 0.156 cannot revert.`,
60976107
});
60986108
}
6109+
// `thread/revert` only acts on a thread loaded in this app-server
6110+
// process. After a restart or idle release, load it the same way
6111+
// the next turn would before reverting.
6112+
if (!loaded) {
6113+
yield* client.raw.request("thread/resume", {
6114+
threadId,
6115+
excludeTurns: true,
6116+
...codexThreadRuntimeParams({
6117+
threadId: threadInput.providerThread.appThreadId,
6118+
modelSelection: input.modelSelection,
6119+
runtimePolicy: input.runtimePolicy,
6120+
}),
6121+
});
6122+
}
60996123
const response = yield* ensureInitialized.pipe(
61006124
Effect.andThen(revertCodexThread(client, threadId, numTurns)),
61016125
);
@@ -6163,8 +6187,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
61636187
// Reached only when the selected source turn has no native
61646188
// turn reference, so the fork had to be taken at head and then
61656189
// trimmed with the paginated history API.
6166-
const historyMode = yield* ensureInitialized.pipe(
6167-
Effect.andThen(readCodexThreadHistoryMode(client.raw, response.thread.id)),
6190+
const { historyMode } = yield* ensureInitialized.pipe(
6191+
Effect.andThen(readCodexThreadHistoryMetadata(client.raw, response.thread.id)),
61686192
);
61696193
if (historyMode !== "paginated") {
61706194
return yield* new ProviderAdapterForkThreadError({

‎apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,22 @@ const scenarioExpectations = {
275275
turnCompletedCount: 3,
276276
approvalRequestCount: 0,
277277
},
278+
thread_rollback_after_restart: {
279+
outgoing: [
280+
"initialize",
281+
"initialized",
282+
"thread/start",
283+
"turn/start",
284+
"thread/read",
285+
"thread/resume",
286+
"thread/turns/list",
287+
"thread/revert",
288+
],
289+
incoming: ["turn/started", "turn/completed", "item/agentMessage/delta", "thread/reverted"],
290+
turnStartCount: 3,
291+
turnCompletedCount: 3,
292+
approvalRequestCount: 0,
293+
},
278294
thread_fork_native_continue: {
279295
outgoing: ["initialize", "initialized", "thread/start", "thread/fork", "turn/start"],
280296
incoming: ["thread/started", "turn/started", "turn/completed", "item/agentMessage/delta"],

‎apps/server/src/orchestration-v2/testkit/fixtures/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ import { assertClaudeThreadRollbackOutput } from "./thread_rollback/claude_outpu
8989
import { assertThreadRollbackOutput } from "./thread_rollback/codex_output.ts";
9090
import { threadRollbackInput } from "./thread_rollback/input.ts";
9191
import { assertPiThreadRollbackOutput } from "./thread_rollback/pi_output.ts";
92+
import { assertThreadRollbackAfterRestartOutput } from "./thread_rollback_after_restart/codex_output.ts";
93+
import { threadRollbackAfterRestartInput } from "./thread_rollback_after_restart/input.ts";
9294
import { threadRollbackAfterStopInput } from "./thread_rollback_after_stop/input.ts";
9395
import { assertPiThreadRollbackAfterStopOutput } from "./thread_rollback_after_stop/pi_output.ts";
9496
import { assertTodoListOutput } from "./todo_list/codex_output.ts";
@@ -1126,6 +1128,21 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray<OrchestratorReplayFixtu
11261128
},
11271129
],
11281130
},
1131+
{
1132+
name: "thread_rollback_after_restart",
1133+
buildInput: threadRollbackAfterRestartInput,
1134+
providers: [
1135+
{
1136+
driver: ProviderDriverKind.make("codex"),
1137+
transcriptFile: new URL(
1138+
"./thread_rollback_after_restart/codex_transcript.ndjson",
1139+
import.meta.url,
1140+
),
1141+
modelSelection: CODEX_MODEL_SELECTION,
1142+
assertOutput: assertThreadRollbackAfterRestartOutput,
1143+
},
1144+
],
1145+
},
11291146
{
11301147
name: "thread_rollback_after_stop",
11311148
buildInput: threadRollbackAfterStopInput,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { assert } from "@effect/vitest";
2+
import type { ProviderReplayTranscript } from "@t3tools/contracts";
3+
4+
import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
5+
import { assertThreadRollbackOutput } from "../thread_rollback/codex_output.ts";
6+
7+
/**
8+
* Codex only reverts threads loaded in its current app-server process, so the
9+
* released session's successor must resume the thread before `thread/revert`.
10+
*/
11+
export function assertThreadRollbackAfterRestartOutput(
12+
result: OrchestratorV2ScenarioResult,
13+
transcript: ProviderReplayTranscript,
14+
) {
15+
assertThreadRollbackOutput(result, transcript);
16+
const methods = transcript.entries.flatMap((entry) =>
17+
entry.type === "expect_outbound" &&
18+
typeof entry.frame === "object" &&
19+
entry.frame !== null &&
20+
"method" in entry.frame
21+
? [String(entry.frame.method)]
22+
: [],
23+
);
24+
const revert = methods.indexOf("thread/revert");
25+
assert.isAbove(revert, 0, "the rollback must reach thread/revert");
26+
assert.equal(
27+
methods.lastIndexOf("thread/resume", revert),
28+
revert - 2,
29+
"the fresh app-server must resume the thread right before listing turns and reverting",
30+
);
31+
}

0 commit comments

Comments
 (0)