fix: use activity timeout for code agent turns
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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)) : [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+103
-14
@@ -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,
|
||||
|
||||
@@ -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 错误/);
|
||||
|
||||
Reference in New Issue
Block a user