Skip to content

Commit 1dbd9a3

Browse files
authored
fix(codex): avoid false queued terminal idle timeout (#87096)
1 parent bfddd45 commit 1dbd9a3

2 files changed

Lines changed: 76 additions & 0 deletions

File tree

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5618,6 +5618,70 @@ describe("runCodexAppServerAttempt", () => {
56185618
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
56195619
});
56205620

5621+
it("does not idle-timeout when terminal completion queues behind projection", async () => {
5622+
const harness = createStartedThreadHarness();
5623+
const params = createParams(
5624+
path.join(tempDir, "session.jsonl"),
5625+
path.join(tempDir, "workspace"),
5626+
);
5627+
params.timeoutMs = 120;
5628+
const turnStartProgressEvents: DiagnosticEventPayload[] = [];
5629+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
5630+
if (event.type === "run.progress" && event.reason === "codex_app_server:turn:start") {
5631+
turnStartProgressEvents.push(event);
5632+
}
5633+
});
5634+
let resolveReasoningStarted!: () => void;
5635+
const reasoningStarted = new Promise<void>((resolve) => {
5636+
resolveReasoningStarted = resolve;
5637+
});
5638+
let releaseProjection!: () => void;
5639+
const projectionGate = new Promise<void>((resolve) => {
5640+
releaseProjection = resolve;
5641+
});
5642+
params.onReasoningStream = async () => {
5643+
resolveReasoningStarted();
5644+
await projectionGate;
5645+
};
5646+
5647+
let settled = false;
5648+
const run = runCodexAppServerAttempt(params, {
5649+
turnCompletionIdleTimeoutMs: 5,
5650+
turnTerminalIdleTimeoutMs: 5,
5651+
}).finally(() => {
5652+
settled = true;
5653+
});
5654+
await harness.waitForMethod("turn/start");
5655+
await vi.waitFor(() => expect(turnStartProgressEvents).toHaveLength(2), { interval: 1 });
5656+
stopDiagnostics();
5657+
5658+
const blockedProjection = harness.notify({
5659+
method: "item/reasoning/textDelta",
5660+
params: {
5661+
threadId: "thread-1",
5662+
turnId: "turn-1",
5663+
itemId: "reasoning-1",
5664+
delta: "thinking",
5665+
},
5666+
});
5667+
void blockedProjection.catch(() => undefined);
5668+
await reasoningStarted;
5669+
5670+
const queuedTerminal = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
5671+
void queuedTerminal.catch(() => undefined);
5672+
await new Promise((resolve) => setTimeout(resolve, 30));
5673+
5674+
expect(settled).toBe(false);
5675+
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
5676+
5677+
releaseProjection();
5678+
await queuedTerminal;
5679+
const result = await run;
5680+
expect(result.aborted).toBe(false);
5681+
expect(result.timedOut).toBe(false);
5682+
expect(result.promptError).toBeNull();
5683+
});
5684+
56215685
it("releases the session when a completed agent message item goes quiet", async () => {
56225686
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
56235687
const request = vi.fn(async (method: string) => {

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,7 @@ export async function runCodexAppServerAttempt(
20142014
const fireTurnCompletionIdleTimeout = () => {
20152015
if (
20162016
completed ||
2017+
terminalTurnNotificationQueued ||
20172018
runAbortController.signal.aborted ||
20182019
!turnCompletionIdleWatchArmed ||
20192020
activeAppServerTurnRequests > 0
@@ -2053,6 +2054,7 @@ export async function runCodexAppServerAttempt(
20532054
const fireTurnTerminalIdleTimeout = () => {
20542055
if (
20552056
completed ||
2057+
terminalTurnNotificationQueued ||
20562058
runAbortController.signal.aborted ||
20572059
!turnTerminalIdleWatchArmed ||
20582060
activeAppServerTurnRequests > 0
@@ -2581,6 +2583,16 @@ export async function runCodexAppServerAttempt(
25812583
if (isTerminalTurnNotificationForTurn(notification, turnId)) {
25822584
terminalTurnNotificationQueued = true;
25832585
}
2586+
// Touch idle-watch timestamps at receive time, not just after queued
2587+
// projection. A queued terminal event should suppress short false-idle
2588+
// guards, while the full attempt watchdog still releases a wedged queue.
2589+
if (correlation.matchesActiveTurn !== false) {
2590+
const now = Date.now();
2591+
turnCompletionLastActivityAt = now;
2592+
turnCompletionLastActivityReason = `notification:${notification.method}`;
2593+
turnAttemptLastProgressAt = now;
2594+
turnAttemptLastProgressReason = `notification:${notification.method}`;
2595+
}
25842596
notificationQueue = notificationQueue.then(
25852597
() => handleNotification(notification),
25862598
() => handleNotification(notification),

0 commit comments

Comments
 (0)