From 8c5f25d0e5ed6c8024dc46fc1c3da7574fb73c2b Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 10:10:34 +0000 Subject: [PATCH] fix: use activity timeout for code agent turns --- cmd/hwlab-cloud-api/main.mjs | 7 +- internal/cloud/code-agent-chat.mjs | 64 ++++- .../code-agent-session-registry.test.mjs | 179 ++++++++++++++ internal/cloud/code-agent-trace-store.mjs | 3 + internal/cloud/codex-stdio-session.mjs | 230 +++++++++++++++--- internal/cloud/server.mjs | 1 + scripts/src/code-agent-response-contract.mjs | 4 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 6 +- web/hwlab-cloud-web/app.mjs | 117 +++++++-- web/hwlab-cloud-web/scripts/check.mjs | 6 +- 10 files changed, 563 insertions(+), 54 deletions(-) diff --git a/cmd/hwlab-cloud-api/main.mjs b/cmd/hwlab-cloud-api/main.mjs index 7cef2721..c4e16be0 100644 --- a/cmd/hwlab-cloud-api/main.mjs +++ b/cmd/hwlab-cloud-api/main.mjs @@ -8,6 +8,10 @@ const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS, min: 1000, max: 300000 }); +const codeAgentHardTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_HARD_TIMEOUT_MS, Math.max(600000, codeAgentTimeoutMs * 4), { + min: codeAgentTimeoutMs, + max: 1800000 +}); if (!process.env.HWLAB_IMAGE && commitId) { process.env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`; @@ -18,7 +22,8 @@ if (!process.env.HWLAB_IMAGE_TAG && commitId) { } const server = createCloudApiServer({ - codeAgentTimeoutMs + codeAgentTimeoutMs, + codeAgentHardTimeoutMs }); function parseTimeout(value, fallback, { min, max }) { diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 2ad7a96b..7af5ecc4 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -231,6 +231,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { now: options.now, workspace: options.workspace, timeoutMs: options.timeoutMs, + hardTimeoutMs: options.hardTimeoutMs, model: providerPlan.model, codexStdioManager: options.codexStdioManager, traceRecorder, @@ -246,6 +247,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { now: options.now, workspace: options.workspace, timeoutMs: options.timeoutMs, + hardTimeoutMs: options.hardTimeoutMs, model: providerPlan.model, codexStdioManager: options.codexStdioManager, traceRecorder, @@ -1316,7 +1318,7 @@ function m3IoSkillBlockedBeforeRequestResult({ }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ @@ -1328,6 +1330,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI now, workspace, timeoutMs, + hardTimeoutMs, model, traceRecorder, conversationFacts, @@ -1392,6 +1395,10 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI conversationId, targetUrl: error.targetUrl, timeoutMs: error.timeoutMs, + hardTimeoutMs: error.hardTimeoutMs, + idleMs: error.idleMs, + lastActivityAt: error.lastActivityAt, + waitingFor: error.waitingFor, stage: error.stage, missingTools: error.missingTools, availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) @@ -1409,6 +1416,7 @@ async function callCodexStdioWithM3IoSkillRunner({ now, workspace, timeoutMs, + hardTimeoutMs, model, codexStdioManager, traceRecorder, @@ -1424,6 +1432,7 @@ async function callCodexStdioWithM3IoSkillRunner({ now, workspace, timeoutMs, + hardTimeoutMs, model, codexStdioManager, traceRecorder, @@ -2683,6 +2692,11 @@ function normalizeChatError(error, context = {}) { userMessage: taxonomy.userMessage, retryable: taxonomy.retryable }); + const timeoutMs = numberErrorField(error.timeoutMs ?? context.timeoutMs); + const hardTimeoutMs = numberErrorField(error.hardTimeoutMs ?? context.hardTimeoutMs); + const idleMs = numberErrorField(error.idleMs ?? context.idleMs); + const lastActivityAt = safeIsoLike(error.lastActivityAt ?? context.lastActivityAt); + const waitingFor = redactText(error.waitingFor ?? context.waitingFor ?? ""); const blocker = structuredBlocker({ code, layer: taxonomy.layer, @@ -2702,7 +2716,12 @@ function normalizeChatError(error, context = {}) { conversationId, stage, lastEvent, - elapsedMs + elapsedMs, + timeoutMs, + hardTimeoutMs, + idleMs, + lastActivityAt, + waitingFor }); const normalized = { code, @@ -2721,6 +2740,11 @@ function normalizeChatError(error, context = {}) { capabilityLevel, route, toolName, + timeoutMs, + hardTimeoutMs, + idleMs, + lastActivityAt, + waitingFor, missingConfig: safeMissingConfig(error), blockers }; @@ -2896,6 +2920,18 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "Codex stdio runner 响应超时,输入、sessionId 和 traceId 已保留,可重试。" }, + codex_stdio_idle_timeout: { + layer: "runner", + category: "timeout", + retryable: true, + userMessage: "Codex stdio runner 超过配置时间无新事件,输入、sessionId 和 traceId 已保留,可重试。" + }, + codex_stdio_hard_timeout: { + layer: "runner", + category: "timeout", + retryable: true, + userMessage: "Codex stdio runner 达到硬性总时长上限,输入、sessionId 和 traceId 已保留,可重试。" + }, codex_stdio_canceled: { layer: "runner", category: "canceled", @@ -2941,7 +2977,7 @@ function errorTaxonomy(code, error = {}) { }; } -function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null }) { +function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null, timeoutMs = null, hardTimeoutMs = null, idleMs = null, lastActivityAt = null, waitingFor = null }) { return { code, layer, @@ -2961,6 +2997,11 @@ function structuredBlocker({ code, layer, message, userMessage, retryable, trace stage, lastEvent, elapsedMs, + timeoutMs, + hardTimeoutMs, + idleMs, + lastActivityAt, + waitingFor, blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : [] }; } @@ -2983,7 +3024,12 @@ function normalizeBlockers(blockers, fallback) { conversationId: blocker.conversationId, stage: blocker.stage, lastEvent: blocker.lastEvent, - elapsedMs: blocker.elapsedMs + elapsedMs: blocker.elapsedMs, + timeoutMs: numberErrorField(blocker.timeoutMs), + hardTimeoutMs: numberErrorField(blocker.hardTimeoutMs), + idleMs: numberErrorField(blocker.idleMs), + lastActivityAt: safeIsoLike(blocker.lastActivityAt), + waitingFor: blocker.waitingFor ? redactText(blocker.waitingFor) : null })); if (normalized.length > 0) return normalized; return [{ @@ -3004,6 +3050,16 @@ function safeMissingConfig(error) { ].filter((item) => typeof item === "string" && /^[A-Za-z0-9_./:-]+$/u.test(item)); } +function numberErrorField(value) { + return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null; +} + +function safeIsoLike(value) { + const text = typeof value === "string" ? value.trim() : ""; + if (!text || text.length > 80) return null; + return /^[0-9T:Z.+-]+$/u.test(text) ? text : null; +} + function sanitizeErrorField(key, value) { if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") { return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : []; diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index dd13e042..a5c18428 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1582,6 +1582,64 @@ function createFakeAppServerClient({ calls, responses = ["first stdio reply", "s }; } +function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eventEveryMs = 20, eventCount = 0, completeAfterMs = null } = {}) { + let notificationHandler = null; + let turn = 0; + const timers = new Set(); + const later = (ms, callback) => { + const timer = setTimeout(() => { + timers.delete(timer); + callback(); + }, ms); + timers.add(timer); + }; + return { + async initialize() { + calls?.push({ method: "initialize" }); + return { initialized: true }; + }, + setNotificationHandler(handler) { + notificationHandler = typeof handler === "function" ? handler : null; + }, + async startThread(args) { + calls?.push({ method: "thread/start", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_stdio_timed" } } }); + return { threadId: "thread_stdio_timed" }; + }, + async resumeThread(args) { + calls?.push({ method: "thread/resume", args }); + notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); + return { threadId: args.threadId }; + }, + async startTurn(args) { + calls?.push({ method: "turn/start", args }); + turn += 1; + const turnId = `turn_stdio_timed_${turn}`; + notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } }); + for (let index = 1; index <= eventCount; index += 1) { + later(eventEveryMs * index, () => { + notificationHandler?.({ + method: "item/commandExecution/outputDelta", + params: { delta: `progress-${index}` } + }); + }); + } + if (Number.isInteger(completeAfterMs)) { + later(completeAfterMs, () => { + notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_${turn}`, delta: text } }); + notificationHandler?.({ method: "item/completed", params: { item: { id: `item_${turn}`, type: "agentMessage", text } } }); + notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } }); + }); + } + return { turnId }; + }, + close() { + for (const timer of timers) clearTimeout(timer); + timers.clear(); + } + }; +} + test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { const payload = await handleCodeAgentChat( { @@ -1955,6 +2013,127 @@ test("Codex app-server retrying error notification waits for completed turn", as await rm(codexHome, { recursive: true, force: true }); }); +test("Codex app-server activity timeout does not fail an active turn", async () => { + const calls = []; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_active_turn", + createRpcClient: async () => createTimedFakeAppServerClient({ + calls, + text: "active turn completed", + eventEveryMs: 20, + eventCount: 5, + completeAfterMs: 130 + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + }; + + try { + const started = Date.now(); + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_active_turn", + traceId: "trc_stdio_active_turn", + message: "需要超过 activity timeout 但持续输出事件" + }, + { + now: () => "2026-05-24T00:09:00.000Z", + codexStdioManager: manager, + env, + timeoutMs: 35, + hardTimeoutMs: 500 + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.reply.content, "active turn completed"); + assert.ok(Date.now() - started >= 100); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "item/commandExecution/outputDelta")); + assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:no_activity"), false); + assert.equal(payload.error, undefined); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + +test("Codex app-server activity timeout fails only after no new events", async () => { + const calls = []; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_stdio_idle_timeout", + createRpcClient: async () => createTimedFakeAppServerClient({ + calls, + eventCount: 0, + completeAfterMs: null + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + }; + + try { + const started = Date.now(); + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_idle_timeout", + traceId: "trc_stdio_idle_timeout", + message: "停止输出事件后触发 idle timeout" + }, + { + now: () => "2026-05-24T00:09:30.000Z", + codexStdioManager: manager, + env, + timeoutMs: 30, + hardTimeoutMs: 500 + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "timeout"); + assert.equal(payload.error.code, "codex_stdio_idle_timeout"); + assert.match(payload.error.message, /no new events/u); + assert.equal(payload.error.timeoutMs, 30); + assert.ok(payload.error.idleMs >= 30); + assert.match(payload.error.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u); + assert.equal(payload.error.waitingFor, "turn/completed"); + assert.ok(Date.now() - started >= 30); + const timeoutEvent = payload.runnerTrace.events.find((event) => event.label === "timeout:no_activity"); + assert.ok(timeoutEvent); + assert.equal(timeoutEvent.timeoutMs, 30); + assert.ok(timeoutEvent.idleMs >= 30); + assert.match(timeoutEvent.lastActivityAt, /^\d{4}-\d{2}-\d{2}T/u); + assert.equal(timeoutEvent.waitingFor, "turn/completed"); + assert.equal(payload.runnerTrace.events.some((event) => event.label === "timeout:hard_cap"), false); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("Codex app-server failed turn is not wrapped as completed assistant output", async () => { const calls = []; const fakeCodex = await createFakeCodexCommand(); diff --git a/internal/cloud/code-agent-trace-store.mjs b/internal/cloud/code-agent-trace-store.mjs index 423f573d..e2781de1 100644 --- a/internal/cloud/code-agent-trace-store.mjs +++ b/internal/cloud/code-agent-trace-store.mjs @@ -263,6 +263,9 @@ function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = errorCode: safeText(event.errorCode, 120), waitingFor: safeText(event.waitingFor, 220), timeoutMs: typeof event.timeoutMs === "number" ? Math.trunc(event.timeoutMs) : undefined, + hardTimeoutMs: typeof event.hardTimeoutMs === "number" ? Math.trunc(event.hardTimeoutMs) : undefined, + lastActivityAt: safeText(event.lastActivityAt, 80), + idleMs: typeof event.idleMs === "number" ? Math.max(0, Math.trunc(event.idleMs)) : undefined, outputTruncated: event.outputTruncated === true, terminal: event.terminal === true, valuesPrinted: false diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 2cfe04e1..f4314c8f 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -25,6 +25,8 @@ export const DEFAULT_CODEX_STDIO_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export const DEFAULT_CODEX_STDIO_MAX_SESSIONS = 64; export const DEFAULT_CODEX_STDIO_COMMAND = "codex"; export const DEFAULT_CODEX_STDIO_PROBE_TTL_MS = 30 * 1000; +const DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS = 150000; +const DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS = 10 * 60 * 1000; export const CODEX_STDIO_SESSION_STATUSES = Object.freeze([ "creating", "ready", @@ -456,7 +458,8 @@ export function createCodexStdioSessionManager(options = {}) { sandbox, model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), traceRecorder, - timeoutMs: effectiveTimeout(params.timeoutMs) + timeoutMs: effectiveActivityTimeout(params.timeoutMs), + hardTimeoutMs: effectiveHardTimeout(params.hardTimeoutMs, params.timeoutMs) }); traceRecorder.append({ type: "tool_call", @@ -608,11 +611,14 @@ export function createCodexStdioSessionManager(options = {}) { label: canceled ? "cancel:canceled" : timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`, errorCode: canceled ? "codex_stdio_canceled" : error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"), message: canceled ? "Codex stdio request was canceled by the user." : error.message || "Codex stdio session failed.", - timeoutMs: timeout ? effectiveTimeout(params.timeoutMs) : undefined, + timeoutMs: timeout ? error.timeoutMs ?? effectiveActivityTimeout(params.timeoutMs) : undefined, + idleMs: timeout ? error.idleMs : undefined, + lastActivityAt: timeout ? error.lastActivityAt : undefined, + hardTimeoutMs: timeout ? error.hardTimeoutMs : undefined, sessionId: session.sessionId, sessionStatus: session.status, turn: session.turn, - waitingFor: canceled ? "user-retry" : timeout ? "codex-stdio-tool-response" : null, + waitingFor: canceled ? "user-retry" : timeout ? error.waitingFor ?? "codex-stdio-tool-response" : null, terminal: true }); if (canceled) { @@ -1345,17 +1351,19 @@ async function runAppServerTurn({ sandbox, model, traceRecorder, - timeoutMs + timeoutMs, + hardTimeoutMs } = {}) { const state = createAppServerTurnState({ traceRecorder, session }); - const effectiveMs = effectiveTimeout(timeoutMs); + const effectiveMs = effectiveActivityTimeout(timeoutMs); + const effectiveHardMs = effectiveHardTimeout(hardTimeoutMs, effectiveMs); if (typeof client.setNotificationHandler === "function") { client.setNotificationHandler((message) => state.handle(message)); } try { let threadId = optionalId(session?.threadId); if (threadId) { - traceRecorder.append({ + state.appendTrace({ type: "thread", status: "started", label: "thread:resume:started", @@ -1374,7 +1382,7 @@ async function runAppServerTurn({ }, effectiveMs); threadId = optionalId(resumed?.threadId) ?? threadId; state.setThreadId(threadId); - traceRecorder.append({ + state.appendTrace({ type: "thread", status: "completed", label: "thread:resume:completed", @@ -1385,7 +1393,7 @@ async function runAppServerTurn({ waitingFor: "turn/start" }); } else { - traceRecorder.append({ + state.appendTrace({ type: "thread", status: "started", label: "thread:start:started", @@ -1404,7 +1412,7 @@ async function runAppServerTurn({ threadId = optionalId(started?.threadId); if (!threadId) throw new Error("thread/start response did not include thread.id"); state.setThreadId(threadId); - traceRecorder.append({ + state.appendTrace({ type: "thread", status: "completed", label: "thread:start:completed", @@ -1416,7 +1424,7 @@ async function runAppServerTurn({ }); } - traceRecorder.append({ + state.appendTrace({ type: "turn", status: "started", label: "turn:start:started", @@ -1437,7 +1445,7 @@ async function runAppServerTurn({ const turnId = optionalId(turnStart?.turnId); if (!turnId) throw new Error("turn/start response did not include turn.id"); state.setTurnId(turnId); - traceRecorder.append({ + state.appendTrace({ type: "turn", status: "started", label: "turn:start:completed", @@ -1449,9 +1457,7 @@ async function runAppServerTurn({ waitingFor: "turn/completed" }); - const rawResult = typeof client.waitForTurn === "function" - ? await client.waitForTurn(turnId, effectiveMs) - : await state.wait(effectiveMs, client.closedPromise); + const rawResult = await state.wait(effectiveMs, client.closedPromise, { hardTimeoutMs: effectiveHardMs }); return normalizeAppServerTurnResult(rawResult, state.snapshot(), { threadId, turnId }); } finally { if (typeof client.setNotificationHandler === "function") { @@ -1470,6 +1476,9 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { const terminalPromise = new Promise((resolve) => { terminalResolve = resolve; }); + let lastActivityAt = Date.now(); + let lastActivityLabel = "turn-state:created"; + let lastWaitingFor = "app-server-turn"; function setThreadId(value) { threadId = optionalId(value) ?? threadId; @@ -1479,6 +1488,31 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { turnId = optionalId(value) ?? turnId; } + function activitySnapshot(referenceNow = Date.now()) { + return { + lastActivityAt: new Date(lastActivityAt).toISOString(), + lastActivityLabel, + idleMs: Math.max(0, referenceNow - lastActivityAt), + waitingFor: lastWaitingFor + }; + } + + function observeActivity({ label = "app-server:activity", waitingFor = null } = {}) { + lastActivityAt = Date.now(); + lastActivityLabel = label; + if (waitingFor) lastWaitingFor = waitingFor; + return activitySnapshot(lastActivityAt); + } + + function appendTrace(event = {}) { + const appended = traceRecorder?.append(event); + observeActivity({ + label: appended?.label ?? event.label ?? event.type ?? "trace:event", + waitingFor: appended?.waitingFor ?? event.waitingFor ?? null + }); + return appended; + } + function finish(status, error = null, extra = {}) { if (terminal) return; terminal = { @@ -1486,6 +1520,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { terminalError: error ? redactText(error) : null, threadId, turnId, + ...activitySnapshot(), ...extra }; terminalResolve(terminal); @@ -1493,12 +1528,16 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { function handle(message) { const method = typeof message?.method === "string" ? message.method : "unknown"; + observeActivity({ + label: `app-server:${method}`, + waitingFor: appServerWaitingForMethod(method) + }); const params = extractAppServerRecord(message?.params); const item = extractAppServerRecord(params?.item); const turn = extractAppServerRecord(params?.turn); if (method === "thread/started") { setThreadId(extractAppServerString(extractAppServerRecord(params?.thread), "id")); - traceRecorder?.append({ + appendTrace({ type: "thread", status: "completed", label: "thread:started", @@ -1512,7 +1551,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { } if (method === "turn/started") { setTurnId(extractAppServerString(turn, "id")); - traceRecorder?.append({ + appendTrace({ type: "turn", status: "started", label: "turn:started", @@ -1529,7 +1568,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { const delta = String(params?.delta ?? ""); assistantText += delta; if (delta) { - traceRecorder?.append({ + appendTrace({ type: "assistant_message", status: "chunk", label: "assistant:chunk", @@ -1546,7 +1585,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { } if (method === "item/completed" && item?.type === "agentMessage") { if (typeof item.text === "string") finalResponse = item.text; - traceRecorder?.append({ + appendTrace({ type: "assistant_message", status: "completed", label: "assistant:item_completed", @@ -1560,7 +1599,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { return; } if (method === "item/commandExecution/outputDelta" || method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") { - traceRecorder?.append({ + appendTrace({ type: method.startsWith("item/reasoning/") ? "reasoning" : "tool_call", status: "output_chunk", label: method, @@ -1580,7 +1619,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { const willRetry = params?.willRetry === true; setThreadId(extractAppServerString(params, "threadId")); setTurnId(extractAppServerString(params, "turnId")); - traceRecorder?.append({ + appendTrace({ type: willRetry ? "provider_retry" : "error", status: willRetry ? "retrying" : "failed", label: willRetry ? "app-server:retrying" : "app-server:error", @@ -1601,7 +1640,7 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { const status = appServerTerminalStatus(turn?.status); const error = extractAppServerRecord(turn?.error); const message = typeof error?.message === "string" ? error.message : null; - traceRecorder?.append({ + appendTrace({ type: "turn", status, label: `turn:completed:${status ?? "unknown"}`, @@ -1618,11 +1657,81 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { } } - async function wait(timeoutMs, closedPromise) { - let timer; + async function wait(timeoutMs, closedPromise, { hardTimeoutMs = null } = {}) { + let activityTimer; + let hardTimer; const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Codex app-server turn timed out after ${timeoutMs}ms`)), timeoutMs); + const checkActivity = () => { + const now = Date.now(); + const activity = activitySnapshot(now); + if (activity.idleMs >= timeoutMs) { + const error = appServerActivityTimeoutError({ + timeoutMs, + idleMs: activity.idleMs, + lastActivityAt: activity.lastActivityAt, + waitingFor: activity.waitingFor, + threadId, + turnId + }); + appendTrace({ + type: "timeout", + status: "failed", + label: "timeout:no_activity", + errorCode: error.code, + message: error.message, + timeoutMs, + idleMs: activity.idleMs, + lastActivityAt: activity.lastActivityAt, + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: activity.waitingFor, + terminal: true + }); + reject(error); + return; + } + activityTimer = setTimeout(checkActivity, Math.max(1, timeoutMs - activity.idleMs)); + }; + activityTimer = setTimeout(checkActivity, timeoutMs); }); + const hardTimeoutPromise = hardTimeoutMs + ? new Promise((_, reject) => { + hardTimer = setTimeout(() => { + const activity = activitySnapshot(); + const error = appServerHardTimeoutError({ + hardTimeoutMs, + timeoutMs, + idleMs: activity.idleMs, + lastActivityAt: activity.lastActivityAt, + waitingFor: activity.waitingFor, + threadId, + turnId + }); + appendTrace({ + type: "timeout", + status: "failed", + label: "timeout:hard_cap", + errorCode: error.code, + message: error.message, + timeoutMs, + hardTimeoutMs, + idleMs: activity.idleMs, + lastActivityAt: activity.lastActivityAt, + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + threadId, + turnId, + waitingFor: activity.waitingFor, + terminal: true + }); + reject(error); + }, hardTimeoutMs); + }) + : null; const candidates = [terminalPromise]; if (closedPromise && typeof closedPromise.then === "function") { candidates.push(closedPromise.then((exit) => { @@ -1632,29 +1741,36 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { terminalError: "Codex app-server transport closed before turn/completed.", threadId, turnId, + ...activitySnapshot(), appServerExit: exit }; })); } try { - return await Promise.race([...candidates, timeoutPromise]); + return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise].filter(Boolean)); } finally { - clearTimeout(timer); + clearTimeout(activityTimer); + clearTimeout(hardTimer); } } function snapshot() { + const activity = activitySnapshot(); return { threadId, turnId, assistantText: redactText(assistantText), finalResponse: redactText(firstNonEmpty(finalResponse, assistantText)), terminalStatus: terminal?.terminalStatus ?? null, - terminalError: terminal?.terminalError ?? null + terminalError: terminal?.terminalError ?? null, + lastActivityAt: terminal?.lastActivityAt ?? activity.lastActivityAt, + idleMs: terminal?.idleMs ?? activity.idleMs, + waitingFor: terminal?.waitingFor ?? activity.waitingFor }; } return { + appendTrace, handle, setThreadId, setTurnId, @@ -1663,6 +1779,49 @@ function createAppServerTurnState({ traceRecorder, session } = {}) { }; } +function appServerWaitingForMethod(method) { + if (method === "thread/started") return "turn/start"; + if (method === "turn/started") return "assistant-message"; + if (method === "item/agentMessage/delta") return "turn/completed"; + if (method === "item/completed") return "turn/completed"; + if (method === "item/commandExecution/outputDelta") return "turn/completed"; + if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") return "turn/completed"; + if (method === "error") return "turn/completed"; + if (method === "turn/completed") return "turn/completed"; + return "app-server-notification"; +} + +function appServerActivityTimeoutError({ timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) { + const error = new Error(`Codex app-server idle timeout: no new events for ${idleMs}ms, threshold ${timeoutMs}ms; waitingFor=${waitingFor ?? "unknown"}.`); + Object.assign(error, { + code: "codex_stdio_idle_timeout", + timeoutMs, + idleMs, + lastActivityAt, + waitingFor, + threadId, + turnId, + stage: "app-server-turn" + }); + return error; +} + +function appServerHardTimeoutError({ hardTimeoutMs, timeoutMs, idleMs, lastActivityAt, waitingFor, threadId, turnId }) { + const error = new Error(`Codex app-server hard timeout reached after ${hardTimeoutMs}ms; last activity was ${idleMs}ms ago; waitingFor=${waitingFor ?? "unknown"}.`); + Object.assign(error, { + code: "codex_stdio_hard_timeout", + timeoutMs, + hardTimeoutMs, + idleMs, + lastActivityAt, + waitingFor, + threadId, + turnId, + stage: "app-server-turn" + }); + return error; +} + function normalizeAppServerTurnResult(rawResult, state, fallback) { const raw = extractAppServerRecord(rawResult) ?? {}; return { @@ -1672,6 +1831,9 @@ function normalizeAppServerTurnResult(rawResult, state, fallback) { finalResponse: redactText(firstNonEmpty(raw.finalResponse, raw.content, raw.text, state.finalResponse, state.assistantText)), terminalStatus: appServerTerminalStatus(raw.terminalStatus ?? raw.status ?? state.terminalStatus), terminalError: raw.terminalError ? redactText(raw.terminalError) : state.terminalError ?? null, + lastActivityAt: raw.lastActivityAt ?? state.lastActivityAt ?? null, + idleMs: raw.idleMs ?? state.idleMs ?? null, + waitingFor: raw.waitingFor ?? state.waitingFor ?? null, appServerExit: raw.appServerExit ?? null }; } @@ -3828,7 +3990,19 @@ function hasEnvValue(env, name) { } function effectiveTimeout(timeoutMs) { - return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 150000; + return effectiveActivityTimeout(timeoutMs); +} + +function effectiveActivityTimeout(timeoutMs) { + return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODEX_STDIO_TURN_ACTIVITY_TIMEOUT_MS; +} + +function effectiveHardTimeout(hardTimeoutMs, activityTimeoutMs = null) { + const activityMs = effectiveActivityTimeout(activityTimeoutMs); + const configured = Number.isInteger(hardTimeoutMs) && hardTimeoutMs > 0 + ? hardTimeoutMs + : DEFAULT_CODEX_STDIO_TURN_HARD_TIMEOUT_MS; + return Math.max(configured, activityMs); } function dropEmpty(value) { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 7961eecc..b59e8503 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -1246,6 +1246,7 @@ async function handleCodeAgentChatHttp(request, response, options) { { callProvider: options.callCodeAgentProvider, timeoutMs: options.codeAgentTimeoutMs, + hardTimeoutMs: options.codeAgentHardTimeoutMs, env: options.env, workspace: options.workspace, skillsDirs: options.skillsDirs, diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 0efa6fb2..621b0f78 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -18,7 +18,7 @@ const blockedCodeAgentUiLabels = new Set([ "仍是 fallback", "BLOCKED 凭证缺口" ]); -const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu; +const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu; export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, httpStatus = null } = {}) { const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus }); @@ -547,7 +547,7 @@ export function classifyCodeAgentBrowserFailure(error, { traceId = null } = {}) status: "blocked", blocker: "timeout", category: "timeout", - chineseSummary: `超时:Code Agent 请求在配置的长时间边界内没有完成${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`, + chineseSummary: `超时:Code Agent 在配置的活动时间边界内没有新事件${observedTraceId ? `;traceId=${observedTraceId}` : ""}。`, reason: message }; } diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index e88d08e2..7295f63f 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -2675,7 +2675,7 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /旧 4500ms/u.test(app) && /结构化 blocker/u.test(app) && /sourceKind:\s*"PENDING"/u.test(app) && - /Code Agent 请求超过/u.test(app) && + /Code Agent 超过.*无新事件/u.test(app) && /Code Agent SOURCE 回复/u.test(app) && /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/u.test(app) && /sourceKind:\s*completion\.sourceKind/u.test(submitBody) && @@ -4302,7 +4302,7 @@ async function inspectJourneyUi(page) { sourceMessageVisible: Boolean(document.querySelector(".message-card.status-source")), failedMessageVisible: Boolean(document.querySelector(".message-card.status-failed")), failedMessageHasTrace: /trace=trc_/u.test(failedText), - failedMessageHasChineseTimeout: /请求超过|请求超时|保留输入|重试/u.test(failedText), + failedMessageHasChineseTimeout: /无新事件|请求超时|保留输入|重试/u.test(failedText), runningMessageVisible: Boolean(document.querySelector(".message-card.status-running")), pendingMessageText: pendingText.replace(/\s+/gu, " ").trim(), pendingMessageChinese: /正在处理|后端处理中|不会把旧 4500ms|结构化 blocker|真实超时/u.test(pendingText), @@ -4417,7 +4417,7 @@ async function inspectLegacyFailureWindow(page, { beforeMessageCount }) { function backendTimeoutMsFromPayload(payload) { const message = String(payload?.error?.message ?? ""); - const match = message.match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu); + const match = message.match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); if (!match) return null; const value = Number.parseInt(match[1], 10); return Number.isInteger(value) && value > 0 ? value : null; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 23a36ec4..865962db 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -710,7 +710,12 @@ async function submitAgentMessage(value, options = {}) { conversationId: activeConversationId, sessionId: requestedSessionId, messageId: pendingMessage.id, - input: value + input: value, + lastActivityAt: Date.now(), + lastActivityIso: new Date().toISOString(), + traceEventCount: 0, + waitingFor: "agent/chat", + lastEventLabel: "request:submitted" }; el.commandInput.value = ""; renderAgentChatStatus("running"); @@ -813,6 +818,10 @@ async function submitAgentMessage(value, options = {}) { userMessage: error.userMessage, message: error.message, timeoutMs: error.timeoutMs, + hardTimeoutMs: error.hardTimeoutMs, + idleMs: error.idleMs, + lastActivityAt: error.lastActivityAt, + waitingFor: error.waitingFor, providerStatus: error.providerStatus, missingConfig: error.missingConfig, route: error.route, @@ -917,6 +926,7 @@ function updateMessageTrace(messageId, snapshot, options = {}) { const current = state.chatMessages[index]; const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace); const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId; + noteCurrentRequestTraceActivity(snapshot); state.chatMessages[index] = { ...current, runnerTrace, @@ -936,6 +946,22 @@ function updateMessageTrace(messageId, snapshot, options = {}) { } } +function noteCurrentRequestTraceActivity(snapshot) { + const request = state.currentRequest; + if (!request || request.traceId !== snapshot.traceId) return; + const eventCount = Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length; + if (eventCount <= (request.traceEventCount ?? 0)) { + if (snapshot.waitingFor) request.waitingFor = snapshot.waitingFor; + return; + } + request.traceEventCount = eventCount; + request.lastActivityAt = Date.now(); + request.lastActivityIso = new Date(request.lastActivityAt).toISOString(); + request.lastTraceUpdatedAt = snapshot.updatedAt ?? request.lastTraceUpdatedAt; + request.waitingFor = snapshot.waitingFor ?? request.waitingFor; + request.lastEventLabel = snapshot.lastEvent?.label ?? snapshot.events.at(-1)?.label ?? request.lastEventLabel; +} + function latestTraceSnapshot(traceId) { for (const message of [...state.chatMessages].reverse()) { if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace; @@ -1017,6 +1043,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI }, timeoutMs: CODE_AGENT_TIMEOUT_MS, timeoutName: "Code Agent", + activityRef: () => state.currentRequest?.traceId === traceId ? state.currentRequest : null, body: JSON.stringify({ message, conversationId, @@ -1094,10 +1121,26 @@ async function fetchJson(path, options = {}) { const { timeoutMs = API_TIMEOUT_MS, timeoutName = path, + activityRef = null, ...fetchOptions } = options; const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), timeoutMs); + let timer = null; + let timeoutDetail = null; + const startedAt = Date.now(); + const activityTimeout = Boolean(activityRef); + const scheduleTimeout = () => { + const activity = readActivityRef(activityRef, startedAt); + const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); + const remainingMs = timeoutMs - idleMs; + if (remainingMs <= 0) { + timeoutDetail = { ...activity, idleMs }; + controller.abort(); + return; + } + timer = window.setTimeout(scheduleTimeout, Math.max(1, remainingMs)); + }; + scheduleTimeout(); try { const response = await fetch(path, { ...fetchOptions, @@ -1133,18 +1176,39 @@ async function fetchJson(path, options = {}) { return { ok: true, path, status: response.status, data }; } catch (error) { const timedOut = error.name === "AbortError"; + const timeoutState = timeoutDetail ?? readActivityRef(activityRef, startedAt); + const idleMs = timedOut ? Math.max(0, Date.now() - timeoutState.lastActivityAt) : undefined; return { ok: false, path, timeout: timedOut, timeoutMs, - error: timedOut ? `${timeoutName} 请求超时 ${timeoutMs}ms` : `请求失败:${error.message}` + idleMs, + lastActivityAt: timedOut ? timeoutState.lastActivityIso : undefined, + waitingFor: timedOut ? timeoutState.waitingFor : undefined, + error: timedOut + ? activityTimeout + ? `${timeoutName} 超过 ${timeoutMs}ms 无新事件` + : `${timeoutName} 请求超时 ${timeoutMs}ms` + : `请求失败:${error.message}` }; } finally { - window.clearTimeout(timer); + if (timer) window.clearTimeout(timer); } } +function readActivityRef(activityRef, fallbackMs = Date.now()) { + const ref = typeof activityRef === "function" ? activityRef() : activityRef; + const lastActivityAt = Number(ref?.lastActivityAt); + const safeLastActivityAt = Number.isFinite(lastActivityAt) && lastActivityAt > 0 ? lastActivityAt : fallbackMs; + return { + lastActivityAt: safeLastActivityAt, + lastActivityIso: typeof ref?.lastActivityIso === "string" ? ref.lastActivityIso : new Date(safeLastActivityAt).toISOString(), + waitingFor: typeof ref?.waitingFor === "string" ? ref.waitingFor : null, + lastEventLabel: typeof ref?.lastEventLabel === "string" ? ref.lastEventLabel : null + }; +} + async function callRpc(method, params = {}) { const id = nextProtocolId("req"); const traceId = nextProtocolId("trc"); @@ -2747,7 +2811,7 @@ function classifyCodeAgentCompletion(result) { status: "timeout", replied: false, sourceKind: "BLOCKED", - title: "Code Agent 等待超时" + title: "Code Agent 无新事件超时" }; } if (result?.status === "error") { @@ -2896,7 +2960,7 @@ function agentErrorFromHttpResponse(response, traceId) { "request_failed" ); const error = new Error(response.timeout - ? `Code Agent 请求超过 ${response.timeoutMs}ms 未完成;已保留输入,可稍后重试。` + ? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。` : payloadError?.userMessage || response.error || "Code Agent 请求失败"); error.traceId = response.data?.traceId || traceId; error.code = code; @@ -2908,6 +2972,10 @@ function agentErrorFromHttpResponse(response, traceId) { error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined; error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined; error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined); + error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined; + error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined); + error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined); + error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined); error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status; error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined; return error; @@ -2921,13 +2989,30 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) const observedTraceId = result?.traceId || error?.traceId || traceId || null; const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : ""; const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message); + const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs; + const idleMs = error?.idleMs ?? result?.error?.idleMs; + const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt; + const waitingFor = error?.waitingFor ?? result?.error?.waitingFor; const providerStatus = error?.providerStatus ?? result?.error?.providerStatus; - if (isTimeoutFailure(code, message)) { + if (code === "codex_stdio_hard_timeout") { return { category: "timeout", - title: "Code Agent 等待超时", - text: `Code Agent 请求${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}未完成;输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` + title: "Code Agent 达到硬性上限", + text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}` + }; + } + + if (isTimeoutFailure(code, message)) { + const detail = [ + typeof idleMs === "number" ? `idleMs=${idleMs}` : null, + lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null, + waitingFor ? `waitingFor=${waitingFor}` : null + ].filter(Boolean).join(";"); + return { + category: "timeout", + title: "Code Agent 无新事件超时", + text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}。` : ""}${traceSuffix}` }; } @@ -3015,7 +3100,7 @@ function titleForBlockerCategory(category, code) { if (value === "needs_config") return "Code Agent 需要配置"; if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断"; if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback"; - if (value === "timeout") return "Code Agent 等待超时"; + if (value === "timeout") return "Code Agent 无新事件超时"; if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消"; if (value === "runner_busy") return "Code Agent Runner 忙碌"; if (value === "session_blocked") return "Code Agent Session 受阻"; @@ -3032,12 +3117,12 @@ function normalizeErrorCode(code) { } function isTimeoutFailure(code, message) { - return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout"].includes(code) || - /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu.test(message); + return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) || + /\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message); } function timeoutMsFromText(message) { - const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过)\s*(\d+)\s*ms/iu); + const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu); if (!match) return null; const value = Number.parseInt(match[1], 10); return Number.isInteger(value) && value > 0 ? value : null; @@ -3269,7 +3354,7 @@ function messagePendingContextPanel(message) { ["conversation", message.conversationId], ["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"], ["sessionStatus", message.runnerTrace?.sessionStatus], - ["timeout", `${CODE_AGENT_TIMEOUT_MS}ms`] + ["activityTimeout", `${CODE_AGENT_TIMEOUT_MS}ms 无新事件`] ]; const section = document.createElement("section"); section.className = "message-pending-context tone-border-pending"; @@ -3401,6 +3486,10 @@ function traceEventMeta(event) { return [ event.toolName ? `tool=${event.toolName}` : null, event.waitingFor ? `waiting=${event.waitingFor}` : null, + typeof event.timeoutMs === "number" ? `timeoutMs=${event.timeoutMs}` : null, + typeof event.hardTimeoutMs === "number" ? `hardTimeoutMs=${event.hardTimeoutMs}` : null, + typeof event.idleMs === "number" ? `idleMs=${event.idleMs}` : null, + event.lastActivityAt ? `lastActivityAt=${event.lastActivityAt}` : null, typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null, event.errorCode ? `error=${event.errorCode}` : null, event.outputSummary, diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2b33fa60..08d5a565 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -401,7 +401,9 @@ assert.match(app, /error\.traceId = (?:traceId|response\.data\?\.traceId \|\| tr assert.match(app, /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/); assert.match(app, /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/); assert.match(app, /async function sendAgentMessage[\s\S]*?timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/); +assert.match(app, /activityRef:\s*\(\)\s*=>\s*state\.currentRequest/); assert.match(functionBody(app, "fetchJson"), /timeoutMs\s*=\s*API_TIMEOUT_MS/); +assert.match(functionBody(app, "fetchJson"), /无新事件/); for (const hardwareTerm of [ "Gateway 在线", "BOX 在线", @@ -912,10 +914,10 @@ assert.match(app, /function\s+restartRunningAgentMessage/); assert.match(app, /先取消当前 trace,再用同一输入重新发送/); assert.match(app, /canRetryTerminalAgentMessage/); assert.match(app, /结构化 blocker/); -assert.match(app, /Code Agent 请求超过/); +assert.match(app, /Code Agent 超过 \$\{response\.timeoutMs\}ms 无新事件/); assert.match(app, /agentFailurePresentation/); assert.match(app, /agentStatusLabel/); -assert.match(app, /Code Agent 等待超时/); +assert.match(app, /Code Agent 无新事件超时/); assert.match(app, /Code Agent Runner 忙碌/); assert.match(app, /Code Agent Session 受阻/); assert.match(app, /Code Agent API 错误/);