feat(cli): replay agent continuation from trace

This commit is contained in:
Codex
2026-06-01 20:50:59 +08:00
parent a4459ea4c6
commit a686c37247
5 changed files with 129 additions and 14 deletions
@@ -7,6 +7,7 @@
- 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。
- 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。
- 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。
- Web/CLI 路径分叉属于优先级高的工具摩擦。浏览器工作台暴露问题后,先用 `hwlab-cli client` 走同一 Cloud Web base URL 复现;Code Agent continuation 优先用 `client agent send --from-trace <traceId>` 从 inspect 自动恢复 Web 上下文,device-pod 操作优先验证 `19666` 同源代理,再决定是否修业务本体。
- 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。
- 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。
- 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。
+7 -2
View File
@@ -9,6 +9,7 @@
- 提供 WEB 等价的非视觉业务入口:登录鉴权、session 恢复、Device Pod 看板、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。
- 只走 Cloud Web 同源 API surface;默认 base URL 是 `http://74.48.78.17:19666`,也可通过 `--base-url``HWLAB_CLIENT_BASE_URL` 指向其他 Cloud Web 入口。
- Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、trace/result 或 provider 问题后,必须先能用 `hwlab-cli client agent send/result/trace/inspect` 对同一 base URL、同一 `/v1/agent/chat`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。
- 从 Web trace 回放 Code Agent 问题时,优先用 `client agent send --from-trace <traceId>` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,自动带回原 `conversationId/sessionId/threadId` 并把 `retryOf` 指向来源 trace;只有 inspect 缺失时才手动传 `--conversation-id``--session-id``--thread-id``--retry-of`。CLI 输出必须包含 replay 来源、inspect 状态和 redacted continuation 摘要。
- 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、device-pod 内部 Service、gateway RPC 或本地 fixture;需要鉴权的请求使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它允许显式 `--api-base-url` 指向 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。
- Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod:<pod>:<container>' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。
- `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev``v0.2``v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。
@@ -18,7 +19,7 @@
- 输出默认是 JSON;任何失败都要有 `ok:false``action``status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`
- `device-pod-cli`/`hwpod``job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head``grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。
- Code Agent 交互必须默认暴露 `traceId``resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--conversation-id``--session-id``--thread-id``--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。
- `client agent send` 是 Cloud Web Code Agent composer 的非视觉等价入口。它必须支持 `--from-trace``--conversation-id``--session-id``--thread-id``--retry-of`,并在输出中返回 redacted continuation 摘要,证明本次 CLI 请求是否覆盖 Web 的继续会话路径。
- AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resumeCLI 仍应把 Web 提交的 `threadId` 原样送到 Cloud Web API,以便验证 adapter 是否正确把它降级为 `requestedThreadId` 元数据,而不是让 runner 执行旧 thread resume。
- `client harness``client harness-ops``client harness-opt` 吸收 G14 harness-ops 的短连接业务能力:health、submit、result、trace、wait 和 audit。
- harness 系列命令只调用 Cloud Web/Code Agent 同源 API,不创建镜像、Job、常驻服务,也不执行 hot-sync、kubectl cp 或硬编码 namespace/pod 的运行面写路径。
@@ -47,7 +48,7 @@
| `hwlab-cli client gateway sessions` | `GET Cloud API /v1/gateway/sessions` | 显式 Cloud API 诊断入口,观察 gateway online/stale、inflight 和 capability;默认不带 Web cookie。 |
| `hwlab-cli client gateway invoke` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 诊断入口,执行一次 bounded shell dispatch 并返回结构化 dispatch 摘要。 |
| `hwlab-cli client gateway pressure` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 压测入口,真实验证大输出、长单行、stderr、timeout 和并发超容量不会造成黑洞。 |
| `hwlab-cli client agent send` | `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--conversation-id``--session-id``--thread-id``--retry-of` 复现 Web continuation。 |
| `hwlab-cli client agent send` | `GET /v1/agent/chat/inspect` + `POST /v1/agent/chat` + `GET /result/{trace}` | 以 short connection 提交 Code Agent 消息并轮询结果,默认输出 assistant 回复文本摘要;支持 `--from-trace` 自动复现 Web continuation,也支持手动 `--conversation-id``--session-id``--thread-id``--retry-of`。 |
| `hwlab-cli client agent trace TRACE` | `GET /v1/agent/chat/trace/{trace}` | 回放 trace,默认输出状态、事件摘要和 assistant stream 文本。 |
| `hwlab-cli client agent cancel TRACE` | `POST /v1/agent/chat/cancel` | 取消当前 Code Agent 请求。 |
| `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL`harness-ops``harness-opt` 是同义别名。 |
@@ -73,6 +74,10 @@
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client agent send --message "你好" --provider-profile deepseek --timeout-ms 120000`,确认响应包含 accepted/result/trace 信息和 assistant 回复文本;若后端失败,输出必须包含 traceId、resultUrl 或 runnerTrace 摘要,不能无输出或只给旧 gate blocker。
## T3.1
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:对一个来自 Cloud Web 的失败 trace 运行 `client agent send --from-trace <traceId> --message "重试上一条" --provider-profile deepseek --no-wait`,确认 CLI 先访问 `/v1/agent/chat/inspect?traceId=<traceId>`,再提交 `/v1/agent/chat`,输出包含 `replay.source``continuation.replayedFromTrace``conversationId/sessionId/threadId/retryOf`,且不输出 cookie、token 或 secret 原文。
## T4
阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:运行 `client request GET /v1/access/status``client request GET /v1/diagnostics/gate``client rpc system.health`,确认它们全部走 `19666` Cloud Web 同源 API,返回 JSON route/httpStatus/body,且 `client request` 传入绝对 URL 会被拒绝。
+7 -1
View File
@@ -7,6 +7,7 @@
- 向用户提供 Cloud Workbench、Code Agent 对话、live status、device-pod 右侧面板、trace 展示和帮助内容。
- 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、device-pod Service、FRP、Kubernetes 或 provider Secret。
- 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。
- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、device-pod list/status 和 device-pod job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。
## 内部架构
@@ -24,6 +25,7 @@
| `GET /help` | 返回可用 route 摘要。 |
| `GET /v1``GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 |
| `POST /v1/agent/chat``POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口。 |
| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 |
| `POST /v1/m3/io``POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 |
## 测试规格
@@ -48,6 +50,10 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。
## T3.1
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:通过 `19666` Cloud Web 同源 path 对当前允许的 device-pod job/操作 POST 做只读或 dry-run 级验证,确认与 `19667` Cloud API 的 route policy 对齐;如果 Cloud API 返回业务级 4xxCloud Web 也应透传业务错误,不应在 Web 层直接 404。
## T4
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:运行 `bun run --cwd web/hwlab-cloud-web check`,确认输出或日志显示已执行 Cloud Web TypeScript 语义检查、自动发现的单元测试、bundle build 和 dist freshness 校验;不得用只跑 `bun build` 或浏览器手工刷新替代该检查。
@@ -61,7 +67,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验,
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| Workbench 首屏 | 已实现 | 当前页面直接进入工作台,不是 landing page。 |
| cloud-api 同源代理 | 已实现 | 受 route policy 控制。 |
| cloud-api 同源代理 | 已实现 | 受 route policy 控制device-pod job POST 必须与 Cloud API route policy 对齐。 |
| Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 |
| device-pod 面板 | 未完全实现 | 当前主要消费 fake/只读 device-pod payload。 |
| 完整多用户 admin/user UI | 未完全实现 | 登录态存在,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 |
+47
View File
@@ -290,6 +290,53 @@ test("hwlab-cli client agent send preserves Web continuation fields", async () =
assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false);
});
test("hwlab-cli client agent send can replay continuation from an existing trace", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"retry device pod question",
"--from-trace",
"trc_source_web",
"--trace-id",
"trc_replay_web",
"--no-wait"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/agent/chat/inspect?traceId=trc_source_web")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
latestTraceId: "trc_source_web",
query: { traceId: "trc_source_web" },
conversationFacts: { conversationId: "cnv_source", sessionId: "ses_source" },
session: { conversationId: "cnv_source", sessionId: "ses_source", threadId: "019e8078-db67-7750-a5d9-1a99f3abd445" }
}), { status: 200 });
}
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_replay_web", resultUrl: "/v1/agent/chat/result/trc_replay_web" }), { status: 202 });
},
sleep: async () => {}
});
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_source_web");
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(calls[1].body.conversationId, "cnv_source");
assert.equal(calls[1].body.sessionId, "ses_source");
assert.equal(calls[1].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(calls[1].body.retryOf, "trc_source_web");
assert.equal(result.payload.continuation.replayedFromTrace, "trc_source_web");
assert.equal(result.payload.replay.source, "cloud-web:/v1/agent/chat/inspect");
assert.equal(JSON.stringify(result.payload).includes("hwlab_session=session-a"), false);
});
test("hwlab-cli client agent trace shows assistant stream text in compact output", async () => {
const result = await runHwlabCli(["client", "agent", "trace", "trc_test", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], {
fetchImpl: async () => new Response(JSON.stringify({
+67 -11
View File
@@ -94,7 +94,7 @@ function help() {
"hwlab-cli client workbench summary --pod-id device-pod-71-freq",
"hwlab-cli client request GET /v1/access/status [--full]",
"hwlab-cli client rpc system.health [--full]",
"hwlab-cli client agent send --message TEXT|--message-file PATH [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek --timeout-ms 120000",
"hwlab-cli client agent result TRACE_ID",
"hwlab-cli client agent trace TRACE_ID",
"hwlab-cli client agent inspect --trace-id TRACE_ID",
@@ -534,6 +534,55 @@ function agentInspectPath(context: any) {
return `/v1/agent/chat/inspect?${params.toString()}`;
}
async function resolveAgentReplayContext(context: any) {
const fromTrace = text(context.parsed.fromTrace);
if (!fromTrace) return { fromTrace: null, conversationId: "", sessionId: "", threadId: "", retryOf: "", summary: null };
const traceId = requiredTraceId(fromTrace);
const pathName = `/v1/agent/chat/inspect?traceId=${encodeURIComponent(traceId)}`;
const response = await requestJson({ ...context, method: "GET", path: pathName });
if (!isHttpSuccess(response) || response.body?.ok === false) {
throw cliError("from_trace_inspect_failed", "client agent send --from-trace could not inspect the source trace", {
fromTrace: traceId,
route: route("GET", pathName),
httpStatus: response.status,
status: response.body?.status ?? null,
error: response.body?.error ?? null
});
}
const body = response.body ?? {};
const conversationId = safeReplayConversationId(body.query?.conversationId) || safeReplayConversationId(body.conversationFacts?.conversationId) || safeReplayConversationId(body.session?.conversationId) || "";
const sessionId = safeReplaySessionId(body.query?.sessionId) || safeReplaySessionId(body.conversationFacts?.sessionId) || safeReplaySessionId(body.session?.sessionId) || "";
const threadId = text(body.query?.threadId) || text(body.session?.threadId) || text(body.conversationFacts?.threadId);
return {
fromTrace: traceId,
conversationId,
sessionId,
threadId,
retryOf: traceId,
summary: pruneUndefined({
source: "cloud-web:/v1/agent/chat/inspect",
fromTrace: traceId,
inspectHttpStatus: response.status,
status: body.status,
latestTraceId: body.latestTraceId,
conversationId,
sessionId,
threadId,
valuesPrinted: false
})
};
}
function safeReplayConversationId(value: unknown) {
const result = text(value);
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(result) ? result : "";
}
function safeReplaySessionId(value: unknown) {
const result = text(value);
return /^ses_[A-Za-z0-9_.:-]+$/u.test(result) ? result : "";
}
async function harnessCommand(context: any) {
const subcommand = context.rest[0] || "help";
if (["help", "--help", "-h"].includes(subcommand)) return harnessHelp();
@@ -724,20 +773,25 @@ async function agentSend(context: any) {
const { parsed } = context;
const message = await messageValue(context);
if (!message) throw cliError("message_required", "client agent send requires --message or stdin text");
const replay = await resolveAgentReplayContext(context);
const traceId = text(parsed.traceId) || makeId("trc");
const conversationId = text(parsed.conversationId) || makeId("cnv");
const conversationId = text(parsed.conversationId) || replay.conversationId || makeId("cnv");
const sessionId = text(parsed.sessionId) || replay.sessionId;
const threadId = text(parsed.threadId) || replay.threadId;
const retryOf = text(parsed.retryOf) || replay.retryOf;
const continuation = agentContinuationSummary({
conversationId,
sessionId: text(parsed.sessionId),
threadId: text(parsed.threadId),
retryOf: text(parsed.retryOf)
sessionId,
threadId,
retryOf,
fromTrace: replay.fromTrace
});
const requestBody = clean({
message,
conversationId,
sessionId: text(parsed.sessionId),
threadId: text(parsed.threadId),
retryOf: text(parsed.retryOf),
sessionId,
threadId,
retryOf,
traceId,
providerProfile: text(parsed.providerProfile) || "deepseek",
gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs),
@@ -757,10 +811,10 @@ async function agentSend(context: any) {
}
});
if (!isHttpSuccess(accepted)) {
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation });
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary });
}
if (parsed.noWait === true) {
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, waited: false });
return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, replay: replay.summary, waited: false });
}
const result = await pollAgentResult(context, traceId, accepted.body);
return ok("client.agent.send", {
@@ -769,6 +823,7 @@ async function agentSend(context: any) {
traceId,
conversationId,
continuation,
replay: replay.summary,
accepted: compactBody(accepted.body),
result: result.response ? compactResponse(result.response) : null,
polls: result.polls,
@@ -1289,10 +1344,11 @@ function compactResponse(response: any) {
return { httpStatus: response.status, route: route(response.method, response.path), body: compactApiBody(response.body) };
}
function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null }) {
function agentContinuationSummary(value: { conversationId?: string | null; sessionId?: string | null; threadId?: string | null; retryOf?: string | null; fromTrace?: string | null }) {
return pruneUndefined({
webEquivalent: true,
shortConnection: true,
replayedFromTrace: text(value.fromTrace),
conversationId: text(value.conversationId),
sessionId: text(value.sessionId),
threadId: text(value.threadId),