fix: bind hwpod uart read

This commit is contained in:
Codex Agent
2026-06-06 18:27:27 +08:00
parent e5e89636a8
commit 1ba9b70da1
3 changed files with 156 additions and 8 deletions
+2 -2
View File
@@ -137,7 +137,7 @@ Keil MDK 装配规则:当 `spec.workspace.toolchain` 为 `keil-mdk`、`keil`
Windows subject worktree 文本修改优先使用 `workspace.apply-patch``workspace.replace``workspace.insert-after``workspace.write`,不要把 `cmd.run` 的 PowerShell/cmd quoting 当成标准编辑路径。文本编辑结果必须返回编辑前后 SHA、文件字节数、换行类型、diff 摘要和 dry-run 状态;`apply-patch` 匹配失败时必须返回 normalized preview、候选行号、CRLF/LF 统计、文件 SHA/bytes 和 hwpod-node implementation version,便于判断是上下文不匹配、CRLF 问题还是远端实现版本问题。`workspace.apply-patch` 应保留原文件换行风格,尤其不能把 CRLF subject 文件静默改写成 LF。
UART op 在`hwpod-node` 未配置本地工具绑定时必须返回结构化 blocker details,至少包含请求 port、`spec.ioProbe` 解析出的物理 port、workspacePath、平台、node version、接受的 spec/env 配置位置和 `supportedFallback=false`。这类结果是可诊断 blocker不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
UART read 由`hwpod-node` 绑定节点本地 `serial-monitor` CLI 执行。默认绑定路径是 `~/.agents/skills/serial-monitor`,也可通过 `HWPOD_SERIAL_MONITOR_DIR` / `HWPOD_SERIAL_MONITOR_COMMAND` 或 plan args 覆盖;`spec.ioProbe.uart.port``spec.ioProbe.uart.baudrate` 是物理串口和波特率权威。`io.uart.read` 只能在 `serial-monitor monitor status` 显示正在监控同一 COM/baud 时返回数据;未监控、串口不匹配或工具失败时必须返回结构化 blocker details,至少包含请求 port、解析出的物理 port/baud、workspacePath、平台、node version、monitor status、可用 ports 和启动建议。不得静默伪造空读数,也不得要求 Agent 改走手写 PowerShell/串口脚本旁路。
标准 plan 形态:
@@ -258,4 +258,4 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run logs <runId> --tail 8000
8. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。
9. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。
10. Windows subject worktree 文本编辑必须能在 CRLF 文件中通过 `workspace.insert-after``workspace.replace` 完成一行源码修改,返回 before/after SHA 与 diff 摘要,并保留原文件 CRLF;`workspace.apply-patch` 在 context 不匹配时必须返回可诊断 payload,而不是只给 `context not found`
11. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 未绑定时必须返回包含 `ioProbe`/COM 口/配置 hint 的结构化 blocker details。
11. Keil `debug.download` 自动装配必须输出结构化 `cmd.run` argv 步骤;带空格的 `probeName` 在 plan 中必须是单独 argv 元素。`io.uart.read` 必须通过节点本地 `serial-monitor` 读取正在监控的 COM/baud;未监控或工具失败时必须返回包含 `ioProbe`/COM 口/baud/monitor status/启动建议的结构化 blocker details。
+56 -3
View File
@@ -99,6 +99,13 @@ test("hwpod-node reports cmd.run non-zero exits as failed results", async () =>
test("hwpod-node reports structured UART binding diagnostics", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-"));
try {
const fixture = path.join(root, "serial-monitor-idle.mjs");
await writeFile(fixture, `
const args = process.argv.slice(2).join(" ");
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: false, port: null, baudRate: null, lastPort: "COM9", lastBaudRate: 115200 } }));
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
else { console.error("unsupported " + args); process.exit(2); }
`, "utf8");
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_uart_diag",
@@ -110,17 +117,63 @@ test("hwpod-node reports structured UART binding diagnostics", async () => {
args: {
workspacePath: root,
port: "uart1",
ioProbe: { uart: { id: "uart1", port: "COM9" } }
serialMonitorDir: root,
serialMonitorCommand: [process.execPath, fixture],
ioProbe: { uart: { id: "uart1", port: "COM9", baudrate: 115200 } }
}
}]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, false);
assert.equal(result.results[0].status, "blocked");
assert.equal(result.results[0].blocker.code, "hwpod_node_op_not_configured");
assert.equal(result.results[0].blocker.code, "hwpod_uart_monitor_not_active");
assert.equal(result.results[0].blocker.details.requestedPort, "uart1");
assert.equal(result.results[0].blocker.details.resolvedPort, "COM9");
assert.deepEqual(result.results[0].blocker.details.requiredBinding.nodeEnvHints, ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"]);
assert.equal(result.results[0].blocker.details.baudRate, 115200);
assert.equal(result.results[0].blocker.details.serialMonitor.monitorStatus.data.isMonitoring, false);
assert.match(result.results[0].blocker.details.serialMonitor.startCommand, /monitor start -p COM9 -b 115200/u);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("hwpod-node reads UART through serial-monitor binding", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-node-uart-active-"));
try {
const fixture = path.join(root, "serial-monitor-fixture.mjs");
await writeFile(fixture, `
const args = process.argv.slice(2).join(" ");
if (args === "monitor status") console.log(JSON.stringify({ action: "monitor_status", success: true, data: { isMonitoring: true, port: "COM9", baudRate: 115200, startTime: "2026-06-06T10:00:00Z" } }));
else if (args === "ports") console.log(JSON.stringify({ action: "list_ports", success: true, data: { ports: [{ path: "COM9" }] } }));
else if (args.startsWith("fetch")) console.log(JSON.stringify({ action: "fetch_data", success: true, data: [{ timestamp: "2026-06-06T10:00:01Z", port: "COM9", data: "UART OK" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "fixture.jsonl" }));
else { console.error("unsupported " + args); process.exit(2); }
`, "utf8");
const result = await executeHwpodNodeOpsPlan({
contractVersion: "hwpod-node-ops-v1",
planId: "hwpod_plan_uart_read",
hwpodId: "hwpod-local",
nodeId: "pc-host-1",
ops: [{
opId: "op_uart",
op: "io.uart.read",
args: {
workspacePath: root,
port: "uart1",
maxBytes: 128,
serialMonitorDir: root,
serialMonitorCommand: [process.execPath, fixture],
ioProbe: { uart: { id: "uart1", port: "COM9", baudrate: 115200 } }
}
}]
}, { now: () => "2026-06-05T00:00:00.000Z" });
assert.equal(result.ok, true);
assert.equal(result.results[0].status, "completed");
assert.equal(result.results[0].output.bindingSource, "serial-monitor-cli");
assert.equal(result.results[0].output.resolvedPort, "COM9");
assert.equal(result.results[0].output.text, "UART OK");
assert.deepEqual(result.results[0].output.command.slice(0, 2), [process.execPath, fixture]);
} finally {
await rm(root, { recursive: true, force: true });
}
+98 -3
View File
@@ -97,7 +97,7 @@ export function connectHwpodNodeWs(config: any, options: any = {}) {
type: "register",
nodeId,
name: text(config.name) || nodeId,
capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset"],
capabilities: ["hwpod-node-ops", "cmd.run", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.write", "workspace.replace", "workspace.insert-after", "debug.build", "debug.download", "debug.reset", "io.uart.read"],
labels: { platform: process.platform, arch: process.arch, hostname: os.hostname(), version: NODE_VERSION, startedAt: now() }
});
sendHeartbeat();
@@ -281,7 +281,11 @@ async function executeOp(op: any, context: any) {
const output = await debugCommand(name, args);
return output.ok ? opOk(opId, name, output) : opFailed(opId, name, output, "hwpod_node_command_failed", `${name} node-side command exited with ${output.exitCode}`);
}
if (["io.uart.read", "io.uart.write", "io.uart.jsonrpc"].includes(name)) {
if (name === "io.uart.read") {
const output = await uartRead(args);
return output.ok ? opOk(opId, name, output) : opBlocked(opId, name, output.blockerCode, output.summary, output.details);
}
if (["io.uart.write", "io.uart.jsonrpc"].includes(name)) {
return opBlocked(opId, name, "hwpod_node_op_not_configured", `${name} requires node-side UART tool binding on this hwpod-node`, uartBindingDiagnostics(name, args));
}
return opBlocked(opId, name, "unsupported_hwpod_node_op", `unsupported hwpod-node op: ${name}`);
@@ -655,6 +659,87 @@ async function cmdRun(args: any) {
return { cwd, command: [command, ...argv], ...output };
}
async function uartRead(args: any) {
const diagnostics = uartBindingDiagnostics("io.uart.read", args);
const serialMonitorDir = text(args.serialMonitorDir) || text(process.env.HWPOD_SERIAL_MONITOR_DIR) || path.join(os.homedir(), ".agents", "skills", "serial-monitor");
const commandBase = serialMonitorCommandBase(args);
const timeoutMs = numberValue(args.timeoutMs) ?? 10000;
const status = await runSerialMonitor(commandBase, ["monitor", "status"], { cwd: serialMonitorDir, timeoutMs })
.catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [...commandBase, "monitor", "status"] }));
const statusBody = parseJsonMaybe(status.stdout);
const statusData = objectValue(statusBody?.data);
const targetPort = text(diagnostics.resolvedPort) || text(diagnostics.requestedPort);
const targetBaudRate = numberValue(diagnostics.baudRate);
const activePort = text(statusData.port);
const activeBaudRate = numberValue(statusData.baudRate);
const monitoringMatches = status.ok && statusBody?.success === true && statusData.isMonitoring === true
&& (!targetPort || activePort.toLowerCase() === targetPort.toLowerCase())
&& (!targetBaudRate || activeBaudRate === targetBaudRate);
if (!monitoringMatches) {
const ports = await runSerialMonitor(commandBase, ["ports"], { cwd: serialMonitorDir, timeoutMs }).catch((error) => ({ ok: false, stdout: "", stderr: error?.message || String(error), exitCode: null, command: [] }));
return {
ok: false,
blockerCode: "hwpod_uart_monitor_not_active",
summary: `io.uart.read requires serial-monitor to be active on ${targetPort || "the requested UART"}${targetBaudRate ? `/${targetBaudRate}` : ""}`,
details: {
...diagnostics,
serialMonitor: {
dir: serialMonitorDir,
command: commandBase,
monitorStatus: statusBody ?? { parseOk: false, stdout: status.stdout, stderr: status.stderr, exitCode: status.exitCode },
ports: parseJsonMaybe(ports.stdout) ?? { parseOk: false, stdout: ports.stdout, stderr: ports.stderr, exitCode: ports.exitCode },
startCommand: `cd ${serialMonitorDir} && bun scripts/serial-monitor-cli.ts monitor start -p ${targetPort || "<PORT>"}${targetBaudRate ? ` -b ${targetBaudRate}` : ""}`
}
}
};
}
const limit = Math.max(1, Math.min(numberValue(args.limit) ?? Math.ceil((numberValue(args.maxBytes) ?? 4096) / 80), 200));
const fetchArgs = ["fetch", "-l", String(limit), "--session-only", ...(text(args.since) ? ["-s", text(args.since)] : [])];
const fetch = await runSerialMonitor(commandBase, fetchArgs, { cwd: serialMonitorDir, timeoutMs });
const fetchBody = parseJsonMaybe(fetch.stdout);
if (!fetch.ok || fetchBody?.success !== true) {
return {
ok: false,
blockerCode: "hwpod_uart_read_failed",
summary: "serial-monitor fetch failed for io.uart.read",
details: { ...diagnostics, serialMonitor: { dir: serialMonitorDir, command: commandBase, fetch: fetchBody ?? { parseOk: false, stdout: fetch.stdout, stderr: fetch.stderr, exitCode: fetch.exitCode } } }
};
}
const rows = Array.isArray(fetchBody.data) ? fetchBody.data : [];
const maxBytes = numberValue(args.maxBytes) ?? 4096;
const joined = rows.map((row: any) => text(row?.data)).filter(Boolean).join("\n");
const truncatedText = joined.length > maxBytes ? joined.slice(0, maxBytes) : joined;
return {
ok: true,
bindingSource: "serial-monitor-cli",
serialMonitorDir,
requestedPort: diagnostics.requestedPort,
resolvedPort: targetPort,
baudRate: targetBaudRate || activeBaudRate || null,
command: [...commandBase, ...fetchArgs],
data: rows,
count: fetchBody.count ?? rows.length,
totalCount: fetchBody.totalCount ?? null,
hasMore: fetchBody.hasMore ?? false,
text: truncatedText,
truncated: joined.length > maxBytes || fetchBody.truncated === true,
monitorStatus: statusBody,
sourceFile: fetchBody.sourceFile ?? null
};
}
async function runSerialMonitor(commandBase: string[], argv: string[], { cwd, timeoutMs }: any) {
const output = await spawnOutput([...commandBase, ...argv], { cwd, timeoutMs });
return { command: [...commandBase, ...argv], ...output };
}
function serialMonitorCommandBase(args: any) {
if (Array.isArray(args.serialMonitorCommand) && args.serialMonitorCommand.length > 0) return args.serialMonitorCommand.map(String);
const configured = text(process.env.HWPOD_SERIAL_MONITOR_COMMAND);
if (configured) return configured.split(/\s+/u).filter(Boolean);
return ["bun", "scripts/serial-monitor-cli.ts"];
}
async function spawnShellOutput(command: string, { cwd, timeoutMs }: any) {
const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", command] : ["sh", "-lc", command];
const output = await spawnOutput(shellCommand, { cwd, timeoutMs });
@@ -809,10 +894,12 @@ function uartBindingDiagnostics(op: string, args: any) {
const uart = objectValue(ioProbe.uart);
const requestedPort = text(args.port) || text(uart.id) || "uart1";
const devicePort = text(uart.port) || text(uart.path) || text(ioProbe.port);
const baudRate = numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate);
return {
op,
requestedPort,
resolvedPort: devicePort || requestedPort,
baudRate: baudRate ?? null,
workspacePath: text(args.workspacePath) || null,
ioProbe,
platform: process.platform,
@@ -820,12 +907,20 @@ function uartBindingDiagnostics(op: string, args: any) {
requiredBinding: {
kind: "uart-tool",
acceptedSpecPaths: ["spec.ioProbe.uart.port", "spec.ioProbe.port"],
nodeEnvHints: ["HWPOD_UART_TOOL", "HWPOD_UART_PORT"]
nodeEnvHints: ["HWPOD_SERIAL_MONITOR_DIR", "HWPOD_SERIAL_MONITOR_COMMAND", "HWPOD_UART_PORT"]
},
supportedFallback: false
};
}
function parseJsonMaybe(value: string) {
try {
return JSON.parse(String(value ?? "").trim());
} catch {
return null;
}
}
function parseOptions(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = { _: [] };
for (let index = 0; index < argv.length; index += 1) {