fix(codex): fail fast after quiescent app-server turns

Fix Codex app-server turns that go quiet after the last non-assistant current-turn item completes without turn/completed.\n\nMaintainer proof: focused watchdog regression passed on rebased head, diff whitespace check passed, and local OpenClaw CLI + WebSocket app-server transport proof observed turn/interrupt after the short idle watchdog.\n\nCo-authored-by: funmerlin <funmerlin@users.noreply.github.com>
This commit is contained in:
Merlin
2026-05-15 17:16:33 +02:00
committed by GitHub
parent bbf50a406e
commit 127156a88a
3 changed files with 105 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<void> = 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",

View File

@@ -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