mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
fix(codex): recover replies after compaction failure (#125895)
Preserve completed tool work when native Codex compaction fails, close failed compaction progress, and bypass unrelated model/auth failover before isolated finalization. Fixes #125789.
This commit is contained in:
committed by
GitHub
parent
6988551b87
commit
d204ebe6a4
@@ -3,9 +3,11 @@ import {
|
||||
registerCodexEventProjectorTestLifecycle,
|
||||
expect,
|
||||
it,
|
||||
THREAD_ID,
|
||||
TURN_ID,
|
||||
createProjector,
|
||||
buildEmptyToolTelemetry,
|
||||
createParams,
|
||||
readAttemptTerminal,
|
||||
expectUsageLimitPromptError,
|
||||
forCurrentTurn,
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
turnCompleted,
|
||||
turnWithStatus,
|
||||
pendingCommandStarted,
|
||||
vi,
|
||||
} from "./event-projector.test-harness.js";
|
||||
|
||||
registerCodexEventProjectorTestLifecycle();
|
||||
@@ -202,6 +205,7 @@ describe("CodexAppServerEventProjector terminal errors", () => {
|
||||
{ codexErrorInfo: "serverOverloaded", expected: true },
|
||||
{ codexErrorInfo: "usageLimitExceeded", expected: false },
|
||||
{ codexErrorInfo: "unauthorized", expected: false },
|
||||
{ codexErrorInfo: "other", expected: false },
|
||||
])(
|
||||
"projects $codexErrorInfo terminal error recovery eligibility as $expected",
|
||||
async ({ codexErrorInfo, expected }) => {
|
||||
@@ -212,9 +216,110 @@ describe("CodexAppServerEventProjector terminal errors", () => {
|
||||
);
|
||||
|
||||
expect(projector.settledTurnFailureFinalizationAllowed).toBe(expected);
|
||||
expect(
|
||||
readAttemptTerminal(projector.buildResult(buildEmptyToolTelemetry())).promptErrorSource,
|
||||
).toBe("prompt");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an active native compaction failure scoped through the failed turn", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const onContextCompacted = vi.fn();
|
||||
const projector = await createProjector(
|
||||
{ ...(await createParams()), onAgentEvent },
|
||||
{ onContextCompacted },
|
||||
);
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: { type: "contextCompaction", id: "compact-failed" },
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
appServerError({
|
||||
message: "remote compaction failed",
|
||||
willRetry: false,
|
||||
codexErrorInfo: "other",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readAttemptTerminal(projector.buildResult(buildEmptyToolTelemetry()))).toMatchObject({
|
||||
promptError: "remote compaction failed",
|
||||
promptErrorSource: "compaction",
|
||||
});
|
||||
expect(projector.settledTurnFailureFinalizationAllowed).toBe(true);
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("turn/completed", {
|
||||
turn: {
|
||||
id: TURN_ID,
|
||||
status: "failed",
|
||||
error: { message: "remote compaction failed", codexErrorInfo: "other" },
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
expect(readAttemptTerminal(result)).toMatchObject({
|
||||
promptError: "remote compaction failed",
|
||||
promptErrorSource: "compaction",
|
||||
});
|
||||
expect(projector.settledTurnFailureFinalizationAllowed).toBe(true);
|
||||
expect(projector.isCompacting()).toBe(false);
|
||||
expect(result.itemLifecycle).toEqual({ startedCount: 0, completedCount: 0, activeCount: 0 });
|
||||
expect(result.compactionCount).toBeUndefined();
|
||||
expect(onContextCompacted).not.toHaveBeenCalled();
|
||||
expect(
|
||||
onAgentEvent.mock.calls
|
||||
.map(([event]) => event)
|
||||
.filter((event) => event.stream === "compaction"),
|
||||
).toEqual([
|
||||
{
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "start",
|
||||
backend: "codex-app-server",
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
itemId: "compact-failed",
|
||||
},
|
||||
},
|
||||
{
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "end",
|
||||
backend: "codex-app-server",
|
||||
completed: false,
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
itemId: "compact-failed",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps other errors prompt-scoped after native compaction completes", async () => {
|
||||
const projector = await createProjector();
|
||||
const compaction = { item: { type: "contextCompaction", id: "compact-completed" } };
|
||||
|
||||
await projector.handleNotification(forCurrentTurn("item/started", compaction));
|
||||
await projector.handleNotification(forCurrentTurn("item/completed", compaction));
|
||||
await projector.handleNotification(
|
||||
appServerError({
|
||||
message: "unrelated provider failure",
|
||||
willRetry: false,
|
||||
codexErrorInfo: "other",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readAttemptTerminal(projector.buildResult(buildEmptyToolTelemetry()))).toMatchObject({
|
||||
promptError: "unrelated provider failure",
|
||||
promptErrorSource: "prompt",
|
||||
});
|
||||
expect(projector.settledTurnFailureFinalizationAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Codex rate-limit resets for usage-limit app-server errors", async () => {
|
||||
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
|
||||
const projector = await createProjector(undefined, {
|
||||
|
||||
@@ -301,17 +301,19 @@ export class CodexAppServerEventProjector {
|
||||
case "rawResponseItem/completed":
|
||||
await this.handleRawResponseItemCompleted(params);
|
||||
break;
|
||||
case "error":
|
||||
case "error": {
|
||||
this.responseCompletions.clear();
|
||||
if (params.willRetry === true) {
|
||||
break;
|
||||
}
|
||||
const codexErrorInfo = isJsonObject(params.error) ? params.error.codexErrorInfo : undefined;
|
||||
const compactionFailure = codexErrorInfo === "other" && this.isCompacting();
|
||||
this.settledTurnFailureFinalizationAllowed =
|
||||
(isJsonObject(params.error) ? params.error.codexErrorInfo : undefined) ===
|
||||
"serverOverloaded";
|
||||
codexErrorInfo === "serverOverloaded" || compactionFailure;
|
||||
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
|
||||
this.promptErrorSource = "prompt";
|
||||
this.promptErrorSource = compactionFailure ? "compaction" : "prompt";
|
||||
break;
|
||||
}
|
||||
case "thread/compacted":
|
||||
case "turn/started":
|
||||
case "turn/diff/updated":
|
||||
@@ -490,17 +492,7 @@ export class CodexAppServerEventProjector {
|
||||
channelId: this.params.messageChannel ?? this.params.messageProvider ?? undefined,
|
||||
},
|
||||
});
|
||||
this.emitAgentEvent({
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "end",
|
||||
backend: "codex-app-server",
|
||||
completed: true,
|
||||
threadId: this.threadId,
|
||||
turnId: this.turnId,
|
||||
itemId,
|
||||
},
|
||||
});
|
||||
this.emitCompactionEnd(itemId, true);
|
||||
}
|
||||
this.toolProgressProjection.recordToolMeta(item);
|
||||
this.toolProgressProjection.rememberCommandAggregateOutputEcho(item);
|
||||
@@ -522,8 +514,13 @@ export class CodexAppServerEventProjector {
|
||||
return;
|
||||
}
|
||||
this.completedTurn = turn;
|
||||
const compactionFailure =
|
||||
turn.status === "failed" &&
|
||||
(this.promptErrorSource === "compaction" ||
|
||||
(turn.error?.codexErrorInfo === "other" && this.isCompacting()));
|
||||
this.settledTurnFailureFinalizationAllowed =
|
||||
turn.status === "failed" && turn.error?.codexErrorInfo === "serverOverloaded";
|
||||
turn.status === "failed" &&
|
||||
(turn.error?.codexErrorInfo === "serverOverloaded" || compactionFailure);
|
||||
if (turn.status !== "completed") {
|
||||
this.responseCompletions.clear();
|
||||
}
|
||||
@@ -536,7 +533,17 @@ export class CodexAppServerEventProjector {
|
||||
this.promptError = usageLimitMessage
|
||||
? createCodexUsageLimitPromptError(usageLimitMessage)
|
||||
: (turn.error?.message ?? "codex app-server turn failed");
|
||||
this.promptErrorSource = "prompt";
|
||||
this.promptErrorSource = compactionFailure ? "compaction" : "prompt";
|
||||
}
|
||||
if (compactionFailure) {
|
||||
// Codex omits item/completed on failure, so the terminal turn must close
|
||||
// every active structural compaction for state and stream consumers.
|
||||
const failedCompactionItemIds = [...this.activeCompactionItemIds];
|
||||
for (const itemId of failedCompactionItemIds) {
|
||||
this.activeItemIds.delete(itemId);
|
||||
this.activeCompactionItemIds.delete(itemId);
|
||||
this.emitCompactionEnd(itemId, false);
|
||||
}
|
||||
}
|
||||
const turnItems = turn.items ?? [];
|
||||
// The final snapshot is authoritative when item notifications were omitted.
|
||||
@@ -574,6 +581,20 @@ export class CodexAppServerEventProjector {
|
||||
await this.reasoningProjection.maybeEndReasoning();
|
||||
}
|
||||
|
||||
private emitCompactionEnd(itemId: string, completed: boolean): void {
|
||||
this.emitAgentEvent({
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "end",
|
||||
backend: "codex-app-server",
|
||||
completed,
|
||||
threadId: this.threadId,
|
||||
turnId: this.turnId,
|
||||
itemId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async emitSnapshotOnlyNativeToolProgress(item: CodexThreadItem): Promise<void> {
|
||||
if (
|
||||
!shouldSynthesizeToolProgressForItem(item) ||
|
||||
|
||||
@@ -4586,6 +4586,83 @@ describe("runCodexAppServerAttempt", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
it("captures settled tool evidence when an active native compaction fails terminally", async () => {
|
||||
const storePath = path.join(tempDir, "settled-compaction-failure.sqlite");
|
||||
const sessionId = "session-settled-compaction-failure";
|
||||
const sessionFile = `agent:main:${sessionId}`;
|
||||
const workspaceDir = path.join(tempDir, "workspace-settled-compaction-failure");
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
await attachSqliteSessionTarget(params, storePath, sessionId);
|
||||
params.prompt = "Finish the task and report the result.";
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify(
|
||||
itemNotification("item/started", {
|
||||
type: "commandExecution",
|
||||
id: "tool-settled",
|
||||
command: "echo completed-work",
|
||||
cwd: workspaceDir,
|
||||
status: "inProgress",
|
||||
}),
|
||||
);
|
||||
await harness.notify(
|
||||
itemNotification("item/completed", {
|
||||
type: "commandExecution",
|
||||
id: "tool-settled",
|
||||
command: "echo completed-work",
|
||||
cwd: workspaceDir,
|
||||
status: "completed",
|
||||
aggregatedOutput: "completed-work\n",
|
||||
exitCode: 0,
|
||||
durationMs: 12,
|
||||
}),
|
||||
);
|
||||
await harness.notify(
|
||||
itemNotification("item/started", { type: "contextCompaction", id: "compact-failed" }),
|
||||
);
|
||||
await harness.notify({
|
||||
method: "error",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
error: {
|
||||
message: "remote compaction failed",
|
||||
codexErrorInfo: "other",
|
||||
additionalDetails: null,
|
||||
},
|
||||
willRetry: false,
|
||||
},
|
||||
});
|
||||
await harness.notify(
|
||||
turnCompleted({
|
||||
id: "turn-1",
|
||||
status: "failed",
|
||||
error: {
|
||||
message: "remote compaction failed",
|
||||
codexErrorInfo: "other",
|
||||
additionalDetails: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await run;
|
||||
|
||||
expect(readAttemptTerminal(result)).toMatchObject({
|
||||
promptError: "remote compaction failed",
|
||||
promptErrorSource: "compaction",
|
||||
});
|
||||
expect(result.itemLifecycle).toEqual({ startedCount: 1, completedCount: 1, activeCount: 0 });
|
||||
expect(result.settledTurnFinalizationContext).toMatchObject({
|
||||
source: "openclaw-transcript",
|
||||
messages: [
|
||||
expect.objectContaining({ role: "user" }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
expect.objectContaining({ role: "toolResult", toolCallId: "tool-settled" }),
|
||||
],
|
||||
});
|
||||
expect(Object.isFrozen(result.settledTurnFinalizationContext?.messages)).toBe(true);
|
||||
});
|
||||
it("preserves every command failure from official app-server events", async () => {
|
||||
const sessionFile = path.join(tempDir, "session-multi-command-failure.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace-multi-command-failure");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { normalizeUsage } from "../../usage.js";
|
||||
import { createUsageAccumulator } from "../usage-accumulator.js";
|
||||
import { recoverEmbeddedRunAttempt } from "./attempt-recovery.js";
|
||||
import { createEmbeddedRunContextRecoveryState } from "./context-recovery-state.js";
|
||||
import { resolveEmbeddedRunAttemptTerminalState } from "./terminal-outcome.js";
|
||||
|
||||
describe("recoverEmbeddedRunAttempt", () => {
|
||||
@@ -96,4 +97,102 @@ describe("recoverEmbeddedRunAttempt", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses prompt failover for an operation-scoped compaction failure", async () => {
|
||||
const promptFailover = vi.fn(async () => {
|
||||
throw new Error("prompt failover must not run");
|
||||
});
|
||||
const assistant = buildEmbeddedRunnerAssistant({
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "tool-read", name: "read", arguments: {} }],
|
||||
});
|
||||
const messagesSnapshot = [
|
||||
assistant,
|
||||
{ role: "toolResult", toolCallId: "tool-read", toolName: "read", isError: false },
|
||||
] as never;
|
||||
const failoverRetryController = {
|
||||
resolveAuthProfileFailureReason: vi.fn(),
|
||||
advanceAuthProfile: vi.fn(),
|
||||
advanceRateLimitAuthProfile: vi.fn(),
|
||||
maybeMarkAuthProfileFailure: vi.fn(),
|
||||
maybeBackoffBeforeOverloadFailover: vi.fn(),
|
||||
};
|
||||
const attempt = makeEmbeddedRunnerAttempt({
|
||||
terminal: {
|
||||
kind: "failed",
|
||||
source: "compaction",
|
||||
error: new Error("unexpected status 404"),
|
||||
},
|
||||
messagesSnapshot,
|
||||
lastAssistant: assistant,
|
||||
currentAttemptAssistant: assistant,
|
||||
settledTurnFinalizationContext: {
|
||||
source: "openclaw-transcript",
|
||||
messages: messagesSnapshot,
|
||||
},
|
||||
replayMetadata: { hadPotentialSideEffects: false, replaySafe: true },
|
||||
currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true },
|
||||
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
|
||||
});
|
||||
const terminalState = resolveEmbeddedRunAttemptTerminalState({ attempt, assistant });
|
||||
|
||||
const recovery = await recoverEmbeddedRunAttempt({
|
||||
runInput: {
|
||||
runParams: {
|
||||
config: {},
|
||||
agentId: "main",
|
||||
sessionId: "session:compaction-failure",
|
||||
runId: "run:compaction-failure",
|
||||
},
|
||||
resolvedSessionKey: "agent:main:compaction-failure",
|
||||
startedAtMs: Date.now(),
|
||||
},
|
||||
preparedRuntime: {
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.6-luna",
|
||||
model: { id: "gpt-5.6-luna" },
|
||||
genericCompactionRecoveryAllowed: false,
|
||||
maybeRefreshRuntimeAuthForAuthError: promptFailover,
|
||||
snapshot: () => ({
|
||||
thinkLevel: "off",
|
||||
agentHarness: { id: "codex" },
|
||||
outerContextTokenMeta: {},
|
||||
lastProfileId: "profile-1",
|
||||
pluginHarnessOwnsTransport: false,
|
||||
}),
|
||||
},
|
||||
normalizedAttempt: {
|
||||
attempt,
|
||||
sessionIdUsed: attempt.sessionIdUsed,
|
||||
attemptAssistant: assistant,
|
||||
currentAttemptAssistant: assistant,
|
||||
currentAttemptCompletedAssistant: undefined,
|
||||
terminalState,
|
||||
setTerminalLifecycleMeta: vi.fn(),
|
||||
attemptCompactionCount: 0,
|
||||
activeErrorContext: { provider: "openai", model: "gpt-5.6-luna" },
|
||||
resolveReplayInvalidForAttempt: () => false,
|
||||
canRestartForLiveSwitch: false,
|
||||
},
|
||||
runtimePlan: { auth: {} },
|
||||
sessionPromptState: { sessionFile: "/tmp/session.jsonl" },
|
||||
failoverRetryController,
|
||||
compactionRuntime: {},
|
||||
contextRecoveryState: createEmbeddedRunContextRecoveryState(),
|
||||
usageAccumulator: createUsageAccumulator(),
|
||||
lastRunPromptUsage: undefined,
|
||||
runtimeAuthRetry: false,
|
||||
codexAppServerRecoveryRetryAvailable: false,
|
||||
codexAppServerRecoveryRetries: 0,
|
||||
lastRetryFailoverReason: null,
|
||||
traceAttempts: [],
|
||||
sessionAgentId: "main",
|
||||
} as never);
|
||||
|
||||
expect(recovery).toEqual({ action: "proceed", shouldSurfaceCodexCompletionTimeout: false });
|
||||
expect(promptFailover).not.toHaveBeenCalled();
|
||||
expect(failoverRetryController.advanceAuthProfile).not.toHaveBeenCalled();
|
||||
expect(failoverRetryController.advanceRateLimitAuthProfile).not.toHaveBeenCalled();
|
||||
expect(failoverRetryController.maybeMarkAuthProfileFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,11 @@ function settledFailedAttempt(): EmbeddedRunAttemptWithReceiptEvidence {
|
||||
{ role: "toolResult", toolCallId: "tool-exec", toolName: "exec", isError: true },
|
||||
] as never;
|
||||
const attempt = makeEmbeddedRunnerAttempt({
|
||||
terminal: {
|
||||
kind: "failed",
|
||||
source: "compaction",
|
||||
error: new Error("native context compaction failed"),
|
||||
},
|
||||
sessionIdUsed: "session-settled",
|
||||
sessionFileUsed: "/tmp/session-settled.jsonl",
|
||||
assistantTexts: [],
|
||||
|
||||
Reference in New Issue
Block a user