Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
114 changes: 114 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
> => event.type === "subagent.updated",
);
return {
adapter,
runtime,
providerThread,
threadId,
Expand Down Expand Up @@ -1146,6 +1147,119 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);

it.effect.each([
{
code: "unauthorized",
message:
"Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
stale: true,
},
{
code: "unauthorized",
message:
"Error running remote compact task: Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
stale: true,
},
{
code: "unauthorized",
message:
"Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.",
stale: false,
},
])(
"marks only external account mismatch for idle recovery: $message",
({ code, message, stale }) =>
Effect.scoped(
Effect.gen(function* () {
const nativeThreadId = "auth-change-thread";
const nativeTurnId = "auth-change-turn";
const error = { message, codexErrorInfo: code, additionalDetails: null };
const harness = yield* makeCodexReplayHarness(
makeCodexReplayTranscript({
scenario: "external-auth-change",
entries: [
...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "hello" }),
{
type: "emit_inbound",
label: "error",
frame: {
method: "error",
params: {
threadId: nativeThreadId,
turnId: nativeTurnId,
error,
willRetry: false,
},
},
},
{
type: "emit_inbound",
label: "failed",
frame: {
method: "turn/completed",
params: {
threadId: nativeThreadId,
turn: {
...makeCodexReplayTurn({ id: nativeTurnId, status: "failed" }),
error,
},
},
},
},
],
}),
);
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("auth-change-attempt"),
text: "hello",
}),
);
yield* harness.firstTerminal;
assert.equal(yield* harness.runtime.hasStaleCredentials ?? Effect.succeed(false), stale);
assert.equal(harness.terminalEvents()[0]?.status, "failed");
assert.include(harness.terminalEvents()[0]?.failure?.message, message);
if (stale) {
const replacement = yield* harness.adapter.openSession({
threadId: harness.threadId,
providerSessionId: ProviderSessionId.make("auth-change-replacement"),
modelSelection: CODEX_TEST_MODEL_SELECTION,
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
});
const replacementThread = yield* replacement.ensureThread({
threadId: harness.threadId,
modelSelection: CODEX_TEST_MODEL_SELECTION,
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
});
const failedAgain = yield* replacement.events.pipe(
Stream.filter((event) => event.type === "turn.terminal"),
Stream.runHead,
Effect.forkChild({ startImmediately: true }),
);
yield* replacement.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: replacementThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("auth-change-replacement-attempt"),
text: "hello",
}),
);
yield* Fiber.join(failedAgain);
assert.isFalse(yield* replacement.hasStaleCredentials ?? Effect.succeed(false));
assert.include(harness.terminalEvents()[0]?.failure?.message, "run codex login");
}
}),
).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect("adds the session MCP server without replacing launch-argument servers", () =>
Effect.gen(function* () {
const scenario = "launch-mcp-servers";
Expand Down
56 changes: 54 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,22 @@ function toProtocolError(
});
}

// Codex's guarded refresh refuses a different on-disk account; account/read cannot reload it.
function isCodexAccountMismatch(message: string, code: string | null | undefined): boolean {
return (
code === "unauthorized" &&
message.includes(
"Your access token could not be refreshed because you have since logged out or signed in to another account.",
)
);
}

function codexAuthRecoveryMessage(message: string | undefined, code: string | null | undefined) {
return message !== undefined && isCodexAccountMismatch(message, code)
? `${message} Let other Codex turns and background work finish, then send your message again. If it still fails, run codex login in this provider's home and restart T3 when Codex is idle.`
: message;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
function normalizeCodexCause(error: unknown): unknown {
return error;
}
Expand Down Expand Up @@ -1703,6 +1719,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId);
const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions;
const continuationRequests = adapterOptions.continuationRequests;
// A replacement must complete a turn before another auth restart; broken saved auth must not loop.
const authRecoveryAvailable = yield* Ref.make(true);

return ProviderAdapter.ProviderAdapterV2.of({
instanceId: adapterOptions.instanceId,
Expand All @@ -1725,6 +1743,19 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
const staleCredentials = yield* Ref.make(false);
const recordAuthFailure = Effect.fn("CodexAdapterV2.recordAuthFailure")(function* (
message: string,
code: string | null,
) {
if (
resolvedRuntime === undefined &&
isCodexAccountMismatch(message, code) &&
(yield* Ref.getAndSet(authRecoveryAvailable, false))
) {
yield* Ref.set(staleCredentials, true);
}
});
const client = yield* clientFactory.open({
instanceId: adapterOptions.instanceId,
threadId: input.threadId,
Expand Down Expand Up @@ -4516,6 +4547,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (

yield* client.handleServerNotification("error", (payload) =>
Effect.gen(function* () {
if (!payload.willRetry) {
yield* recordAuthFailure(
payload.error.message,
codexErrorInfoCode(payload.error.codexErrorInfo),
);
}
const context = yield* awaitActiveTurn(payload.turnId);
if (context === undefined) {
return;
Expand All @@ -4527,7 +4564,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
nativeCode: notificationCode,
failure: withCodexManagedFailure(
makeProviderFailure({
message: payload.error.additionalDetails?.trim() || payload.error.message,
message: codexAuthRecoveryMessage(
payload.error.additionalDetails?.trim() || payload.error.message,
notificationCode,
),
code: notificationCode,
class: codexUsageLimitError(notificationCode, payload.error.codexErrorInfo)
? "usage_limit"
Expand Down Expand Up @@ -5629,7 +5669,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
? previousFailure.failure
: withCodexManagedFailure(
makeProviderFailure({
message: input.failureMessage,
message: codexAuthRecoveryMessage(input.failureMessage, failureCode),
code: failureCode,
class: failureClass,
}),
Expand Down Expand Up @@ -6122,6 +6162,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (

yield* client.handleServerNotification("turn/completed", (payload) =>
Effect.gen(function* () {
if (payload.turn.error != null) {
yield* recordAuthFailure(
payload.turn.error.message,
codexErrorInfoCode(payload.turn.error.codexErrorInfo),
);
}
if (payload.turn.status === "completed" && !(yield* Ref.get(staleCredentials))) {
yield* Ref.set(authRecoveryAvailable, true);
}
const context = (yield* Ref.get(activeTurns)).get(payload.turn.id);
if (context === undefined) {
return;
Expand Down Expand Up @@ -6552,6 +6601,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
providerSessionId: input.providerSessionId,
providerSession: session,
events: Stream.fromEffectRepeat(Queue.take(events)),
...(resolvedRuntime === undefined
? { hasStaleCredentials: Ref.get(staleCredentials) }
: {}),
canReuseContextUsage: canReuseCodexContextUsage,
// Idle release can beat delayed subagent resumes: Codex reports them completed between turns with no resume-expected signal.
hasPendingBackgroundWork: Effect.gen(function* () {
Expand Down
Loading
Loading