fix code agent gateway trace timeouts

This commit is contained in:
Codex
2026-05-25 01:14:17 +00:00
parent b2a26d4c81
commit b3d298c3e5
14 changed files with 329 additions and 25 deletions
+3 -1
View File
@@ -12,7 +12,7 @@ const gatewaySessionId = process.env.HWLAB_GATEWAY_SESSION_ID ?? `gws_${gatewayI
const port = parsePort(process.env.PORT, 7001);
const cloudUrl = trimUrl(process.env.HWLAB_GATEWAY_CLOUD_URL);
const pollIntervalMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_POLL_INTERVAL_MS, 500);
const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 10000);
const commandTimeoutMs = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_TIMEOUT_MS, 120000);
const outputLimitBytes = parsePositiveInteger(process.env.HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES, 65536);
const resourceId = process.env.HWLAB_GATEWAY_RESOURCE_ID ?? "res_windows_host";
const boxId = process.env.HWLAB_GATEWAY_BOX_ID ?? "box_windows_host";
@@ -250,6 +250,8 @@ function executeCommand(input, { timeoutMs, outputLimitBytes: limitBytes }) {
windowsHide: true,
env: {
...process.env,
PYTHONUTF8: process.env.PYTHONUTF8 ?? "1",
PYTHONIOENCODING: process.env.PYTHONIOENCODING ?? "utf-8",
NO_PROXY: process.env.NO_PROXY,
no_proxy: process.env.no_proxy
},
@@ -98,6 +98,10 @@ PS1
PowerShell 默认使用 wrapper 的 `-EncodedCommand` 路径;不要手写 `cmd /c powershell ...` 的管道、引号或中文转义。简单 `cmd` 命令仍可用 `--command "cmd /d /s /c ..."`,但涉及目录枚举、Unicode、管道、排序或 JSON 输出时优先用 `--powershell-stdin`
Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat` 传入 `gatewayShellTimeoutMs`。Codex prompt 必须把该值落实到 wrapper 的 `--timeout-ms`cloud-api `hardware.invoke.shell` dispatch timeout 必须取环境配置、请求 `input.timeoutMs` 和 120s 默认值中的较大值并加 grace;不得让 20s/30s 的旧默认提前返回 `dispatchStatus=timed_out`。wrapper 自身 HTTP request timeout 要比 shell timeout 稍长,确保用户看到的是 gateway/cloud 返回的结构化 `status/operationId/dispatch`,不是 wrapper 先超时丢失结果。
Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:首行展示 `tool hardware.invoke.shell status=<status> op=<operationId> exit=<exitCode> s=<duration>`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。
Windows 侧 skill、编译器、脚本工具和多参数命令都应走同一个通用传输模式,不新增某个工具的专用 wrapper 子命令:
- 工作目录优先用 wrapper 的 `--cwd <windows path>` 或脚本内 `Set-Location -LiteralPath <path>` 表达,不要在 prompt 里拼 `cmd /c "cd ... && ..."`
@@ -109,6 +113,7 @@ Windows 文件系统探测必须是有界小输出:
-`F:\work``F:\work\ConStart` 或同类目录先做顶层目录/项目标记探测,不要读取 Secret、env、kubeconfig、DB URL 或完整源码内容。
- 使用 `-LiteralPath``Select-Object -First <N>``ConvertTo-Json -Compress -Depth <N>`stdout 目标控制在约 12 KB 内。
- wrapper 的 PowerShell prologue 已设置 UTF-8 console/output,并提供 `Read-HwlabText``Select-HwlabText``ConvertTo-HwlabJson`。读取中文 `SKILL.md`、Keil 日志或 manifest 时优先用这些 helper,避免 `Get-Content`/`Select-String` 的扩展对象字段和系统代码页造成乱码。
- 不要先输出完整目录 JSON 再依赖终端截断;需要更多信息时按明确候选项目二次查询。
- 如果命令已经到达 gateway 但因脚本语法或输出大小失败,只允许简化修正一次;最终回复要记录失败 `operationId`、修正后的成功 `operationId` 和有界输出摘要。
@@ -55,6 +55,7 @@ NODE
- 不要用 `kubectl patch --patch-file -` 假设 stdin 可用;目标 `kubectl` 版本可能把 `-` 当普通文件名。用 `/tmp/*.patch.json``--patch-file`
- 不要把大 JSON patch 放进 `kubectl patch -p "$patch"` 或 shell argvConfigMap 覆盖源码文件时很容易触发 `Argument list too long`。用 stdin 写远端临时文件。
- 不要对已经很大的 hotfix ConfigMap 使用 `kubectl apply -f -``apply` 会尝试写入 `kubectl.kubernetes.io/last-applied-configuration`,可能超过 Kubernetes annotation 256 KiB 限制。局部更新用 `patch --type merge --patch-file`
- 多文件运行面覆盖第一次创建 ConfigMap 时也不要默认用 `kubectl apply -f -`;如果 data 可能超过 annotation 限制,先 `kubectl delete configmap <name> --ignore-not-found`,再 `kubectl create configmap <name> --from-file=...`,随后用 Deployment annotation 记录 hotfix 版本并 rollout。正式 CD 收口时必须删除这些 unmanaged ConfigMap/volumeMount,不让热修覆盖继续漂在运行面。
- 不要通过会截断 stdout 的控制 CLI 把 `kubectl get configmap -o json` 回读到本地再 `replace`;大 data 可能被日志/截断污染成非法 JSON。只有在确认完整无截断的原生通道中,才允许对完整对象做 `kubectl replace -f -`
- 每次 `rollout restart` 后立即执行 `rollout status`,再用 running pod 做最小只读验证,例如 `node --check``grep` marker、`curl` health 或公开入口 trace/result 轮询。
- 临时补丁文件必须放在远端 `/tmp`,命名包含 hotfix 目标,命令结束后删除;issue 里只记录 ConfigMap 名、key、mountPath、operationId/traceId 和验证方法,不粘贴完整 data。
+1 -1
View File
@@ -26,7 +26,7 @@
| `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` | 允许执行 shell 命令;未设置时 gateway 拒绝 `hardware.invoke.shell`。 |
| `HWLAB_GATEWAY_DEMO_OPEN=1` | demo 明确打开标记;本地 smoke 会设置。 |
| `HWLAB_GATEWAY_POLL_INTERVAL_MS` | poll 间隔,默认 500ms。 |
| `HWLAB_GATEWAY_CMD_TIMEOUT_MS` | 单条命令超时,默认 10000ms。 |
| `HWLAB_GATEWAY_CMD_TIMEOUT_MS` | 单条命令超时,默认 120000msWorkbench 发起 Code Agent 请求时可通过“Gateway 命令超时”控件把本轮 wrapper `--timeout-ms` 调到 1/2/3/5/10 分钟。 |
| `HWLAB_GATEWAY_CMD_OUTPUT_LIMIT_BYTES` | stdout/stderr 单路输出上限,默认 65536 bytes。 |
本地或内网存在代理时,必须显式设置:
+20 -6
View File
@@ -95,9 +95,10 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
"Do not call gateway, box-simu, or patch-panel directly.",
"Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.",
"For registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <ms> --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.",
"For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand for Unicode-safe execution.",
"For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-Json -Compress, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.",
"For registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <gatewayShellTimeoutMs> --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.",
"For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand and injects UTF-8 helper functions for Unicode-safe execution.",
"For Windows text files and skill manifests, avoid raw Get-Content or Select-String objects in JSON. Use Read-HwlabText, Select-HwlabText, and ConvertTo-HwlabJson from the wrapper prologue, or explicitly project plain scalar fields before ConvertTo-Json.",
"For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-HwlabJson, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.",
"For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI from a PowerShell stdin script using explicit paths or argument arrays. Do not reimplement skill logic, and do not use shell working-directory tricks like cd &&.",
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py from the generic PowerShell stdin wrapper path; do not reimplement Keil build logic.",
"If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.",
@@ -493,7 +494,13 @@ export function createCodexStdioSessionManager(options = {}) {
runnerLimitations: ["skills-manifest-unavailable", "no-text-fallback-for-skills-discovery"]
});
}
const prompt = buildCodexUserPrompt(params.message, { conversationId, traceId, conversationFacts: params.conversationFacts, sidecar });
const prompt = buildCodexUserPrompt(params.message, {
conversationId,
traceId,
conversationFacts: params.conversationFacts,
sidecar,
gatewayShellTimeoutMs: normalizeGatewayShellTimeoutMs(params.gatewayShellTimeoutMs, env)
});
const toolName = session.threadId ? "codex-app-server.thread/resume+turn/start" : "codex-app-server.thread/start+turn/start";
traceRecorder.append({
type: "prompt",
@@ -3490,12 +3497,13 @@ function safeDisplayPath(value) {
return redactText(String(value ?? ".")).replace(/\s+/gu, " ");
}
function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar }) {
function buildCodexUserPrompt(message, { conversationId, traceId, conversationFacts, sidecar, gatewayShellTimeoutMs }) {
return [
`conversationId: ${conversationId}`,
`traceId: ${traceId}`,
`gatewayShellTimeoutMs: ${gatewayShellTimeoutMs}`,
"",
CODEX_STDIO_BOUNDARY_INSTRUCTIONS,
CODEX_STDIO_BOUNDARY_INSTRUCTIONS.replace(/<gatewayShellTimeoutMs>/gu, String(gatewayShellTimeoutMs)),
codexConversationFactsPrompt(conversationFacts),
codexSidecarSkillsPrompt(sidecar),
"",
@@ -3504,6 +3512,12 @@ function buildCodexUserPrompt(message, { conversationId, traceId, conversationFa
].filter((line) => line !== null && line !== undefined).join("\n");
}
function normalizeGatewayShellTimeoutMs(value, env = process.env) {
const parsed = Number.parseInt(value ?? env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS ?? "", 10);
if (!Number.isInteger(parsed) || parsed <= 0) return 120000;
return Math.min(Math.max(parsed, 30000), 600000);
}
function codexSidecarSkillsPrompt(sidecar) {
if (!sidecar?.intent?.skills) return null;
const skills = sidecar.skills;
+1 -1
View File
@@ -4,7 +4,7 @@ import { ENVIRONMENT_DEV, JSON_RPC_VERSION } from "../protocol/index.mjs";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
const DEFAULT_STALE_MS = 30000;
const DEFAULT_DISPATCH_TIMEOUT_MS = 30000;
const DEFAULT_DISPATCH_TIMEOUT_MS = 120000;
export function createGatewayDemoRegistry({
now = () => new Date().toISOString(),
+13 -4
View File
@@ -286,7 +286,7 @@ async function handleHardwareInvokeShell(params, envelope, context) {
params,
meta: envelope.meta
}),
timeoutMs: parseDispatchTimeout(context.env ?? process.env)
timeoutMs: parseDispatchTimeout(context.env ?? process.env, params)
});
if (!dispatch.ok) {
@@ -332,9 +332,18 @@ function statusFromShellResult(result = {}) {
return "completed";
}
function parseDispatchTimeout(env) {
const parsed = Number.parseInt(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS ?? "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 30000;
function parseDispatchTimeout(env, params = {}) {
const configured = parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, null);
const requested = parsePositiveInteger(params?.input?.timeoutMs ?? params?.timeoutMs, null);
const base = Math.max(configured ?? 0, requested ?? 0, 120000);
const graceMs = parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_GRACE_MS, 10000);
const bounded = Math.min(Math.max(base + graceMs, 1000), 610000);
return bounded;
}
function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
async function runtimeReadiness(runtimeStore) {
+47
View File
@@ -126,6 +126,53 @@ test("JSON-RPC invalid meta reports unknown serviceId reason without leaking sec
assert.equal(JSON.stringify(response).includes("sk-"), false);
});
test("hardware.invoke.shell dispatch timeout honors requested gateway shell timeout with grace", async () => {
let observedTimeoutMs = null;
const response = await handleJsonRpcRequest({
jsonrpc: "2.0",
id: "req_01J00000000000000000000004",
method: "hardware.invoke.shell",
params: {
projectId: "prj_mvp_topology",
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
resourceId: "res_windows_host",
capabilityId: "cap_windows_cmd_exec",
input: {
command: "powershell -NoProfile -Command Start-Sleep -Seconds 45",
timeoutMs: 180000
}
},
meta: {
traceId: "trc_01J00000000000000000000004",
actorId: "usr_01J00000000000000000000004",
serviceId: "hwlab-cloud-web",
environment: "dev"
}
}, {
env: {
HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS: "30000",
HWLAB_GATEWAY_DISPATCH_TIMEOUT_GRACE_MS: "5000"
},
gatewayRegistry: {
isOnline: (gatewaySessionId) => gatewaySessionId === "gws_DESKTOP-1MHOD9I",
enqueue: async ({ timeoutMs }) => {
observedTimeoutMs = timeoutMs;
return {
ok: false,
status: "timed_out",
error: "forced timeout"
};
}
}
});
validateResponse(response);
assert.equal(observedTimeoutMs, 185000);
assert.equal(response.result.status, "timed_out");
assert.equal(response.result.dispatch.dispatchStatus, "timed_out");
assert.equal(response.result.dispatch.shellExecuted, false);
});
test("cloud-api runtime accepts register/report/invoke and exposes audit/evidence records", async () => {
const runtimeStore = createCloudRuntimeStore({
now: () => "2026-05-21T00:00:00.000Z"
+1 -1
View File
@@ -92,7 +92,7 @@ export function createCloudApiServer(options = {}) {
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 30000)
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
});
return createServer(async (request, response) => {
try {
+33 -4
View File
@@ -9,7 +9,21 @@ const DEFAULT_PROJECT_ID = "prj_mvp_topology";
const DEFAULT_GATEWAY_SESSION_ID = "gws_DESKTOP-1MHOD9I";
const DEFAULT_RESOURCE_ID = "res_windows_host";
const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec";
const POWERSHELL_PROLOGUE = "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding;";
const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000;
const MAX_GATEWAY_SHELL_TIMEOUT_MS = 600000;
const REQUEST_TIMEOUT_GRACE_MS = 10000;
const POWERSHELL_PROLOGUE = [
"$ErrorActionPreference = 'Stop';",
"$utf8NoBom = [System.Text.UTF8Encoding]::new($false);",
"[Console]::InputEncoding = $utf8NoBom;",
"[Console]::OutputEncoding = $utf8NoBom;",
"$OutputEncoding = $utf8NoBom;",
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';",
"$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8';",
"function Read-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath) [System.IO.File]::ReadAllText($LiteralPath, $utf8NoBom) }",
"function Select-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath,[Parameter(Mandatory=$true)][string]$Pattern,[int]$First=20) Select-String -LiteralPath $LiteralPath -Pattern $Pattern -Encoding UTF8 | Select-Object -First $First @{Name='path';Expression={$_.Path}}, @{Name='lineNumber';Expression={$_.LineNumber}}, @{Name='line';Expression={$_.Line}} }",
"function ConvertTo-HwlabJson { param([Parameter(ValueFromPipeline=$true)][object]$InputObject,[int]$Depth=8) begin { $items = @() } process { $items += $InputObject } end { if ($items.Count -eq 1) { $items[0] | ConvertTo-Json -Compress -Depth $Depth } else { $items | ConvertTo-Json -Compress -Depth $Depth } } }"
].join("\n");
async function main() {
const args = parseArgs(process.argv.slice(2));
@@ -22,10 +36,17 @@ async function main() {
const apiBaseUrl = stripTrailingSlash(args.apiBaseUrl || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || DEFAULT_API_BASE_URL);
const traceId = args.traceId || `trc_gateway_shell_cli_${Date.now()}`;
const requestId = args.requestId || `req_gateway_shell_cli_${randomUUID()}`;
const timeoutMs = positiveInteger(args.timeoutMs, 30000);
const timeoutMs = boundedPositiveInteger(args.timeoutMs ?? process.env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS, DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, {
min: 1000,
max: MAX_GATEWAY_SHELL_TIMEOUT_MS
});
const requestTimeoutMs = boundedPositiveInteger(args.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, {
min: timeoutMs,
max: timeoutMs + Math.max(REQUEST_TIMEOUT_GRACE_MS, Math.floor(timeoutMs / 2))
});
const response = await requestJson(`${apiBaseUrl}/v1/rpc/hardware.invoke.shell`, {
method: "POST",
timeoutMs,
timeoutMs: requestTimeoutMs,
headers: {
"content-type": "application/json",
"x-trace-id": traceId,
@@ -100,7 +121,8 @@ function printHelp() {
" --powershell-stdin Read PS1 from stdin, encode it, and run through the gateway",
" --powershell-exe NAME Default powershell.exe",
" --cwd WINDOWS_PATH Gateway-side working directory; prefer this over shell-level cd &&",
" --timeout-ms N Short dispatch timeout in milliseconds",
" --timeout-ms N Gateway command timeout, default 120000ms; can also use HWLAB_GATEWAY_SHELL_TIMEOUT_MS",
" --request-timeout-ms N HTTP wait timeout; defaults to command timeout plus a small response grace",
" --project-id ID Default prj_mvp_topology",
" --gateway-session-id ID Default gws_DESKTOP-1MHOD9I",
" --resource-id ID Default res_windows_host",
@@ -205,6 +227,13 @@ function positiveInteger(value, fallback) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function boundedPositiveInteger(value, fallback, { min, max } = {}) {
const parsed = positiveInteger(value, fallback);
const lower = Number.isInteger(min) ? min : 1;
const upper = Number.isInteger(max) ? max : parsed;
return Math.min(Math.max(parsed, lower), upper);
}
function stripTrailingSlash(value) {
return String(value).replace(/\/+$/u, "");
}
+171 -7
View File
@@ -21,11 +21,14 @@ const DEFAULT_API_TIMEOUT_MS = 4500;
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 600000;
const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000;
const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000;
const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000;
const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1";
const TRACE_STREAM_FALLBACK_MS = 2500;
const TRACE_POLL_INTERVAL_MS = 1000;
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs();
let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs();
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 });
const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 });
const rpcReadMethods = Object.freeze([
@@ -135,6 +138,7 @@ const el = {
commandForm: byId("command-form"),
commandInput: byId("command-input"),
codeAgentTimeout: byId("code-agent-timeout"),
gatewayShellTimeout: byId("gateway-shell-timeout"),
commandSend: byId("command-send"),
commandClear: byId("command-clear"),
hardwareSourceStatus: byId("hardware-source-status"),
@@ -266,6 +270,17 @@ function resolveUserCodeAgentTimeoutMs() {
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 });
}
function resolveUserGatewayShellTimeoutMs() {
let stored = null;
try {
stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return clampTimeoutMs(stored, { min: 30000, max: 600000 })
?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 });
}
function initCodeAgentTimeoutControl() {
syncCodeAgentTimeoutControl();
el.codeAgentTimeout.addEventListener("change", () => {
@@ -280,11 +295,24 @@ function initCodeAgentTimeoutControl() {
renderCodeAgentSummary();
renderConversation();
});
el.gatewayShellTimeout.addEventListener("change", () => {
const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS;
GATEWAY_SHELL_TIMEOUT_MS = next;
try {
window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next));
} catch {
// localStorage can be unavailable in hardened browser modes.
}
syncCodeAgentTimeoutControl();
renderCodeAgentSummary();
});
}
function syncCodeAgentTimeoutControl() {
el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS);
el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`;
el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS);
el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`;
}
function initLiveBuildOverlay() {
@@ -1441,6 +1469,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
sessionId,
threadId,
traceId,
gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS,
shortConnection: true,
projectId: gateSummary.topology.projectId
})
@@ -4101,26 +4130,80 @@ function renderTraceEventList(list, rows) {
function traceDisplayRows(trace, events) {
const rows = [];
let noisyRun = [];
let toolOutputRun = [];
const flushNoisyRun = () => {
if (noisyRun.length === 0) return;
const row = traceNoiseSummaryRow(trace, noisyRun);
if (row) rows.push(row);
noisyRun = [];
};
const flushToolOutputRun = () => {
if (toolOutputRun.length === 0) return;
const row = traceToolOutputSummaryRow(trace, toolOutputRun);
if (row) rows.push(row);
toolOutputRun = [];
};
for (const event of events) {
if (isNoisyTraceEvent(event)) {
flushToolOutputRun();
noisyRun.push(event);
continue;
}
if (isToolOutputChunkTraceEvent(event)) {
flushNoisyRun();
toolOutputRun.push(event);
continue;
}
flushNoisyRun();
flushToolOutputRun();
const row = traceDisplayRow(trace, event);
if (!row) continue;
rows.push(row);
}
flushNoisyRun();
flushToolOutputRun();
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
}
function traceToolOutputSummaryRow(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
const first = visibleEvents[0];
const last = visibleEvents.at(-1);
const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join("");
const parsed = parseGatewayJsonRpcOutput(combined);
const clock = traceClock(first.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, first));
if (parsed) {
const { payload, result, dispatch } = parsed;
const status = result.status ?? dispatch.dispatchStatus ?? "unknown";
const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null;
const ok = status === "succeeded" || status === "completed" || exitCode === 0;
const header = [
`${clock} total=${total}`,
ok ? "ok" : status === "timed_out" ? "timeout" : "fail",
"tool hardware.invoke.shell",
`status=${status}`,
result.operationId ? `op=${result.operationId}` : "op=null",
exitCode !== null ? `exit=${exitCode}` : null,
typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null,
`chunks=${visibleEvents.length}`
].filter(Boolean).join(" ");
return {
seq: first.seq ?? null,
tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked",
header,
body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined })
};
}
return {
seq: first.seq ?? null,
tone: "source",
header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`,
body: compactTraceTextTail(cleanTraceOutputText(combined), 1600)
};
}
function traceNoiseSummaryRow(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
@@ -4194,6 +4277,7 @@ function traceDisplayRow(trace, event, options = {}) {
function isNoisyTraceEvent(event) {
const label = String(event?.label ?? "");
if (isToolOutputChunkTraceEvent(event)) return false;
if (isAssistantChunkTraceEvent(event)) return true;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
@@ -4207,6 +4291,12 @@ function isAssistantChunkTraceEvent(event) {
(event?.type === "assistant_message" && event?.status === "chunk");
}
function isToolOutputChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return label === "item/commandExecution/outputDelta" ||
(event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server"));
}
function readableTraceLabel(event) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
if (label === "request:accepted-short-connection") return "submit short-connection";
@@ -4230,17 +4320,83 @@ function readableTraceLabel(event) {
function traceDisplayBody(event) {
const lines = [
event.promptSummary,
event.outputSummary,
event.message,
event.chunk && !isNoisyTraceEvent(event) ? event.chunk : null,
event.promptSummary ? cleanTraceText(event.promptSummary) : null,
event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null,
event.message ? cleanTraceText(event.message) : null,
event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null,
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
]
.map((value) => cleanTraceText(value))
.filter(Boolean);
].filter(Boolean);
return lines.join("\n").slice(0, 1400);
}
function rawTraceOutputText(event) {
return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? "");
}
function parseGatewayJsonRpcOutput(text) {
const payload = parseFirstJsonObject(text);
if (!payload || payload.jsonrpc !== "2.0" || !payload.result || typeof payload.result !== "object") return null;
const result = payload.result;
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
return { payload, result, dispatch };
}
function parseFirstJsonObject(text) {
const source = String(text ?? "");
const start = source.indexOf("{");
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{") depth += 1;
if (char === "}") depth -= 1;
if (depth === 0) {
try {
return JSON.parse(source.slice(start, index + 1));
} catch {
return null;
}
}
}
return null;
}
function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) {
const stdout = cleanTraceOutputText(dispatch.stdout);
const stderr = cleanTraceOutputText(dispatch.stderr);
const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null;
return [
`request=${payload.id ?? "unknown"}`,
`gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`,
`dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`,
error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null,
dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null,
result.auditId ? `audit=${result.auditId}` : null,
result.evidenceId ? `evidence=${result.evidenceId}` : null,
dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null,
stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null,
stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null,
!stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null
].filter(Boolean).join("\n");
}
function traceStatusToken(event) {
const status = String(event?.status ?? "");
if (status === "completed" || status === "succeeded") return "ok";
@@ -4293,6 +4449,14 @@ function cleanTraceText(value) {
.replace(/\n{3,}/gu, "\n\n");
}
function cleanTraceOutputText(value) {
const text = String(value ?? "").trim();
if (!text) return "";
return text
.replace(/[ \t]{2,}/gu, " ")
.replace(/\n{3,}/gu, "\n\n");
}
function traceActionButton(label, onClick, title) {
const button = document.createElement("button");
button.type = "button";
+10
View File
@@ -211,6 +211,16 @@
<option value="1200000">20 分钟</option>
</select>
</label>
<label class="agent-timeout-control" for="gateway-shell-timeout">
<span>Gateway 命令超时</span>
<select id="gateway-shell-timeout" aria-label="PC gateway shell 命令超时">
<option value="60000">1 分钟</option>
<option value="120000" selected>2 分钟</option>
<option value="180000">3 分钟</option>
<option value="300000">5 分钟</option>
<option value="600000">10 分钟</option>
</select>
</label>
<div class="input-shell">
<span class="prompt-mark">hwlab</span>
<input
+19
View File
@@ -67,6 +67,8 @@ const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-ar
const cloudWebRoutes = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-routes.mjs"), "utf8");
const cloudWebProxy = fs.readFileSync(path.resolve(repoRoot, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "utf8");
const cloudApiServer = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/server.mjs"), "utf8");
const cloudJsonRpc = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/json-rpc.mjs"), "utf8");
const gatewayDemoRegistry = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/gateway-demo-registry.mjs"), "utf8");
const codexStdioSession = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/codex-stdio-session.mjs"), "utf8");
const gatewayShellTool = fs.readFileSync(path.resolve(repoRoot, "tools/hwlab-gateway-shell.mjs"), "utf8");
const frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`;
@@ -792,6 +794,10 @@ assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/);
assert.match(app, /conversationFacts:\s*result\.conversationFacts/);
assert.match(app, /"Prefer":\s*"respond-async"/);
assert.match(app, /DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS\s*=\s*30000/);
assert.match(app, /DEFAULT_GATEWAY_SHELL_TIMEOUT_MS\s*=\s*120000/);
assert.match(app, /GATEWAY_SHELL_TIMEOUT_STORAGE_KEY/);
assert.match(app, /gatewayShellTimeoutMs:\s*GATEWAY_SHELL_TIMEOUT_MS/);
assert.match(html, /id="gateway-shell-timeout"/);
assert.match(app, /CODE_AGENT_CANCEL_TIMEOUT_MS/);
assert.match(functionBody(app, "cancelAgentRequest"), /timeoutMs:\s*CODE_AGENT_CANCEL_TIMEOUT_MS/);
assert.match(app, /function stopRunningTraceStream/);
@@ -817,7 +823,11 @@ assert.match(app, /下载 trace/);
assert.match(app, /function renderTraceEventList/);
assert.match(app, /function traceDisplayRows/);
assert.match(app, /function traceDisplayRow/);
assert.match(app, /function traceToolOutputSummaryRow/);
assert.match(app, /function parseGatewayJsonRpcOutput/);
assert.match(app, /tool hardware\.invoke\.shell/);
assert.match(app, /function traceNoiseSummaryRow/);
assert.match(styles, /max-height:\s*min\(520px,\s*54dvh\)/);
assert.match(app, /assistant stream x\$\{visibleEvents\.length\}/);
assert.match(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/);
assert.match(app, /waiting first assistant token/);
@@ -1093,6 +1103,9 @@ assert.match(codexStdioSession, /progressOnly:\s*true/);
assert.match(codexStdioSession, /\["assistant-message", "turn\/completed"\]\.includes\(activity\.waitingFor\)/);
assert.match(codexStdioSession, /does not reset the backend idle timer/);
assert.match(codexStdioSession, /--powershell-stdin/);
assert.match(codexStdioSession, /gatewayShellTimeoutMs/);
assert.match(codexStdioSession, /Read-HwlabText/);
assert.match(codexStdioSession, /ConvertTo-HwlabJson/);
assert.match(codexStdioSession, /-EncodedCommand/);
assert.match(codexStdioSession, /do not hand-build compound cmd \/c, cd &&, pipes, or nested quotes/);
assert.match(codexStdioSession, /Set-Location -LiteralPath/);
@@ -1101,11 +1114,17 @@ assert.match(codexStdioSession, /Select-Object -First/);
assert.match(codexStdioSession, /ConvertTo-Json -Compress/);
assert.match(codexStdioSession, /C:\\\\Users\\\\liang\\\\\.agents\\\\skills\\\\keil/);
assert.match(gatewayShellTool, /function powershellEncodedCommand/);
assert.match(gatewayShellTool, /DEFAULT_GATEWAY_SHELL_TIMEOUT_MS\s*=\s*120000/);
assert.match(gatewayShellTool, /REQUEST_TIMEOUT_GRACE_MS/);
assert.match(gatewayShellTool, /Read-HwlabText/);
assert.match(gatewayShellTool, /Buffer\.from\(`\$\{prologue\}\$\{source\}`,\s*"utf16le"\)\.toString\("base64"\)/);
assert.match(gatewayShellTool, /--powershell-stdin/);
assert.match(gatewayShellTool, /--cwd WINDOWS_PATH/);
assert.match(gatewayShellTool, /powershell\.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand/);
assert.doesNotMatch(gatewayShellTool, /keil-find|keil-build|keil-job-status/);
assert.match(cloudJsonRpc, /Math\.max\(configured \?\? 0,\s*requested \?\? 0,\s*120000\)/);
assert.match(gatewayDemoRegistry, /DEFAULT_DISPATCH_TIMEOUT_MS\s*=\s*120000/);
assert.match(cloudApiServer, /HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS,\s*120000/);
assert.match(codexStdioSession, /detached:\s*childDetached/);
assert.match(codexStdioSession, /function terminateChildProcess/);
assert.match(codexStdioSession, /process\.kill\(-child\.pid,\s*"SIGTERM"\)/);
+4
View File
@@ -1410,6 +1410,10 @@ h3 {
gap: 8px;
margin: 8px 0 0;
padding-left: 0;
max-height: min(520px, 54dvh);
overflow: auto;
overscroll-behavior: contain;
padding-right: 4px;
color: var(--muted);
font-family: var(--mono);
font-size: 10px;