From ea16a5e9e10c5b2be28ed46ea77ba5a7aa787d8c Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Fri, 15 May 2026 16:53:10 -0500 Subject: [PATCH] fix(codex): yield app-server notification projection (#82333) * fix(codex): yield app-server notification projection * docs(changelog): note codex notification yield fix --- CHANGELOG.md | 1 + .../codex/src/app-server/run-attempt.test.ts | 31 +++++++++++++++++-- .../codex/src/app-server/run-attempt.ts | 12 +++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2918d854bc..49b58720054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - LINE: stop cron recovery from inferring lowercased LINE recipients from canonical session keys, so long-running task replies do not silently retry undeliverable push targets. Fixes #81628. (#81704) Thanks @edenfunf. - TTS: preserve channel-derived voice-note delivery for `/tts audio` replies even when the provider output is not natively voice-compatible. (#82174) Thanks @xuruiray. - Codex app-server: preserve inbound sender metadata and source-channel provenance on mirrored user prompts, including failure snapshots, so channel history keeps the original sender identity. (#82184) Thanks @zknicker. +- Codex app-server: yield projector work to the event loop between embedded-run notifications while preserving pre-turn rate-limit capture, reducing gateway stalls from account and MCP status notifications. Fixes #81936. (#82333) Thanks @joshavant. - Codex account/status: treat metadata-only rate-limit buckets as returned but empty so `/codex status` and `/codex account` report `none returned` instead of counting phantom limits. - Codex/Lossless: keep Codex explicit compaction on native app-server threads while allowing Lossless through the context-engine slot; `openclaw doctor --fix` now migrates legacy `compaction.provider: "lossless-claw"` config to `plugins.slots.contextEngine`. - Cron/doctor: report scheduled jobs with explicit `payload.model` overrides, including provider namespace counts and default-model mismatches, so stale cron model pins are visible during auth or billing investigations. Fixes #82151. Thanks @mgonto. diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index b69d7ca9b87..99756608e1a 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -39,7 +39,11 @@ import { resolveCodexPluginAppCacheEndpoint, } from "./plugin-app-cache-key.js"; import type { CodexServerNotification } from "./protocol.js"; -import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js"; +import { + readRecentCodexRateLimits, + rememberCodexRateLimits, + resetCodexRateLimitCacheForTests, +} from "./rate-limit-cache.js"; import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl, __testing, @@ -1982,6 +1986,29 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("yields a macrotask before processing queued app-server notifications", async () => { + const harness = createStartedThreadHarness(); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.timeoutMs = 1_000; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + + const notification = rateLimitsUpdated(Date.now() + 60_000); + const processing = harness.notify(notification); + await Promise.resolve(); + + expect(readRecentCodexRateLimits()).toBeUndefined(); + await processing; + expect(readRecentCodexRateLimits()).toEqual(notification.params); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false }); + }); + it("releases the session when a completed agent message item goes quiet", async () => { let notify: (notification: CodexServerNotification) => Promise = async () => undefined; const request = vi.fn(async (method: string) => { @@ -3250,7 +3277,7 @@ describe("runCodexAppServerAttempt", () => { if (!harnessRef.current) { throw new Error("Expected Codex app-server harness to be initialized"); } - await harnessRef.current.notify(rateLimitsUpdated(resetsAt)); + void harnessRef.current.notify(rateLimitsUpdated(resetsAt)); throw Object.assign(new Error("You've reached your usage limit."), { data: { codexErrorInfo: "usageLimitExceeded" }, }); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 1fea97ac4ba..18b4c1fd18a 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1319,6 +1319,7 @@ export async function runCodexAppServerAttempt( isCodexTurnAbortMarkerNotification(notification, { currentPromptText: promptBuild.prompt }); const isTurnTerminal = isTurnCompletion || isTurnAbortMarker; try { + await waitForCodexNotificationDispatchTurn(); await projector.handleNotification(notification); } catch (error) { embeddedAgentLog.debug("codex app-server projector notification threw", { @@ -1342,6 +1343,11 @@ export async function runCodexAppServerAttempt( } }; const enqueueNotification = (notification: CodexServerNotification): Promise => { + if (!projector || !turnId) { + userInputBridge?.handleNotification(notification); + pendingNotifications.push(notification); + return Promise.resolve(); + } notificationQueue = notificationQueue.then( () => handleNotification(notification), () => handleNotification(notification), @@ -3255,6 +3261,12 @@ function prependCurrentTurnContext( return text ? [text, prompt].filter(Boolean).join("\n\n") : prompt; } +function waitForCodexNotificationDispatchTurn(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + function handleApprovalRequest(params: { method: string; params: JsonValue | undefined;