diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 115f4e5a..e29006b5 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -24,6 +24,11 @@ Cloud Workbench is the default user-facing frontend at API/edge 访问、source reports、本地 echo、mock replies 或 provider credential gaps 不能被写成真实 assistant reply;readiness 权威文档是 [code-agent-chat-readiness.md](code-agent-chat-readiness.md). +- Cloud Web 的 Code Agent 非视觉业务路径必须能被 `hwlab-cli client agent` + 复现。Web 发现 AgentRun、session continuation、trace/result 或 provider + 问题时,先用 CLI 通过同一 Cloud Web base URL、同一 `/v1/agent/chat`、同一 + `conversationId/sessionId/threadId/retryOf` 复现,再进入前端状态机或样式修复; + 不能用只覆盖首轮新会话的 CLI 验收替代 Web continuation 验收。 ## Required Layout @@ -150,6 +155,12 @@ workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对 可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期, 刷新页面不得要求用户重新登录。 +当后端使用 AgentRun v0.1 短连接 runner 时,Web 仍可把历史 `threadId` 作为会话连续性元数据 +提交,但 HWLAB adapter 不得把该历史 `threadId` 直接透传给新 runner 做 Codex app-server +`thread/resume`。AgentRun runner Job 是新执行面,旧 Web/Codex thread 可能没有对应 rollout; +adapter 应保留 `conversationId/sessionId` 和 redacted `requestedThreadId` 用于追踪,同时让新 +runner 建立自己的 app-server thread,避免 `no rollout found for thread id`。 + 恢复出的历史消息如果携带 `traceId`,前端必须先对 `/v1/agent/chat/result/` 做 终态归一,再回放 `/trace`。旧的 `running` 消息不能只因为 localStorage restore 被降级成 `source` 后停留在“处理中”文本;如果后端 result 已经完成,消息卡片必须替换为真实 diff --git a/docs/reference/commander-collaboration.md b/docs/reference/commander-collaboration.md index 492a02f0..197452bf 100644 --- a/docs/reference/commander-collaboration.md +++ b/docs/reference/commander-collaboration.md @@ -6,6 +6,7 @@ - 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。 - 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。 +- 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。 - 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。 - 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。 - 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 87759471..dd3fafd7 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -8,6 +8,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 状态机。 - 默认业务子命令不直连 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:` 是 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,不为每个版本新增代码分支。 @@ -17,6 +18,8 @@ - 输出默认是 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 的继续会话路径。 +- AgentRun v0.1 短连接 runner 不保证历史 Web/Codex `threadId` 可在新 runner Job 内 resume;CLI 仍应把 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 的运行面写路径。 - 旧 `hwlab-cli cicd`、fixture MVP gate 和 CLI 镜像/Job 口径属于废弃路径;开发中遇到这些旧门禁、旧测试或旧预检时直接删除,不再维护兼容。 @@ -44,7 +47,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 回复文本摘要。 | +| `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 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` 是同义别名。 | diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 0478490e..59bd930c 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -19,6 +19,7 @@ const DEFAULT_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/ const DEFAULT_RUNNER_NAMESPACE = "agentrun-v01"; const DEFAULT_TIMEOUT_MS = 1_200_000; const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek"]); +const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-new-runner-thread"; export function codeAgentAgentRunAdapterEnabled(env = process.env) { const value = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase(); @@ -46,6 +47,8 @@ export function initialAgentRunChatResult({ params = {}, options = {}, traceId } capabilityLevel: "agentrun-v01-manual-dispatch", sessionMode: "agentrun-v01-session-ref", implementationType: "hwlab-cloud-api-agentrun-v01-adapter", + requestedThreadId: safeOpaqueId(params.threadId) || null, + threadContinuityPolicy: THREAD_CONTINUITY_POLICY, agentRun: { adapter: ADAPTER_ID, status: "pending-dispatch", @@ -323,11 +326,12 @@ function buildAgentRunCreateRunInput({ params, env, traceId, backendProfile }) { sessionRef: { sessionId: agentRunSessionId(params, traceId), ...(safeConversationId(params.conversationId) ? { conversationId: safeConversationId(params.conversationId) } : {}), - ...(safeOpaqueId(params.threadId) ? { threadId: safeOpaqueId(params.threadId) } : {}), metadata: { adapter: ADAPTER_ID, hwlabTraceId: traceId, hwlabApi: "/v1/agent/chat", + requestedThreadId: safeOpaqueId(params.threadId) || null, + threadContinuityPolicy: THREAD_CONTINUITY_POLICY, valuesPrinted: false } }, @@ -364,7 +368,9 @@ function buildAgentRunCommandInput({ params, traceId, backendProfile }) { traceId, conversationId: safeConversationId(params.conversationId) || null, sessionId: agentRunSessionId(params, traceId), - threadId: safeOpaqueId(params.threadId) || null, + threadId: null, + requestedThreadId: safeOpaqueId(params.threadId) || null, + threadContinuityPolicy: THREAD_CONTINUITY_POLICY, providerProfile: backendProfile, source: "hwlab-cloud-api", valuesPrinted: false diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 330cab19..58f51d12 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -154,11 +154,17 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.equal(body.backendProfile, "deepseek"); assert.equal(body.resourceBundleRef.repoUrl, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git"); assert.equal(body.resourceBundleRef.commitId, "0123456789abcdef0123456789abcdef01234567"); + assert.equal(Object.hasOwn(body.sessionRef, "threadId"), false); + assert.equal(body.sessionRef.metadata.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.sessionRef.metadata.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread"); return send({ id: "run_hwlab_adapter", status: "pending", backendProfile: "deepseek", sessionRef: body.sessionRef, resourceBundleRef: body.resourceBundleRef }); } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/commands") { assert.equal(body.type, "turn"); assert.match(body.payload.prompt, /AgentRun adapter/u); + assert.equal(body.payload.threadId, null); + assert.equal(body.payload.requestedThreadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(body.payload.threadContinuityPolicy, "hwlab-agentrun-v01-new-runner-thread"); return send({ id: "cmd_hwlab_adapter", runId: "run_hwlab_adapter", state: "pending", type: "turn", seq: 1 }); } if (request.method === "POST" && url.pathname === "/api/v1/runs/run_hwlab_adapter/runner-jobs") { @@ -242,7 +248,13 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", headers: { "content-type": "application/json", "x-trace-id": traceId }, - body: JSON.stringify({ conversationId: "cnv_server-test-agentrun", message: "AgentRun adapter smoke" }) + body: JSON.stringify({ + conversationId: "cnv_server-test-agentrun", + sessionId: "ses_server-test-agentrun", + threadId: "019e8078-db67-7750-a5d9-1a99f3abd445", + retryOf: "trc_previous-agentrun-web", + message: "AgentRun adapter smoke" + }) }); assert.equal(submit.status, 202); const accepted = await submit.json(); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 487c45df..4aa342e9 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; @@ -242,6 +242,54 @@ test("hwlab-cli client agent send submits async and polls result", async () => { assert.equal(result.payload.result.body.reply.content, "hi"); }); +test("hwlab-cli client agent send preserves Web continuation fields", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-agent-continuation-")); + await writeFile(path.join(cwd, "prompt.txt"), "看看有什么device-pod能用?", "utf8"); + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "send", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a", + "--message-file", + "prompt.txt", + "--trace-id", + "trc_web_continue", + "--conversation-id", + "cnv_web_continue", + "--session-id", + "ses_web_continue", + "--thread-id", + "019e8078-db67-7750-a5d9-1a99f3abd445", + "--retry-of", + "trc_previous", + "--no-wait" + ], { + cwd, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_web_continue", resultUrl: "/v1/agent/chat/result/trc_web_continue" }), { status: 202 }); + }, + stdinText: "fallback stdin", + sleep: async () => {} + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/agent/chat"); + assert.equal(calls[0].body.message, "看看有什么device-pod能用?"); + assert.equal(calls[0].body.conversationId, "cnv_web_continue"); + assert.equal(calls[0].body.sessionId, "ses_web_continue"); + assert.equal(calls[0].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(calls[0].body.retryOf, "trc_previous"); + assert.equal(result.payload.continuation.webEquivalent, true); + assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445"); + assert.equal(result.payload.continuation.retryOf, "trc_previous"); + 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({ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index e360cbf4..cd6949da 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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 --provider-profile deepseek --timeout-ms 120000", + "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 result TRACE_ID", "hwlab-cli client agent trace TRACE_ID", "hwlab-cli client agent inspect --trace-id TRACE_ID", @@ -512,7 +512,7 @@ async function agentCommand(context: any) { } if (subcommand === "cancel") { const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); - const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId) }), extraHeaders: { "x-trace-id": traceId } }); + const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), threadId: text(context.parsed.threadId) }), extraHeaders: { "x-trace-id": traceId } }); return responsePayload("client.agent.cancel", response, context, { route: route("POST", "/v1/agent/chat/cancel"), traceId }); } throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); @@ -722,15 +722,22 @@ async function harnessAudit(context: any) { async function agentSend(context: any) { const { parsed } = context; - const message = text(parsed.message ?? parsed.text) || text(context.stdinText); + const message = await messageValue(context); if (!message) throw cliError("message_required", "client agent send requires --message or stdin text"); const traceId = text(parsed.traceId) || makeId("trc"); const conversationId = text(parsed.conversationId) || makeId("cnv"); + const continuation = agentContinuationSummary({ + conversationId, + sessionId: text(parsed.sessionId), + threadId: text(parsed.threadId), + retryOf: text(parsed.retryOf) + }); const requestBody = clean({ message, conversationId, sessionId: text(parsed.sessionId), threadId: text(parsed.threadId), + retryOf: text(parsed.retryOf), traceId, providerProfile: text(parsed.providerProfile) || "deepseek", gatewayShellTimeoutMs: numberOption(parsed.gatewayShellTimeoutMs), @@ -750,10 +757,10 @@ async function agentSend(context: any) { } }); if (!isHttpSuccess(accepted)) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation }); } if (parsed.noWait === true) { - return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, waited: false }); + return responsePayload("client.agent.send", accepted, context, { route: route("POST", "/v1/agent/chat"), traceId, conversationId, continuation, waited: false }); } const result = await pollAgentResult(context, traceId, accepted.body); return ok("client.agent.send", { @@ -761,6 +768,7 @@ async function agentSend(context: any) { route: route("POST", "/v1/agent/chat"), traceId, conversationId, + continuation, accepted: compactBody(accepted.body), result: result.response ? compactResponse(result.response) : null, polls: result.polls, @@ -1281,6 +1289,18 @@ 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 }) { + return pruneUndefined({ + webEquivalent: true, + shortConnection: true, + conversationId: text(value.conversationId), + sessionId: text(value.sessionId), + threadId: text(value.threadId), + retryOf: text(value.retryOf), + valuesPrinted: false + }); +} + function responseBodyForCli(body: any, parsed: ParsedArgs) { return parsed.full === true ? body : compactApiBody(body); }