Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ import {
type CodexCliErrorClassification,
type CodexCliErrorKind,
} from "@/lib/codex-cli-error-classifier";
import {
rememberCodexErrorReplayKey,
shouldSuppressCodexErrorResizeReplay,
} from "@/lib/codex-error-replay-guard";
import {
DEFAULT_CODEX_ERROR_HANDLING_PREFS,
isCodexAutoContinueErrorKind,
Expand Down Expand Up @@ -1807,6 +1811,7 @@ export default function CodexFlowManagerUI() {
const resumeCompletionGuardByTabRef = useRef<Record<string, number>>({});
const ptyNotificationBuffersRef = useRef<Record<string, string>>({});
const codexErrorScanByTabRef = useRef<Record<string, CodexErrorScanState>>({});
const codexHandledErrorKeysByPtyRef = useRef<Record<string, string[]>>({});
const ptyListenersRef = useRef<Record<string, () => void>>({});
const ptyToTabRef = useRef<Record<string, string>>({});
const hydratedPtyBindingQueueRef = useRef<Array<{ tabId: string; ptyId: string; windowId: string; source: string }>>([]);
Expand Down Expand Up @@ -1910,6 +1915,7 @@ export default function CodexFlowManagerUI() {
}
}
codexErrorScanByTabRef.current = {};
codexHandledErrorKeysByPtyRef.current = {};
if (hydratedPtyBindingTimerRef.current !== null) {
try { window.clearTimeout(hydratedPtyBindingTimerRef.current); } catch {}
hydratedPtyBindingTimerRef.current = null;
Expand Down Expand Up @@ -2808,11 +2814,16 @@ export default function CodexFlowManagerUI() {
}, [notifyLog]);

/**
* 重置指定标签页的 Codex 错误扫描状态,可选择保留自动 continue 尝试次数
* 重置指定标签页的 Codex 错误扫描状态;用户主动发送时同时开启新的错误回放边界
*/
const resetCodexErrorScanForTab = useCallback((tabId: string, source: string, options?: { keepAttempts?: boolean }): void => {
const id = String(tabId || "").trim();
if (!id) return;
if (source === "user-send") {
const ptyId = ptyByTabRef.current[id];
if (ptyId)
delete codexHandledErrorKeysByPtyRef.current[ptyId];
}
const state = codexErrorScanByTabRef.current[id];
if (state && typeof state.autoContinueTimerId === "number") {
try { window.clearTimeout(state.autoContinueTimerId); } catch {}
Expand Down Expand Up @@ -4139,12 +4150,44 @@ export default function CodexFlowManagerUI() {
void playCompletionChime();
}

/**
* 记录 PTY 已经实际处理过的 Codex 错误,供后续缩放重绘去重。
*/
function rememberHandledCodexError(ptyId: string, classification: CodexCliErrorClassification): void {
const id = String(ptyId || "").trim();
if (!id) return;
codexHandledErrorKeysByPtyRef.current[id] = rememberCodexErrorReplayKey(
codexHandledErrorKeysByPtyRef.current[id],
classification,
);
}

/**
* 判断当前识别结果是否是 PTY 缩放重绘产生的已处理错误回放。
*/
function shouldSuppressCodexResizeReplay(
tabId: string,
ptyId: string,
classification: CodexCliErrorClassification,
): boolean {
const suppressed = shouldSuppressCodexErrorResizeReplay({
resizeReplayActive: tm.isPtyResizeReplayWindowActive(tabId),
handledErrorKeys: codexHandledErrorKeysByPtyRef.current[ptyId],
classification,
});
if (suppressed)
notifyLog(`codexError.resizeReplay.suppress tab=${tabId} pty=${ptyId} kind=${classification.kind}`);
return suppressed;
}

/**
* 处理已识别的 Codex CLI 错误,统一落计时失败、通知用户并按设置安排自动 continue。
*/
function handleCodexCliErrorDetected(tabId: string, classification: CodexCliErrorClassification, errorKey: string) {
const id = String(tabId || "").trim();
if (!id) return;
const ptyId = ptyByTabRef.current[id];
if (ptyId) rememberHandledCodexError(ptyId, classification);
const autoContinue = scheduleCodexAutoContinue(id, classification, errorKey);
failAgentTurnTimer(id, classification, autoContinue);

Expand Down Expand Up @@ -4185,6 +4228,8 @@ export default function CodexFlowManagerUI() {
function handleCodexCliReconnectDetected(tabId: string, classification: CodexCliErrorClassification) {
const id = String(tabId || "").trim();
if (!id) return;
const ptyId = ptyByTabRef.current[id];
if (ptyId) rememberHandledCodexError(ptyId, classification);
markAgentTurnReconnecting(id, classification);
if (!codexErrorHandlingPrefsRef.current.notifyReconnectErrors) {
notifyLog(`codexError.reconnecting tab=${id} kind=${classification.kind} notify=0`);
Expand Down Expand Up @@ -4280,6 +4325,8 @@ export default function CodexFlowManagerUI() {
const chunkRuntimeStatus = detectCodexCliRuntimeStatusText(cleanedChunk);
if (chunkRuntimeStatus?.phase === "idle") {
const idleClassification = classifyCodexCliErrorText(cleanedChunk);
if (idleClassification?.phase === "final" && shouldSuppressCodexResizeReplay(tabId, ptyId, idleClassification))
return;
if (typeof previous.pendingFinalErrorTimerId === "number") {
try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {}
}
Expand Down Expand Up @@ -4362,6 +4409,8 @@ export default function CodexFlowManagerUI() {
reconnectAttempt: reconnectClassification.reconnectAttempt || chunkRuntimeStatus.reconnectAttempt,
reconnectMaxAttempts: reconnectClassification.reconnectMaxAttempts || chunkRuntimeStatus.reconnectMaxAttempts,
};
if (shouldSuppressCodexResizeReplay(tabId, ptyId, normalizedReconnectClassification))
return;
const reconnectErrorKey = buildCodexCliErrorKey(normalizedReconnectClassification);
restoreFailedAgentTurnToReconnecting(tabId, normalizedReconnectClassification, "pty-runtime-status");
const restoredScanState = codexErrorScanByTabRef.current[tabId] || previous;
Expand Down Expand Up @@ -4413,15 +4462,17 @@ export default function CodexFlowManagerUI() {
if (chunkRuntimeStatus?.phase === "reconnecting" && previous.pendingFinalError && previous.pendingFinalErrorKey) {
const pendingAgeMs = Math.max(0, now - Number(previous.pendingFinalErrorAt || now));
if (pendingAgeMs <= CODEX_RECONNECT_AFTER_ERROR_GRACE_MS) {
if (typeof previous.pendingFinalErrorTimerId === "number") {
try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {}
}
const reconnectClassification: CodexCliErrorClassification = {
...previous.pendingFinalError,
phase: "reconnecting",
reconnectAttempt: chunkRuntimeStatus.reconnectAttempt,
reconnectMaxAttempts: chunkRuntimeStatus.reconnectMaxAttempts,
};
if (shouldSuppressCodexResizeReplay(tabId, ptyId, reconnectClassification))
return;
if (typeof previous.pendingFinalErrorTimerId === "number") {
try { window.clearTimeout(previous.pendingFinalErrorTimerId); } catch {}
}
const reconnectErrorKey = buildCodexCliErrorKey(reconnectClassification);
codexErrorScanByTabRef.current[tabId] = {
...latestScanState,
Expand Down Expand Up @@ -4457,6 +4508,8 @@ export default function CodexFlowManagerUI() {
return;
}

if (shouldSuppressCodexResizeReplay(tabId, ptyId, classification))
return;
const errorKey = buildCodexCliErrorKey(classification);
if (classification.phase === "reconnecting") {
if (typeof previous.pendingFinalErrorTimerId === "number") {
Expand Down Expand Up @@ -4650,7 +4703,13 @@ export default function CodexFlowManagerUI() {
ptyNotificationBuffersRef.current[ptyId] = buffer;
}

function unregisterPtyListener(ptyId: string | undefined | null) {
/**
* 解除 PTY 输出监听;仅在会话确定关闭时清除其错误回放历史。
*/
function unregisterPtyListener(
ptyId: string | undefined | null,
options?: { forgetCodexErrorHistory?: boolean },
): void {
if (!ptyId) return;
const tabId = ptyToTabRef.current[ptyId];
const off = ptyListenersRef.current[ptyId];
Expand All @@ -4662,6 +4721,8 @@ export default function CodexFlowManagerUI() {
if (tabId) {
resetCodexErrorScanForTab(tabId, "pty-unregister");
}
if (options?.forgetCodexErrorHistory)
delete codexHandledErrorKeysByPtyRef.current[ptyId];
delete ptyToTabRef.current[ptyId];
notifyLog(`unregisterPtyListener pty=${ptyId}`);
}
Expand Down Expand Up @@ -7251,7 +7312,7 @@ export default function CodexFlowManagerUI() {
const ptyId = ptyByTabRef.current[tabId];
if (ptyId) {
try { window.host.pty.close(ptyId); } catch {}
unregisterPtyListener(ptyId);
unregisterPtyListener(ptyId, { forgetCodexErrorHistory: true });
delete ptyByTabRef.current[tabId];
}
delete ptyAliveRef.current[tabId];
Expand Down Expand Up @@ -8187,7 +8248,7 @@ export default function CodexFlowManagerUI() {
setPtyAlive((m) => ({ ...m, [tabId]: false }));
delete ptyByTabRef.current[tabId];
setPtyByTab((m) => { const n = { ...m }; delete n[tabId]; return n; });
unregisterPtyListener(id);
unregisterPtyListener(id, { forgetCodexErrorHistory: true });
} catch {}
})
: () => {};
Expand Down Expand Up @@ -8514,7 +8575,7 @@ export default function CodexFlowManagerUI() {
if (pid) {
try { window.host.pty.close(pid); } catch {}
delete ptyByTabRef.current[tabId];
unregisterPtyListener(pid);
unregisterPtyListener(pid, { forgetCodexErrorHistory: true });
}
delete tabExecEnvByTabRef.current[tabId];
delete geminiWindowsEditorReadyByTabRef.current[tabId];
Expand Down
19 changes: 19 additions & 0 deletions web/src/lib/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const GEMINI_WSL_EDITOR_TRIGGER_DISPATCH_THRESHOLD_MS = 2500;
const TERMINAL_PTY_RESIZE_STABLE_DELAY_MS = 220;
const TERMINAL_PTY_RESIZE_MIN_INTERVAL_MS = 320;
const TERMINAL_PTY_RESIZE_MAX_DEFER_MS = 1200;
const TERMINAL_PTY_RESIZE_REPLAY_GUARD_MS = 2500;
const TERMINAL_RESIZE_DEBOUNCE_MS = 150;
const TERMINAL_WINDOW_RESIZE_FAST_DEBOUNCE_MS = 220;
const TERMINAL_WINDOW_RESIZE_STABLE_DEBOUNCE_MS = 1280;
Expand Down Expand Up @@ -503,6 +504,24 @@ export default class TerminalManager {
return !!this.getActiveWindowResizeSession(tabId);
}

/**
* 判断 PTY 是否正处于缩放或刚完成缩放,用于识别终端全屏重绘产生的历史输出回放。
* @param tabId tab 标识
*/
isPtyResizeReplayWindowActive(tabId: string): boolean {
const id = String(tabId || "").trim();
if (!id) return false;
if (this.isWindowResizeSessionActive(id)) return true;
if (typeof this.pendingTimerByTab[id] === "number") return true;
if (typeof this.pendingPtyResizeTimerByTab[id] === "number") return true;
if (typeof this.pendingPtyResizeSinceByTab[id] === "number") return true;
if (this.resizePtyPauseByTab[id]) return true;

const lastPtyResizeAt = this.lastPtyResizeAtByTab[id];
if (typeof lastPtyResizeAt !== "number") return false;
return Math.max(0, this.nowMs() - lastPtyResizeAt) <= TERMINAL_PTY_RESIZE_REPLAY_GUARD_MS;
}

/**
* 判断窗口 resize 会话是否需要等待稳定样本,避免 restore/shrink 中间尺寸逐段下发。
* @param session 窗口 resize 会话
Expand Down
17 changes: 17 additions & 0 deletions web/src/lib/codex-cli-error-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ describe("codex-cli-error-classifier(Codex TUI 错误识别)", () => {
expect(result?.reconnectMaxAttempts).toBe(5);
});

it("最后一次 Reconnecting 后进入历史区的 stream disconnected 应识别为最终失败", () => {
const text = `
Reconnecting... 5/5 (5h 16m 19s esc to interrupt)
Stream disconnected before completion: Incomplete response returned, reason: upstream_error
› 继续
■ stream disconnected before completion: Incomplete response returned, reason: upstream_error
› Explain this codebase
`;

const result = classifyCodexCliErrorText(text);
expect(result?.kind).toBe("networkStream");
expect(result?.phase).toBe("final");
expect(result?.explicitFinal).toBe(true);
expect(shouldDelayCodexCliFinalErrorForReconnect(result)).toBe(false);
expect(detectCodexCliRuntimeStatusText(text)?.phase).toBe("idle");
});

it("新的 Working 状态会压住旧的 Reconnecting 错误", () => {
const text = `
Reconnecting... 1/5 (4m 18s esc to interrupt)
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/codex-cli-error-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const RECONNECTING_STATUS_PATTERN = /Reconnecting\.\.\.\s+(\d+)\/(\d+)/gi;
const WORKING_STATUS_PATTERN = /\bWorking\s*\(/gi;
const CODEX_HISTORY_LINE_MARKER_PATTERN = /(?:^|\n)[^\S\n]*[■▪]\s+/gu;
const CODEX_FINAL_HISTORY_LINE_PATTERN =
/^(?:unexpected status\s+\d{3}|exceeded retry limit|Conversation interrupted|Goal budget reached|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|selected model is at capacity|model is at capacity|currently experiencing high demand|400\s+Bad Request|403\s+Forbidden|413\s+(?:Payload Too Large|Request Entity Too Large)|sign in with ChatGPT)\b/i;
/^(?:unexpected status\s+\d{3}|exceeded retry limit|stream disconnected before completion|Conversation interrupted|Goal budget reached|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|selected model is at capacity|model is at capacity|currently experiencing high demand|400\s+Bad Request|403\s+Forbidden|413\s+(?:Payload Too Large|Request Entity Too Large)|sign in with ChatGPT)\b/i;
const EXPLICIT_FINAL_ERROR_PATTERN =
/(?:exceeded retry limit|selected model is at capacity|you['’]ve hit your usage limit|usage limit(?:ed)?(?:\s+reached)?|currently experiencing high demand|400\s+Bad Request|<h1>\s*400\s+Bad Request\s*<\/h1>|unexpected status\s+413\b|413\s+Payload Too Large|413\s+Request Entity Too Large|<h1>\s*413\s+(?:Payload Too Large|Request Entity Too Large)\s*<\/h1>)/i;
const SOURCE_LOCATION_LINE_PATTERN = /^(?:(?:[A-Za-z]:)?[./\\\w@ -]+\.(?:ts|tsx|js|jsx|json|cjs|mjs|md|css|scss|html|yml|yaml|txt|cs|shader|uxml|uss):\d+(?::\d+)?:)/i;
Expand Down
101 changes: 101 additions & 0 deletions web/src/lib/codex-error-replay-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
classifyCodexCliErrorText,
type CodexCliErrorClassification,
} from "./codex-cli-error-classifier";
import {
CODEX_ERROR_REPLAY_HISTORY_LIMIT,
buildCodexErrorReplayKey,
rememberCodexErrorReplayKey,
shouldSuppressCodexErrorResizeReplay,
} from "./codex-error-replay-guard";

const FINAL_ERROR: CodexCliErrorClassification = {
kind: "networkStream",
severity: "temporary",
retryable: true,
matchedText: "Stream disconnected before completion: Upstream request failed",
phase: "final",
};

describe("codex-error-replay-guard(Codex 缩放错误回放防护)", () => {
it("真实分类结果会把同一错误的重连态与最终态归为同一特征", () => {
const reconnectingError = classifyCodexCliErrorText(`
Reconnecting... 4/5 (2m 10s esc to interrupt)
Stream disconnected before completion: Upstream request failed
`);
const finalError = classifyCodexCliErrorText(`
■ stream disconnected before completion: Upstream request failed
› Continue
`);

expect(reconnectingError?.phase).toBe("reconnecting");
expect(finalError?.phase).toBe("final");
expect(reconnectingError).not.toBeNull();
expect(finalError).not.toBeNull();
expect(buildCodexErrorReplayKey(reconnectingError!)).toBe(buildCodexErrorReplayKey(finalError!));
});

it("错误特征会忽略阶段、大小写、空白和重连次数", () => {
const reconnectingError: CodexCliErrorClassification = {
...FINAL_ERROR,
matchedText: " stream disconnected before completion: upstream request failed ",
phase: "reconnecting",
reconnectAttempt: 4,
reconnectMaxAttempts: 5,
};

expect(buildCodexErrorReplayKey(reconnectingError)).toBe(buildCodexErrorReplayKey(FINAL_ERROR));
});

it("缩放时会屏蔽已经处理过的同一条错误", () => {
const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR);

expect(shouldSuppressCodexErrorResizeReplay({
resizeReplayActive: true,
handledErrorKeys,
classification: FINAL_ERROR,
})).toBe(true);
});

it("缩放时不会屏蔽内容不同的新错误", () => {
const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR);
const newError: CodexCliErrorClassification = {
...FINAL_ERROR,
matchedText: "Stream disconnected before completion: Incomplete response returned",
};

expect(shouldSuppressCodexErrorResizeReplay({
resizeReplayActive: true,
handledErrorKeys,
classification: newError,
})).toBe(false);
});

it("非缩放期间不会屏蔽再次真实出现的同一条错误", () => {
const handledErrorKeys = rememberCodexErrorReplayKey([], FINAL_ERROR);

expect(shouldSuppressCodexErrorResizeReplay({
resizeReplayActive: false,
handledErrorKeys,
classification: FINAL_ERROR,
})).toBe(false);
});

it("错误历史达到上限后只保留最近记录", () => {
let handledErrorKeys: string[] = [];
for (let index = 0; index <= CODEX_ERROR_REPLAY_HISTORY_LIMIT; index++) {
handledErrorKeys = rememberCodexErrorReplayKey(handledErrorKeys, {
...FINAL_ERROR,
matchedText: `error-${index}`,
});
}

expect(handledErrorKeys).toHaveLength(CODEX_ERROR_REPLAY_HISTORY_LIMIT);
expect(handledErrorKeys).not.toContain(buildCodexErrorReplayKey({ ...FINAL_ERROR, matchedText: "error-0" }));
expect(handledErrorKeys).toContain(buildCodexErrorReplayKey({
...FINAL_ERROR,
matchedText: `error-${CODEX_ERROR_REPLAY_HISTORY_LIMIT}`,
}));
});
});
Loading
Loading