diff --git a/CHANGELOG.md b/CHANGELOG.md index b10f68fc560..b5ad551e3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - LINE: acknowledge signed webhook events before agent processing so slow model replies do not cause LINE `request_timeout` delivery failures. Fixes #65375. Thanks @myericho. - 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. +- Codex app-server: keep the short turn-completion idle watchdog armed after the last non-assistant current-turn item completes, so a quiet Codex app-server releases the OpenClaw session lane before the outer attempt timeout. Fixes #82171. (#82172) Thanks @funmerlin. - Gateway/approvals: treat `turnSourceTo` as optional in `canBridgeNoDeviceChatApprovalFromBackend`, matching the existing optional handling of `turnSourceAccountId` and `turnSourceThreadId`. Channels without a recipient concept (webchat, control-ui) leave `turnSourceTo` null on both the approval snapshot and the replay params, so the prior required-string check rejected every backend replay with `APPROVAL_CLIENT_MISMATCH`. Cross-channel replay is still gated by the required `turnSourceChannel` and `sessionKey` checks. Fixes #82132. (#82136) Thanks @ottodeng. - Cron: load runtime plugins before isolated cron model and delivery resolution so external channels can be selected for scheduled runs. (#82111) Thanks @medns. - Twitch: keep gateway accounts running until shutdown instead of treating successful monitor startup as a clean channel exit, preventing immediate auto-restart loops. Fixes #60071. (#81853) Thanks @edenfunf. diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index d8a2c164c54..17d5611a511 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -2458,6 +2458,93 @@ describe("runCodexAppServerAttempt", () => { }); }); + it("times out promptly when the last completed non-assistant current-turn item is not followed by turn completion", async () => { + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-1"); + } + if (method === "turn/start") { + return turnStartResult("turn-1", "inProgress"); + } + return {}; + }); + setCodexAppServerClientFactoryForTest( + async () => + ({ + request, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: () => () => undefined, + }) as never, + ); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.timeoutMs = 200; + + const run = runCodexAppServerAttempt(params, { + turnCompletionIdleTimeoutMs: 5, + turnTerminalIdleTimeoutMs: 60_000, + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()), + { interval: 1 }, + ); + await notify({ + method: "item/started", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "dynamicToolCall", + id: "tool-1", + tool: "sessions_list", + arguments: {}, + status: "inProgress", + }, + }, + }); + await notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "dynamicToolCall", + id: "tool-1", + tool: "sessions_list", + arguments: {}, + status: "completed", + success: true, + contentItems: [], + }, + }, + }); + + await expect(run).resolves.toMatchObject({ + aborted: true, + timedOut: true, + promptError: "codex app-server turn idle timed out waiting for turn/completed", + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith( + "turn/interrupt", + { + threadId: "thread-1", + turnId: "turn-1", + }, + { timeoutMs: 5_000 }, + ), + { interval: 1 }, + ); + }); + it("applies before_prompt_build to Codex developer instructions and turn input", async () => { const beforePromptBuild = vi.fn(async () => ({ systemPrompt: "custom codex system", diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 6bb1b6e5ed6..568212f9eb9 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1258,6 +1258,16 @@ export async function runCodexAppServerAttempt( turnAssistantCompletionIdleWatchArmed && notification.method === "item/completed" && activeTurnItemIds.size === 0; + const trackedDynamicToolCompletion = isTrackedOpenClawDynamicToolCompletionNotification( + notification, + activeOpenClawDynamicToolCallIds, + ); + const shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem = + isCurrentTurnNotification && + notification.method === "item/completed" && + activeTurnItemIds.size === 0 && + !trackedDynamicToolCompletion && + !isCompletedAssistantNotification(notification); if (isCurrentTurnNotification && notification.method === "error") { if (isRetryableErrorNotification(notification.params)) { disarmTurnCompletionIdleWatch(); @@ -1271,6 +1281,11 @@ export async function runCodexAppServerAttempt( armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification)); } else if (unblockedAssistantCompletionRelease) { armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification)); + } else if (shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem) { + // If a non-assistant current-turn item is the last active item and the + // bridge then goes quiet, reset the short completion-idle guard from that + // final completion so the remaining silent-turn gap fails fast. + armTurnCompletionIdleWatch(); } else if ( isCurrentTurnNotification && shouldDisarmAssistantCompletionIdleWatch(notification) @@ -1282,10 +1297,8 @@ export async function runCodexAppServerAttempt( !turnCompletionIdleWatchPinnedByTerminalError && notification.method !== "turn/completed" && isCurrentTurnNotification && - !isTrackedOpenClawDynamicToolCompletionNotification( - notification, - activeOpenClawDynamicToolCallIds, - ) + !trackedDynamicToolCompletion && + !shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem ) { // The short completion-idle watchdog guards blind gaps after Codex // accepts a turn or after OpenClaw hands a turn-scoped request result