From 44a59d4f9e7820b61eb26d9563c6708fabf159b8 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 12:51:51 +0800 Subject: [PATCH] fix: orchestrate caserun agent flow without grading --- docs/reference/spec-hwpod-harness.md | 10 +- tools/hwlab-cli/caserun.test.ts | 102 +++++++ tools/src/hwlab-caserun-lib.ts | 417 +++++++++++++++++++++++++-- tools/src/hwlab-cli-lib.ts | 4 +- 4 files changed, 505 insertions(+), 28 deletions(-) diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index c5e35f1e..1aa79c73 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -214,9 +214,9 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case collect d601-f103-v2-compile --case-re bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /root/hwlab-case-registry ``` -`case run` 默认按 compile-only 验收:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-` 隔离 worktree 并写入 run-local spec,然后调用 `hwpod-cli build`,再通过 `cmd.run` 查询 Keil 异步 job-status,收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs///`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载验收时,应作为独立 action 显式加入 case 定义和验收口径。 +`case run` 在当前版本只负责编排流程和记录原始证据,不做 evidence 自动评价、自动打分或门禁判断。流程是:prepare 先在 subject repo 本地 checkout 下创建 `.worktree/caserun-` 隔离 worktree并写入 run-local spec,然后生成 Code Agent prompt/context,创建或调用 HWLAB Code Agent session,把 isolated worktree、run-local HWPOD spec、任务目标和约束交给 Code Agent;agent 阶段结束或超时后,CaseRun 采集 subject worktree 的 `git status` / `git diff --stat` / `git diff --binary`,再调用 `hwpod-cli build`,通过 `cmd.run` 查询 Keil 异步 job-status,最后收集 `evidence.json` 到 run state,并把审计副本写入 case registry repo 的 `runs///`。下载不作为 compile-only CaseRun 的隐式步骤;后续需要下载时,应作为独立 action 显式加入 case 定义和流程记录。 -当前标准 smoke case 是 `d601-f103-v2-compile`。它绑定已发现并预装的 `D601-F103-V2` HWPOD spec,当前只作为 HWPOD/Keil 编译底座 smoke;它可以由 Code Agent、AgentRun runner 或操作员从 `hwlab-cli case run d601-f103-v2-compile` 入口发起,但通过结果只证明 CaseRun runner 和 Keil 编译链路可用,不证明 Code Agent 已经收到 prompt 或完成源码任务。通过结果必须至少包含:`status=succeeded`、Keil build job terminal success、`returnCode=0`、`compileOnly=true`、`downloadSkipped=true`,并记录 `.hex` 和 `.axf` artifact 路径。 +当前标准 smoke case 是 `d601-f103-v2-compile`。它绑定已发现并预装的 `D601-F103-V2` HWPOD spec,目标是让 CaseRun 通过 Web 等价入口或 AgentRun runner 布置 Code Agent 舞台,再调用 `hwlab-cli case run d601-f103-v2-compile` 跑完整流程,而不是由操作员手工编译后代替运行。当前版本的结果只要求记录:subject provenance、agent prompt/session/trace/conversation/provider、subject worktree diff 文件、HWPOD build invocation、Keil job/status/artifact 字段、`compileOnly=true` 和 `downloadSkipped=true`;不得把这些 evidence 自动归纳为 Code Agent 通过/失败。 产物归属按两条线分开: @@ -234,6 +234,6 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo / 3. `hwpod-cli --dry-run` 输出的 plan 不需要云端 spec,也不读取其他本地旧 profile authority。 4. `hwlab-api` 的 node-ops 入口能接收 plan,返回 JSON result;无可用 node 时必须返回结构化 blocker,而不是静默伪造 DEV-LIVE。 5. 真实 CLI 验收必须证明 `hwpod-cli -> hwpod-compiler-cli -> hwlab-api /v1/hwpod-node-ops -> hwpod-node` 全链路走通。 -6. CaseRun 验收必须能用 `hwlab-cli case run ` 从 case registry repo 读取 case 定义,按 `subject.repoLocalPath` 和 `subject.commitId` 创建隔离 subject worktree,产出 isolated run state 和 case registry repo evidence;compile-only case 的通过条件是 Keil build job terminal success,并明确记录 downloadSkipped。 -7. `d601-f103-v2-compile` 的真实验收只覆盖 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 通过,compile-only CaseRun 通过也不等同于 Code Agent 评测通过。 -8. agent-task CaseRun 的真实验收必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,agent 在隔离 subject worktree 内产生可审计 diff,并由 CaseRun 对该 diff 执行编译、下载或 I/O smoke 等验证动作。 +6. CaseRun 流程必须能用 `hwlab-cli case run ` 从 case registry repo 读取 case 定义,按 `subject.repoLocalPath` 和 `subject.commitId` 创建隔离 subject worktree,生成 Code Agent prompt/context,记录 agent session/trace/conversation/provider,采集 subject worktree diff,执行 compile-only smoke,并产出 isolated run state 和 case registry repo evidence;当前版本只记录流程证据,不设置通过条件、不自动评价 evidence,并明确记录 downloadSkipped。 +7. `d601-f103-v2-compile` 的真实流程只覆盖当前编排链路和 compile-only smoke;操作员直接运行 `hwpod-cli build`、直接调用 Keil 或只检查 node 侧 job,不等同于 CaseRun 流程跑完。 +8. agent-task CaseRun 的真实流程必须证明 `case run` 已创建或调用 Code Agent / AgentRun session,任务 prompt 已进入 agent 上下文,CaseRun 已采集隔离 subject worktree diff,并已继续执行编译、下载或 I/O smoke 等后续动作;当前版本只记录这些事实,不自动判断 agent 是否完成任务。 diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index ece6c3f4..ba73ac31 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -113,6 +113,72 @@ test("case run exposes Keil hex and axf artifacts in evidence summary", () => { assert.deepEqual(artifactsFromKeilStatusForTest({ result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }), ["F:\\out.hex", "F:\\out.axf"]); }); +test("case run orchestrates agent prompt, diff capture, and compile evidence without auto evaluation", async () => { + const root = await mkdtempCaseRoot(); + const caseRepo = path.join(root, "hwlab-case-registry"); + const cwd = path.join(root, "hwlab"); + const caseDir = path.join(caseRepo, "cases", "d601-f103-v2-compile"); + try { + await mkdir(path.join(caseRepo, ".git"), { recursive: true }); + await writeFile(path.join(caseRepo, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + await mkdir(caseDir, { recursive: true }); + await mkdir(cwd, { recursive: true }); + await writeFile(path.join(caseDir, "case.json"), JSON.stringify({ + contractVersion: "hwpod-case-v1", + caseId: "d601-f103-v2-compile", + title: "D601-F103-V2 Keil compile-only CaseRun", + hwpodSpec: "hwpod-spec.yaml", + subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline" }, + runtime: { apiUrl: "http://api.test", webUrl: "http://web.test" } + }, null, 2), "utf8"); + await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); + + const requests: any[] = []; + const result = await runHwlabCli([ + "case", + "run", + "d601-f103-v2-compile", + "--case-repo", + caseRepo, + "--run-id", + "run-agent-flow", + "--poll-interval-ms", + "10", + "--agent-poll-interval-ms", + "10" + ], { + cwd, + now: () => "2026-06-05T00:00:00.000Z", + sleep: async () => undefined, + env: { HWLAB_API_KEY: "hwl_live_test_case_agent_key" }, + runProcess: async () => ({ command: [], exitCode: 0, stdout: JSON.stringify({ body: { results: [{ output: { stdout: JSON.stringify({ job_id: "job-compile-1" }) } }] } }), stderr: "" }), + fetchImpl: caseRunFlowFetch(requests) + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.payload.action, "case.run"); + assert.equal(result.payload.status, "completed"); + assert.equal(result.payload.evidence.status, "recorded"); + assert.equal(result.payload.evidence.autoEvaluation, false); + assert.equal(result.payload.evidence.ok, undefined); + assert.equal(result.payload.agent.stageStatus, "completed"); + assert.equal(result.payload.agent.traceId.startsWith("trc_case_"), true); + assert.match(result.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u); + assert.equal(result.payload.build.hwpodExitCode, 0); + assert.equal(result.payload.build.keilStatus, "completed"); + + const prompt = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-prompt.md"), "utf8"); + assert.match(prompt, /without auto-grading/u); + const diff = await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "agent-diff.patch"), "utf8"); + assert.match(diff, /printf\("hello"\)/u); + assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/sessions"), true); + assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat"), true); + assert.equal(requests.some((item) => item.url === "http://web.test/v1/agent/chat/result/" + result.payload.agent.traceId), true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + async function mkdtempCaseRoot() { return await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-"))); } @@ -141,3 +207,39 @@ function subjectWorktreeFetch(requests: any[]) { ] } }), { status: 200, headers: { "content-type": "application/json" } }); }; } + +function caseRunFlowFetch(requests: any[]) { + let resultPolls = 0; + return async (url: RequestInfo | URL, init?: RequestInit) => { + const rawBody = String(init?.body ?? "{}"); + const body = rawBody ? JSON.parse(rawBody) : {}; + requests.push({ url: String(url), body }); + if (String(url) === "http://web.test/v1/agent/sessions") { + return new Response(JSON.stringify({ ok: true, session: { sessionId: "ses_case_run", conversationId: body.conversationId, threadId: "thread-case", providerProfile: body.providerProfile } }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (String(url) === "http://web.test/v1/agent/chat") { + return new Response(JSON.stringify({ ok: true, accepted: true, traceId: body.traceId, resultUrl: `/v1/agent/chat/result/${body.traceId}` }), { status: 202, headers: { "content-type": "application/json" } }); + } + if (String(url).startsWith("http://web.test/v1/agent/chat/result/")) { + resultPolls += 1; + return new Response(JSON.stringify({ ok: true, status: resultPolls > 1 ? "completed" : "running", reply: { content: "done" } }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (body.ops?.[0]?.args?.argv?.includes("status") && body.ops?.length === 1) { + return hwpodResponse([{ exitCode: 0, stdout: body.ops[0].args.argv.includes("--short") ? " M projects/01_baseline/main.c\n" : "" }]); + } + if (body.ops?.[0]?.args?.argv?.includes("--stat")) { + return hwpodResponse([{ exitCode: 0, stdout: " projects/01_baseline/main.c | 1 +\n" }]); + } + if (body.ops?.[0]?.args?.argv?.includes("--binary")) { + return hwpodResponse([{ exitCode: 0, stdout: "+printf(\"hello\");\n" }]); + } + if (body.ops?.[0]?.args?.argv?.includes("job-status")) { + return hwpodResponse([{ exitCode: 0, stdout: JSON.stringify({ status: "completed", return_code: 0, result: { hex_file: "F:\\out.hex", axf_file: "F:\\out.axf" } }) }]); + } + return subjectWorktreeFetch(requests)(url, init); + }; +} + +function hwpodResponse(outputs: Array<{ exitCode: number; stdout?: string; stderr?: string }>) { + return new Response(JSON.stringify({ body: { results: outputs.map((output) => ({ ok: output.exitCode === 0, output })) } }), { status: 200, headers: { "content-type": "application/json" } }); +} diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index 4852e934..f2227b09 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -1,19 +1,23 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; const DEFAULT_API_URL = "http://74.48.78.17:19667"; +const DEFAULT_WEB_URL = "http://74.48.78.17:19666"; const DEFAULT_POLL_INTERVAL_MS = 1000; const DEFAULT_JOB_TIMEOUT_MS = 50000; const MAX_JOB_TIMEOUT_MS = 50000; +const DEFAULT_AGENT_TIMEOUT_MS = 120000; +const HWLAB_API_KEY_PREFIX = "hwl_live_"; const STANDARD_CASE_REPO_PATH = "/root/hwlab-case-registry"; const REMOVED_CASE_REPO_PATH = "/root/hwpod-cases"; type EnvLike = Record; type ParsedArgs = Record & { _: string[] }; type FetchLike = typeof fetch; +type RunProcessLike = (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; type CaseContext = { parsed: ParsedArgs; @@ -22,6 +26,7 @@ type CaseContext = { cwd: string; now: () => string; sleep: (ms: number) => Promise; + runProcess?: RunProcessLike; rest: string[]; }; @@ -40,6 +45,9 @@ type PreparedCaseRun = { updatedAt: string; definition: Record; subject: PreparedSubjectRun; + agentTask?: PreparedAgentTask | null; + agent?: AgentRunStage | null; + agentDiff?: AgentDiffStage | null; }; type PreparedSubjectRun = { @@ -51,6 +59,42 @@ type PreparedSubjectRun = { setup: Record; }; +type PreparedAgentTask = { + prompt: string; + workspace: "subjectWorktree"; + constraints: string[]; + providerProfile: string; + projectId: string; +}; + +type AgentRunStage = { + stageStatus: string; + baseUrl: string; + projectId: string; + providerProfile: string; + conversationId: string; + sessionId: string; + threadId: string; + traceId: string; + promptPath: string; + promptSha256: string; + accepted: Record | null; + result: Record | null; + polls: number; + timedOut: boolean; + sessionResponse: Record | null; + error?: Record | null; +}; + +type AgentDiffStage = { + statusShort: string; + diffStat: string; + diffPatchPath: string; + diffPatchSha256: string; + sourceRootStatusShort: string; + requests: Record[]; +}; + export async function caseCommand(context: CaseContext) { const subcommand = context.rest[0] || "help"; if (["help", "--help", "-h"].includes(subcommand) || context.parsed.help === true) return caseHelp(); @@ -65,6 +109,8 @@ export function caseHelp() { return ok("case.help", { serviceRuntime: false, compileOnlyDefault: true, + agentTaskRequiredForRun: false, + autoEvaluation: false, stateRoot: ".state/hwlab-cli/caserun", standardCaseRepo: STANDARD_CASE_REPO_PATH, subjectRequired: { @@ -75,14 +121,21 @@ export function caseHelp() { `case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`, `case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`, - `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH}` + `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile deepseek|codex-api|minimax-m3]` + ], + notes: [ + "case build remains a compile-only HWPOD smoke stage.", + "case run prepares the subject worktree, submits a prompt to HWLAB Code Agent, records agent provenance and workspace diff, then runs compile validation.", + "This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence." ] }); } export async function runCaseRun(context: CaseContext) { const prepared = await prepareCaseRun(context, "run"); - const built = await buildCaseRun(context, prepared.run); + const agentStage = await runAgentTaskStage(context, prepared.run); + const diffStage = await collectAgentDiff(context, agentStage.run); + const built = await buildCaseRun(context, diffStage.run); const collected = await collectCaseRun(context, built.run, built.evidence); return ok("case.run", { caseId: collected.run.caseId, @@ -90,10 +143,12 @@ export async function runCaseRun(context: CaseContext) { runDir: collected.run.runDir, compileOnly: true, prepare: prepared.summary, + agent: agentSummary(agentStage.agent), + agentDiff: diffSummary(diffStage.diff), build: built.summary, collect: collected.summary, evidence: collected.evidence - }, collected.evidence.ok === true ? "succeeded" : "failed"); + }, "completed"); } export async function prepareCaseRun(context: CaseContext, action = "prepare") { @@ -106,6 +161,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { const now = context.now(); const apiUrl = apiUrlFrom(context, loaded.definition); const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }); + const agentTask = agentTaskFromDefinition(loaded.definition, caseId); const run: PreparedCaseRun = { caseId, runId, @@ -120,7 +176,8 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { createdAt: now, updatedAt: now, definition: loaded.definition, - subject + subject, + agentTask }; await mkdir(path.dirname(specPath), { recursive: true }); await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8"); @@ -134,6 +191,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { sourceSpecPath: loaded.specPath, apiUrl, compileOnly: true, + agentTask: agentTaskSummary(agentTask), subject, summary: prepareSummary(run), run @@ -143,7 +201,7 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) { const run = prepared ?? await loadOrPrepareCaseRun(context); const command = [process.execPath, path.join(context.cwd, "tools/hwpod-cli.ts"), "build", "--spec", run.specPath, "--reason", `case-run ${run.caseId} ${run.runId} compile-only`]; - const invoked = await runProcess(command, context.cwd, { + const invoked = await (context.runProcess ?? runProcess)(command, context.cwd, { ...process.env, ...context.env, HWLAB_RUNTIME_API_URL: run.apiUrl, @@ -152,19 +210,19 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase const hwpodPayload = parseJsonMaybe(invoked.stdout); const jobId = extractKeilJobId(hwpodPayload); const job = jobId ? await pollKeilJobStatus(context, run, jobId) : null; - const nodeOk = invoked.exitCode === 0 && hwpodPayload?.ok !== false && firstHwpodResultOk(hwpodPayload) !== false; - const jobOk = job ? job.ok === true : false; - const okStatus = nodeOk && jobOk; const evidence = clean({ contractVersion: "hwpod-case-run-evidence-v1", caseId: run.caseId, runId: run.runId, compileOnly: true, - ok: okStatus, - status: okStatus ? "succeeded" : "failed", + autoEvaluation: false, + status: "recorded", observedAt: context.now(), apiUrl: run.apiUrl, subject: run.subject, + agentTask: run.agentTask ? agentTaskSummary(run.agentTask) : null, + agent: run.agent ? agentSummary(run.agent) : null, + agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null, hwpod: clean({ command: commandVisibility(command), exitCode: invoked.exitCode, @@ -175,7 +233,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase artifacts: job?.summary?.artifacts ?? [], decisions: { downloadSkipped: true, - reason: "compile-only validation is the current case-run acceptance line" + reason: "flow-only run: CaseRun records agent, diff and compile evidence without auto-grading them" } }); await writeJson(path.join(run.runDir, "evidence.json"), evidence); @@ -188,7 +246,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase summary: buildSummary(evidence), evidence, run - }, evidence.status as string); + }, "completed"); } export async function collectCaseRun(context: CaseContext, prepared?: PreparedCaseRun, knownEvidence?: any) { @@ -206,9 +264,9 @@ export async function collectCaseRun(context: CaseContext, prepared?: PreparedCa caseRepo: run.caseRepo, caseRepoFiles: files, status: evidence.status, - ok: evidence.ok === true + autoEvaluation: evidence.autoEvaluation === false }; - return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, evidence.status); + return ok("case.collect", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, compileOnly: true, summary, evidence, run }, "completed"); } export function extractKeilJobId(payload: any) { @@ -269,6 +327,326 @@ async function loadCaseDefinition(caseRepo: string, caseId: string) { return { caseDir, caseFile, definition, specPath, specText }; } +function agentTaskFromDefinition(definition: Record, caseId: string) { + const value = definition.agentTask; + if (value === undefined || value === null) return defaultAgentTask(definition, caseId); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw cliError("invalid_agent_task", "case.json agentTask must be an object", { field: "agentTask" }); + } + const source = value as Record; + const prompt = text(source.prompt); + if (!prompt) throw cliError("agent_task_prompt_required", "case.json agentTask.prompt is required for Code Agent CaseRun", { field: "agentTask.prompt" }); + const workspace = text(source.workspace) || "subjectWorktree"; + if (workspace !== "subjectWorktree") throw cliError("unsupported_agent_task_workspace", "case.json agentTask.workspace must be subjectWorktree", { workspace }); + return { + prompt, + workspace, + constraints: stringArray(source.constraints), + providerProfile: text(source.providerProfile) || "deepseek", + projectId: text(source.projectId) || "prj_hwpod_workbench" + } as PreparedAgentTask; +} + +function defaultAgentTask(definition: Record, caseId: string): PreparedAgentTask { + const title = text(definition.title) || caseId; + return { + prompt: `请根据 CaseRun ${caseId}(${title})在 isolated subject worktree 中完成任务准备或最小可观察修改;不要修改 case registry 或原 subject repo checkout。`, + workspace: "subjectWorktree", + constraints: [ + "不得修改 case registry", + "不得修改原 subject repo checkout", + "只允许修改 isolated subject worktree", + "保持 compile-only 流程可继续执行" + ], + providerProfile: "deepseek", + projectId: "prj_hwpod_workbench" + }; +} + +async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { + if (!run.agentTask) { + run = await updateRun(context, run, { agentTask: defaultAgentTask(run.definition, run.caseId) }); + } + const promptPath = path.join(run.runDir, "agent-prompt.md"); + const prompt = renderAgentTaskPrompt(run, run.agentTask); + await writeFile(promptPath, prompt, "utf8"); + const promptSha256 = sha256(prompt); + const baseUrl = webUrlFrom(context, run.definition); + const providerProfile = text(context.parsed.providerProfile) || run.agentTask.providerProfile; + const projectId = text(context.parsed.projectId) || run.agentTask.projectId; + const conversationId = text(context.parsed.conversationId) || `cnv_case_${slug(run.caseId)}_${slug(run.runId)}`; + const session = await tryCreateAgentSession(context, { baseUrl, projectId, providerProfile, conversationId }); + if (!session.ok) { + const agent = agentFailureStage({ stageStatus: "session_failed", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256, error: session.error, sessionResponse: session.body }); + const nextRun = await updateRun(context, run, { agent }); + return { run: nextRun, agent }; + } + const sessionId = session.sessionId; + const threadId = session.threadId; + const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`; + const accepted = await submitAgentTask(context, { baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, prompt }); + if (!accepted.ok) { + const agent = agentFailureStage({ stageStatus: "submit_failed", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, accepted: accepted.body, error: accepted.error, sessionResponse: session.body }); + const nextRun = await updateRun(context, run, { agent }); + return { run: nextRun, agent }; + } + const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: accepted.body }); + const agent: AgentRunStage = clean({ + stageStatus: result.stageStatus, + baseUrl, + projectId, + providerProfile, + conversationId, + sessionId, + threadId, + traceId, + promptPath, + promptSha256, + accepted: compactObject(accepted.body), + result: compactObject(result.body), + polls: result.polls, + timedOut: result.timedOut, + sessionResponse: compactObject(session.body), + error: result.error + }); + const nextRun = await updateRun(context, run, { agent }); + return { run: nextRun, agent }; +} + +async function tryCreateAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) { + try { + return await createAgentSession(context, input); + } catch (error) { + const code = typeof (error as any)?.code === "string" ? (error as any).code : "agent_session_exception"; + return { ok: false, body: null, error: { code, message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null } }; + } +} + +function agentFailureStage(input: { stageStatus: string; baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId?: string; threadId?: string; traceId?: string; promptPath: string; promptSha256: string; accepted?: Record | null; sessionResponse?: Record | null; error?: Record | null }): AgentRunStage { + return clean({ + stageStatus: input.stageStatus, + baseUrl: input.baseUrl, + projectId: input.projectId, + providerProfile: input.providerProfile, + conversationId: input.conversationId, + sessionId: input.sessionId ?? "", + threadId: input.threadId ?? "", + traceId: input.traceId ?? "", + promptPath: input.promptPath, + promptSha256: input.promptSha256, + accepted: input.accepted ?? null, + result: null, + polls: 0, + timedOut: false, + sessionResponse: input.sessionResponse ?? null, + error: input.error ?? null + }) as AgentRunStage; +} + +async function createAgentSession(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string }) { + const response = await caseFetchJson(context, `${input.baseUrl}/v1/agent/sessions`, { + method: "POST", + headers: caseAgentHeaders(context), + body: JSON.stringify({ projectId: input.projectId, providerProfile: input.providerProfile, conversationId: input.conversationId, updatedByClient: "hwlab-cli.case-run" }) + }, "agent_session_create"); + if (!response.ok) return { ok: false, error: response.error, body: response.body }; + const session = response.body?.session ?? response.body; + const sessionId = text(session?.sessionId ?? session?.id); + if (!sessionId) return { ok: false, error: { code: "agent_session_id_missing", httpStatus: response.status, body: compactObject(response.body) }, body: response.body }; + return { + ok: true, + sessionId, + threadId: text(session?.threadId ?? response.body?.threadId), + body: response.body + }; +} + +async function submitAgentTask(context: CaseContext, input: { baseUrl: string; projectId: string; providerProfile: string; conversationId: string; sessionId: string; threadId: string; traceId: string; prompt: string }) { + return caseFetchJson(context, `${input.baseUrl}/v1/agent/chat`, { + method: "POST", + headers: { ...caseAgentHeaders(context), "x-trace-id": input.traceId, prefer: "respond-async", "x-hwlab-short-connection": "1" }, + body: JSON.stringify(clean({ + message: input.prompt, + conversationId: input.conversationId, + sessionId: input.sessionId, + threadId: input.threadId, + traceId: input.traceId, + providerProfile: input.providerProfile, + shortConnection: true, + projectId: input.projectId, + updatedByClient: "hwlab-cli.case-run" + })) + }, "agent_task_submit"); +} + +async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any }) { + const requestedTimeoutMs = numberOption(context.parsed.agentTimeoutMs ?? context.parsed.timeoutMs) ?? DEFAULT_AGENT_TIMEOUT_MS; + const timeoutMs = Math.max(Math.min(requestedTimeoutMs, DEFAULT_AGENT_TIMEOUT_MS), 1000); + const pollIntervalMs = Math.max(numberOption(context.parsed.agentPollIntervalMs ?? context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 250); + const startedAt = Date.now(); + const resultPath = text(input.acceptedBody?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(input.traceId)}`; + const resultUrl = resultPath.startsWith("http") ? resultPath : `${input.baseUrl}${resultPath}`; + let polls = 0; + let lastBody: any = null; + let lastStatus = 0; + while (Date.now() - startedAt < timeoutMs) { + polls += 1; + const response = await caseFetchJson(context, resultUrl, { method: "GET", headers: caseAgentHeaders(context) }, "agent_task_result"); + lastBody = response.body; + lastStatus = response.status; + if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, error: response.error }; + const status = text(lastBody?.status); + if (status && status !== "running") { + return { stageStatus: status, body: lastBody, polls, timedOut: false, error: null }; + } + await context.sleep(pollIntervalMs); + } + return { stageStatus: "timeout", body: lastBody, polls, timedOut: true, error: { code: "agent_task_timeout", traceId: input.traceId, timeoutMs, lastHttpStatus: lastStatus, lastBody: compactObject(lastBody) } }; +} + +async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) { + const status = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "status", "--short"]); + const diffStat = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--stat"]); + const diffPatch = await requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, "diff", "--binary"]); + const sourceStatus = await requestSubjectCommand(context, run, ["status", "--short"]); + const statusShort = text(status.stdout); + const patchText = text(diffPatch.stdout); + const diffPatchPath = path.join(run.runDir, "agent-diff.patch"); + await writeFile(diffPatchPath, patchText, "utf8"); + const diff: AgentDiffStage = { + statusShort, + diffStat: text(diffStat.stdout), + diffPatchPath, + diffPatchSha256: sha256(patchText), + sourceRootStatusShort: text(sourceStatus.stdout), + requests: [ + requestSummary("status", status), + requestSummary("diff-stat", diffStat), + requestSummary("diff-patch", diffPatch), + requestSummary("source-root-status", sourceStatus) + ] + }; + const nextRun = await updateRun(context, run, { agentDiff: diff }); + return { run: nextRun, diff }; +} + +async function requestSubjectCommand(context: CaseContext, run: PreparedCaseRun, argv: string[]) { + const document = await readHwpodSpec(run.specPath); + const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: `case_subject_cmd_${randomUUID()}`, + hwpodId, + nodeId: document.spec.nodeBinding.nodeId, + intent: "cmd.run", + source: { compiler: "hwlab-cli.case", specPath: run.specPath, specAuthority: "case-run-state" }, + ops: [{ + opId: "op_01_subject_cmd", + op: "cmd.run", + args: { hwpodId, workspacePath: run.subject.repoLocalPath, command: "git", argv } + }] + }; + const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(plan) + }); + const textBody = await response.text(); + const body = parseJsonMaybe(textBody) ?? { raw: textBody }; + const result = firstHwpodOutput(body); + const exitCode = numberOption(result?.exitCode) ?? (response.status >= 200 && response.status < 300 ? 0 : 1); + return { ok: response.status >= 200 && response.status < 300 && exitCode === 0, httpStatus: response.status, exitCode, stdout: text(result?.stdout), stderr: text(result?.stderr), body: compactObject(body) }; +} + +async function updateRun(context: CaseContext, run: PreparedCaseRun, patch: Partial & Record) { + const nextRun = { ...run, ...patch, updatedAt: context.now() } as PreparedCaseRun; + await writeJson(path.join(nextRun.runDir, "run.json"), nextRun); + return nextRun; +} + +function renderAgentTaskPrompt(run: PreparedCaseRun, agentTask: PreparedAgentTask) { + const constraints = [ + ...agentTask.constraints, + "只能修改 isolated subject worktree,不得修改 case registry repo。", + "不得修改原 subject repo checkout;所有源码修改必须落在 subjectWorktreePath。", + "完成后不要自行运行确定性 CaseRun 答案执行器;CaseRun 会读取 diff 并执行 compile-only 验收。" + ].filter(Boolean); + return [ + `# HWPOD CaseRun Code Agent Task`, + ``, + `caseId: ${run.caseId}`, + `runId: ${run.runId}`, + `subjectRepoLocalPath: ${run.subject.repoLocalPath}`, + `subjectCommitId: ${run.subject.commitId}`, + `subjectWorktreePath: ${run.subject.worktreePath}`, + `runLocalHwpodSpec: ${run.specPath}`, + `compileOnly: true`, + ``, + `## Task`, + agentTask.prompt, + ``, + `## Constraints`, + ...constraints.map((item) => `- ${item}`), + ``, + `## Flow`, + `- CaseRun will inspect git diff under subjectWorktreePath after your turn completes.`, + `- CaseRun will run HWPOD Keil compile-only smoke with the run-local hwpod-spec.yaml.`, + `- CaseRun records trace/session/conversation, workspace diff and Keil build evidence without auto-grading them.` + ].join("\n"); +} + +async function caseFetchJson(context: CaseContext, url: string, init: RequestInit, errorCode: string) { + try { + const response = await context.fetchImpl(url, init); + const textBody = await response.text(); + const body = parseJsonMaybe(textBody) ?? { raw: textBody }; + const ok = response.status >= 200 && response.status < 300 && body?.ok !== false; + return { ok, status: response.status, body, error: ok ? null : { code: errorCode, httpStatus: response.status, body: compactObject(body) } }; + } catch (error) { + return { ok: false, status: 0, body: null, error: { code: errorCode, message: error instanceof Error ? error.message : String(error) } }; + } +} + +function caseAgentHeaders(context: CaseContext) { + const apiKey = text(context.env.HWLAB_API_KEY); + if (!apiKey.startsWith(HWLAB_API_KEY_PREFIX)) throw cliError("api_key_required", "case run Code Agent stage requires HWLAB_API_KEY for HWLAB v0.2 Cloud Web", { allowed: "HWLAB_API_KEY" }); + return { "content-type": "application/json", authorization: `Bearer ${apiKey}` }; +} + +function webUrlFrom(context: CaseContext, definition: any) { + return text(context.parsed.baseUrl ?? context.parsed.webUrl ?? definition?.runtime?.webUrl ?? context.env.HWLAB_RUNTIME_WEB_URL ?? context.env.HWLAB_CLOUD_WEB_URL) || DEFAULT_WEB_URL; +} + +function firstHwpodOutput(body: any) { + const results = Array.isArray(body?.body?.results) ? body.body.results : Array.isArray(body?.results) ? body.results : []; + const first = results[0] ?? null; + return first?.output ?? first; +} + +function requestSummary(label: string, value: any) { + return { label, httpStatus: value.httpStatus, exitCode: value.exitCode, stdoutBytes: Buffer.byteLength(text(value.stdout)), stderr: clipText(value.stderr, 1000) }; +} + +function agentTaskSummary(agentTask: PreparedAgentTask) { + return { workspace: agentTask.workspace, providerProfile: agentTask.providerProfile, projectId: agentTask.projectId, promptSha256: sha256(agentTask.prompt), constraints: agentTask.constraints }; +} + +function agentSummary(agent: AgentRunStage) { + return clean({ stageStatus: agent.stageStatus, baseUrl: agent.baseUrl, projectId: agent.projectId, providerProfile: agent.providerProfile, conversationId: agent.conversationId, sessionId: agent.sessionId, threadId: agent.threadId, traceId: agent.traceId, promptPath: agent.promptPath, promptSha256: agent.promptSha256, polls: agent.polls, timedOut: agent.timedOut, error: agent.error }); +} + +function diffSummary(diff: AgentDiffStage) { + return { statusShort: diff.statusShort, diffStat: diff.diffStat, diffPatchPath: diff.diffPatchPath, diffPatchSha256: diff.diffPatchSha256, sourceRootStatusShort: diff.sourceRootStatusShort, requests: diff.requests }; +} + +function stringArray(value: unknown) { + return Array.isArray(value) ? value.map((item) => text(item)).filter(Boolean) : []; +} + +function sha256(value: string) { + return createHash("sha256").update(value).digest("hex"); +} + async function prepareSubjectWorktree(context: CaseContext, input: { caseId: string; runId: string; runDir: string; apiUrl: string; definition: Record; sourceSpecPath: string }) { const subject = subjectFromDefinition(input.definition); const worktreePath = subjectWorktreePath(subject.repoLocalPath, input.runId); @@ -485,11 +863,11 @@ function prepareSummary(run: PreparedCaseRun) { } function buildSummary(evidence: any) { - return { status: evidence.status, ok: evidence.ok, jobId: evidence.keilJob?.jobId ?? null, artifacts: evidence.artifacts ?? [] }; + return { status: evidence.status, autoEvaluation: evidence.autoEvaluation === false, hwpodExitCode: evidence.hwpod?.exitCode ?? null, jobId: evidence.keilJob?.jobId ?? null, keilStatus: evidence.keilJob?.status ?? null, artifacts: evidence.artifacts ?? [] }; } function renderEvidenceSummary(evidence: any) { - return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- downloadSkipped: true\n`; + return `# HWPOD CaseRun ${evidence.caseId}\n\n- runId: ${evidence.runId}\n- status: ${evidence.status}\n- autoEvaluation: false\n- compileOnly: ${evidence.compileOnly}\n- subjectRepoLocalPath: ${evidence.subject?.repoLocalPath ?? ""}\n- subjectCommitId: ${evidence.subject?.commitId ?? ""}\n- subjectWorktreePath: ${evidence.subject?.worktreePath ?? ""}\n- agentTraceId: ${evidence.agent?.traceId ?? ""}\n- agentSessionId: ${evidence.agent?.sessionId ?? ""}\n- diffPatchPath: ${evidence.agentDiff?.diffPatchPath ?? ""}\n- hwpodExitCode: ${evidence.hwpod?.exitCode ?? ""}\n- jobId: ${evidence.keilJob?.jobId ?? ""}\n- downloadSkipped: true\n`; } async function runProcess(command: string[], cwd: string, env: Record) { @@ -503,11 +881,6 @@ function hwpodResultTexts(payload: any) { return results.flatMap((item: any) => [item?.stdout, item?.stderr, item?.summary, item?.output?.stdout, item?.output?.stderr, item?.output?.summary]).filter((item: unknown) => text(item)); } -function firstHwpodResultOk(payload: any) { - const results = Array.isArray(payload?.body?.results) ? payload.body.results : []; - return results[0]?.ok; -} - function compactHwpodPayload(payload: any) { if (!payload) return null; return compactObject({ ok: payload.ok, action: payload.action, status: payload.status, traceId: payload.body?.traceId, results: payload.body?.results }); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index cd2e36cc..73f26884 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -30,6 +30,7 @@ type CliOptions = { cwd?: string; now?: () => string; sleep?: (ms: number) => Promise; + runProcess?: (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; }; export async function main(argv = process.argv.slice(2), options: CliOptions = {}) { @@ -51,7 +52,8 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) { stdinText: options.stdinText, cwd: options.cwd ?? process.cwd(), now, - sleep: options.sleep ?? wait + sleep: options.sleep ?? wait, + runProcess: options.runProcess }; const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : target === "case" ? await caseCommand({ ...context, rest: parsed._.slice(1) }) : help(); return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) };