diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 075a5af6..4786afbf 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -214,10 +214,16 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case prepare d601-f103-v2-compile --case-re bun tools/hwlab-cli/bin/hwlab-cli.ts case build d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-dir .state/hwlab-cli/caserun/ bun tools/hwlab-cli/bin/hwlab-cli.ts case collect d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-dir .state/hwlab-cli/caserun/ bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo /root/hwlab-case-registry +bun tools/hwlab-cli/bin/hwlab-cli.ts case run start d601-f103-v2-compile --case-repo /root/hwlab-case-registry --run-id +bun tools/hwlab-cli/bin/hwlab-cli.ts case run status +bun tools/hwlab-cli/bin/hwlab-cli.ts case run result +bun tools/hwlab-cli/bin/hwlab-cli.ts case run logs --tail 8000 ``` `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 定义和流程记录。 +长时间 CaseRun 必须优先使用 `case run start/status/result/logs` 短连接形态,而不是用 shell/nohup 包装同步 `case run`。`start` 只负责创建 `.state/hwlab-cli/caserun//`、启动后台 worker 并立即返回 `runId`、PID、stdout/stderr 路径和下一条查询命令;`status`、`result`、`logs` 都是短查询。`run.json` 是阶段状态权威,阶段开始时就要刷新 `status`、`stage`、`traceId/sessionId/conversationId/threadId`、AgentRun/trace 轮询信息、stdout/stderr byte count 和下一条轮询命令;长等待期间不得出现无输出、无 trace、无状态文件更新的黑洞窗口。 + 当前标准 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 通过/失败。 产物归属按两条线分开: @@ -237,5 +243,6 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts case run d601-f103-v2-compile --case-repo / 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,生成 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 是否完成任务。 +7. 长任务验收必须覆盖 `hwlab-cli case run start ` 立即返回,以及 `status/result/logs ` 在 worker 运行中能短查询到阶段、耗时、stdout/stderr byte count、trace/session/conversation/thread 信息和下一条轮询命令;不得把同步命令长时间无输出视为有效 CaseRun 操作体验。 +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 是否完成任务。 diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 596e6c30..ad0c6f77 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -113,6 +113,68 @@ 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 start returns immediately and status/result/logs are short polling commands", async () => { + const root = await mkdtempCaseRoot(); + const caseRepo = path.join(root, "hwlab-case-registry"); + const cwd = path.join(root, "hwlab"); + const caseId = "d601-f103-v2-compile"; + try { + await createCaseRepo(caseRepo, caseId); + await mkdir(cwd, { recursive: true }); + const started = await runHwlabCli([ + "case", + "run", + "start", + caseId, + "--case-repo", + caseRepo, + "--run-id", + "run-async" + ], { + cwd, + now: () => "2026-06-05T00:00:00.000Z", + startProcess: async (input) => { + await writeFile(input.stdoutPath, "worker accepted\n", "utf8"); + await writeFile(input.stderrPath, "", "utf8"); + return { pid: 4242 }; + } + }); + + assert.equal(started.exitCode, 0); + assert.equal(started.payload.action, "case.run.start"); + assert.equal(started.payload.status, "submitted"); + assert.equal(started.payload.startReturned, true); + assert.equal(started.payload.pid, 4242); + assert.match(started.payload.statusCommand, /case run status run-async/u); + assert.match(started.payload.resultCommand, /case run result run-async/u); + assert.match(started.payload.logsCommand, /case run logs run-async/u); + + const status = await runHwlabCli(["case", "run", "status", "run-async"], { cwd, now: () => "2026-06-05T00:00:01.000Z" }); + assert.equal(status.exitCode, 0); + assert.equal(status.payload.action, "case.run.status"); + assert.equal(status.payload.status, "running"); + assert.equal(status.payload.stage, "started"); + assert.equal(status.payload.pid, 4242); + assert.equal(status.payload.stdoutBytes, Buffer.byteLength("worker accepted\n")); + assert.equal(status.payload.stderrBytes, 0); + assert.match(status.payload.nextPollCommand, /case run status run-async/u); + + const result = await runHwlabCli(["case", "run", "result", "run-async"], { cwd, now: () => "2026-06-05T00:00:02.000Z" }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.action, "case.run.result"); + assert.equal(result.payload.ready, false); + assert.equal(result.payload.status, "running"); + assert.match(result.payload.nextPollCommand, /case run status run-async/u); + + const logs = await runHwlabCli(["case", "run", "logs", "run-async", "--tail", "200"], { cwd, now: () => "2026-06-05T00:00:03.000Z" }); + assert.equal(logs.exitCode, 0); + assert.equal(logs.payload.action, "case.run.logs"); + assert.equal(logs.payload.stdout, "worker accepted\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + 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"); @@ -134,6 +196,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit await writeFile(path.join(caseDir, "hwpod-spec.yaml"), hwpodSpecText(), "utf8"); const requests: any[] = []; + let sawAgentRunningState = false; const result = await runHwlabCli([ "case", "run", @@ -149,7 +212,17 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit ], { cwd, now: () => "2026-06-05T00:00:00.000Z", - sleep: async () => undefined, + sleep: async () => { + const runState = JSON.parse(await readFile(path.join(cwd, ".state", "hwlab-cli", "caserun", "run-agent-flow", "run.json"), "utf8")); + assert.equal(runState.stage, "agent-running"); + assert.equal(runState.agent.stageStatus, "running"); + assert.equal(runState.agent.sessionId, "ses_case_run"); + assert.equal(runState.agent.conversationId, "cnv_case_d601-f103-v2-compile_run-agent-flow"); + assert.equal(runState.agent.threadId, "thread-case"); + assert.match(runState.agent.traceId, /^trc_case_/u); + assert.match(runState.agent.nextPollCommand, /client agent result trc_case_/u); + sawAgentRunningState = true; + }, 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) @@ -168,6 +241,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(result.payload.build.hwpodExitCode, 0); assert.equal(result.payload.build.keilStatus, "completed"); assert.equal(result.payload.collect.autoEvaluation, false); + assert.equal(sawAgentRunningState, true); 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); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index faaa6ddd..857730d8 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -1,5 +1,7 @@ +import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { closeSync, openSync } from "node:fs"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { readHwpodSpec } from "./hwpod-harness-lib.ts"; @@ -18,6 +20,7 @@ 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 StartProcessLike = (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; type CaseContext = { parsed: ParsedArgs; @@ -27,6 +30,8 @@ type CaseContext = { now: () => string; sleep: (ms: number) => Promise; runProcess?: RunProcessLike; + startProcess?: StartProcessLike; + argv?: string[]; rest: string[]; }; @@ -48,6 +53,10 @@ type PreparedCaseRun = { agentTask?: PreparedAgentTask | null; agent?: AgentRunStage | null; agentDiff?: AgentDiffStage | null; + status?: string; + stage?: string; + evidencePath?: string; + completedAt?: string; }; type PreparedSubjectRun = { @@ -82,6 +91,10 @@ type AgentRunStage = { result: Record | null; polls: number; timedOut: boolean; + resultUrl?: string; + lastPollAt?: string; + lastHttpStatus?: number; + nextPollCommand?: string; sessionResponse: Record | null; error?: Record | null; }; @@ -101,10 +114,20 @@ export async function caseCommand(context: CaseContext) { if (subcommand === "prepare") return prepareCaseRun(context); if (subcommand === "build") return buildCaseRun(context); if (subcommand === "collect") return collectCaseRun(context); - if (subcommand === "run") return runCaseRun(context); + if (subcommand === "run") return caseRunCommand(context); throw cliError("unsupported_case_command", `unsupported case command: ${subcommand}`, { subcommand }); } +async function caseRunCommand(context: CaseContext) { + const mode = context.rest[1] || ""; + if (mode === "start") return startCaseRun(context); + if (mode === "status") return statusCaseRun(context); + if (mode === "result") return resultCaseRun(context); + if (mode === "logs") return logsCaseRun(context); + if (mode === "worker") return runCaseRunWorker(context); + return runCaseRun(context); +} + export function caseHelp() { return ok("case.help", { serviceRuntime: false, @@ -121,11 +144,16 @@ 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} [--provider-profile deepseek|codex-api|minimax-m3]` + `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile deepseek|codex-api|minimax-m3]`, + `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, + `case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`, + `case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`, + `case run logs RUN_ID [--tail 8000]` ], 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.", + "case run start/status/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.", "This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence." ] }); @@ -137,6 +165,8 @@ export async function runCaseRun(context: CaseContext) { const diffStage = await collectAgentDiff(context, agentStage.run); const built = await buildCaseRun(context, diffStage.run); const collected = await collectCaseRun(context, built.run, built.evidence); + await writeRunControl(context, collected.run, { status: "completed", stage: "completed", completedAt: context.now(), resultPath: path.join(collected.run.runDir, "result.json") }); + await writeJson(path.join(collected.run.runDir, "result.json"), compactCaseRunResult(collected)); return ok("case.run", { caseId: collected.run.caseId, runId: collected.run.runId, @@ -151,6 +181,160 @@ export async function runCaseRun(context: CaseContext) { }, "completed"); } +export async function startCaseRun(context: CaseContext) { + const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId"); + const runId = runIdFrom(context, caseId); + const runDir = path.resolve(context.cwd, text(context.parsed.runDir) || path.join(stateRoot(context), runId)); + const stdoutPath = path.join(runDir, "worker.stdout.log"); + const stderrPath = path.join(runDir, "worker.stderr.log"); + await mkdir(runDir, { recursive: true }); + const passthrough = workerPassthroughArgs(context, caseId, runId, runDir); + const command = process.execPath; + const args = [path.join(context.cwd, "tools/hwlab-cli/bin/hwlab-cli.mjs"), "case", "run", "worker", ...passthrough]; + const control = await writeRunControl(context, { + caseId, + runId, + runDir, + caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO), + caseDir: "", + caseFile: "", + sourceSpecPath: "", + specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), + apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL, + compileOnly: true, + createdAt: context.now(), + updatedAt: context.now(), + definition: {}, + subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} }, + status: "running", + stage: "queued" + }, { status: "running", stage: "queued", stdoutPath, stderrPath, startedAt: context.now(), command: commandVisibility([command, ...args]) }); + const child = await (context.startProcess ?? startDetachedProcess)({ command, args, cwd: context.cwd, env: { ...process.env, ...context.env, HWLAB_CASERUN_ASYNC_WORKER: "1" }, stdoutPath, stderrPath }); + await writeRunControl(context, control.run, { status: "running", stage: "started", pid: child.pid, stdoutPath, stderrPath, startedAt: control.control.startedAt, command: commandVisibility([command, ...args]) }); + return ok("case.run.start", { + caseId, + runId, + runDir, + pid: child.pid, + stateFile: path.join(runDir, "run.json"), + stdoutPath, + stderrPath, + statusCommand: `hwlab-cli case run status ${runId} --state-dir ${stateRoot(context)}`, + resultCommand: `hwlab-cli case run result ${runId} --state-dir ${stateRoot(context)}`, + logsCommand: `hwlab-cli case run logs ${runId} --state-dir ${stateRoot(context)} --tail 8000`, + startReturned: true, + async: true + }, "submitted"); +} + +async function runCaseRunWorker(context: CaseContext) { + const caseId = requiredText(context.parsed.caseId ?? context.rest[2], "caseId"); + const runId = requiredText(context.parsed.runId, "runId"); + const runDir = path.resolve(context.cwd, requiredText(context.parsed.runDir, "runDir")); + const workerContext = { ...context, parsed: { ...context.parsed, runId, runDir }, rest: ["run", caseId] }; + await writeRunControl(workerContext, await loadControlRunSkeleton(workerContext, caseId, runId, runDir), { status: "running", stage: "worker-running", startedAt: context.now() }); + try { + const payload = await runCaseRun(workerContext); + const run = await readRunFromDir(runDir); + await writeRunControl(workerContext, run, { status: "completed", stage: "completed", completedAt: context.now(), exitCode: 0, resultPath: path.join(runDir, "result.json") }); + await writeJson(path.join(runDir, "result.json"), payload); + return payload; + } catch (error) { + const run = await readRunFromDir(runDir).catch(() => loadControlRunSkeleton(workerContext, caseId, runId, runDir)); + await writeRunControl(workerContext, run, { status: "failed", stage: "failed", completedAt: context.now(), exitCode: 1, error: errorSummary(error) }); + throw error; + } +} + +export async function statusCaseRun(context: CaseContext) { + const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId"); + const run = await readRunById(context, runId); + const control = controlFromRun(run); + const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log"); + const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log"); + const stdoutBytes = await fileSize(stdoutPath); + const stderrBytes = await fileSize(stderrPath); + return ok("case.run.status", { + caseId: run.caseId, + runId: run.runId, + runDir: run.runDir, + stateFile: path.join(run.runDir, "run.json"), + status: text(control.status) || text(run.status) || "unknown", + stage: text(control.stage) || text(run.stage) || inferRunStage(run), + pid: control.pid ?? null, + startedAt: control.startedAt ?? run.createdAt, + updatedAt: run.updatedAt, + completedAt: control.completedAt ?? run.completedAt ?? null, + elapsedMs: elapsedMs(control.startedAt ?? run.createdAt, control.completedAt), + stdoutPath, + stderrPath, + stdoutBytes, + stderrBytes, + prepare: run.subject?.worktreePath ? prepareSummary(run) : null, + agent: run.agent ? agentSummary(run.agent) : null, + agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null, + evidencePath: text(run.evidencePath ?? control.resultPath) || null, + nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`, + resultCommand: `hwlab-cli case run result ${run.runId} --state-dir ${stateRoot(context)}`, + logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000` + }); +} + +export async function resultCaseRun(context: CaseContext) { + const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId"); + const run = await readRunById(context, runId); + const control = controlFromRun(run); + const status = text(control.status) || text(run.status) || "unknown"; + if (status !== "completed") { + return ok("case.run.result", { + caseId: run.caseId, + runId: run.runId, + runDir: run.runDir, + status, + stage: text(control.stage) || inferRunStage(run), + ready: false, + nextPollCommand: `hwlab-cli case run status ${run.runId} --state-dir ${stateRoot(context)}`, + logsCommand: `hwlab-cli case run logs ${run.runId} --state-dir ${stateRoot(context)} --tail 8000` + }, "running"); + } + const evidencePath = text(run.evidencePath) || path.join(run.runDir, "evidence.json"); + const evidence = await readJsonIfExists(evidencePath); + const result = await readJsonIfExists(path.join(run.runDir, "result.json")); + return ok("case.run.result", { + caseId: run.caseId, + runId: run.runId, + runDir: run.runDir, + status, + ready: true, + evidencePath, + resultPath: path.join(run.runDir, "result.json"), + summary: evidence ? buildSummary(evidence) : null, + agent: run.agent ? agentSummary(run.agent) : null, + agentDiff: run.agentDiff ? diffSummary(run.agentDiff) : null, + evidence: context.parsed.full === true ? evidence : compactObject(evidence), + result: context.parsed.full === true ? result : compactObject(result) + }, "completed"); +} + +export async function logsCaseRun(context: CaseContext) { + const runId = requiredText(context.parsed.runId ?? context.rest[2], "runId"); + const run = await readRunById(context, runId); + const control = controlFromRun(run); + const tailBytes = Math.max(1, Math.min(numberOption(context.parsed.tail ?? context.parsed.tailBytes) ?? 8000, 50000)); + const stdoutPath = text(control.stdoutPath) || path.join(run.runDir, "worker.stdout.log"); + const stderrPath = text(control.stderrPath) || path.join(run.runDir, "worker.stderr.log"); + return ok("case.run.logs", { + caseId: run.caseId, + runId: run.runId, + runDir: run.runDir, + tailBytes, + stdoutPath, + stderrPath, + stdout: await readTail(stdoutPath, tailBytes), + stderr: await readTail(stderrPath, tailBytes) + }); +} + export async function prepareCaseRun(context: CaseContext, action = "prepare") { const caseId = requiredText(context.parsed.caseId ?? context.rest[1], "caseId"); const caseRepo = await resolveCaseRepo(context); @@ -181,7 +365,8 @@ export async function prepareCaseRun(context: CaseContext, action = "prepare") { }; await mkdir(path.dirname(specPath), { recursive: true }); await writeFile(specPath, rewriteHwpodSpecWorkspacePath(loaded.specText, subject.workspacePath), "utf8"); - await writeJson(path.join(runDir, "run.json"), run); + const status = action === "prepare" ? "prepared" : "running"; + await writeRunControl(context, run, { status, stage: "prepared" }); return ok(`case.${action}.prepare`, { caseId, runId, @@ -237,7 +422,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase } }); await writeJson(path.join(run.runDir, "evidence.json"), evidence); - await writeJson(path.join(run.runDir, "run.json"), { ...run, updatedAt: context.now(), status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }); + await writeRunControl(context, { ...run, status: evidence.status, evidencePath: path.join(run.runDir, "evidence.json") }, { status: "running", stage: "build-completed", evidencePath: path.join(run.runDir, "evidence.json") }); return ok("case.build", { caseId: run.caseId, runId: run.runId, @@ -375,6 +560,7 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { 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)}`; + run = await updateRun(context, run, { stage: "agent-session-starting", agent: agentFailureStage({ stageStatus: "session_starting", baseUrl, projectId, providerProfile, conversationId, promptPath, promptSha256 }) }); 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 }); @@ -384,13 +570,36 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { const sessionId = session.sessionId; const threadId = session.threadId; const traceId = text(context.parsed.traceId) || `trc_case_${slug(run.caseId)}_${randomUUID().replace(/-/gu, "")}`; + run = await updateRun(context, run, { stage: "agent-submitting", agent: agentFailureStage({ stageStatus: "session_created", baseUrl, projectId, providerProfile, conversationId, sessionId, threadId, traceId, promptPath, promptSha256, sessionResponse: compactObject(session.body) }) }); 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 resultPath = text(accepted.body?.resultUrl) || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + const resultUrl = resultPath.startsWith("http") ? resultPath : `${baseUrl}${resultPath}`; + const submittedAgent: AgentRunStage = clean({ + stageStatus: "submitted", + baseUrl, + projectId, + providerProfile, + conversationId, + sessionId, + threadId, + traceId, + promptPath, + promptSha256, + accepted: compactObject(accepted.body), + result: null, + polls: 0, + timedOut: false, + resultUrl, + nextPollCommand: `hwlab-cli client agent result ${traceId} --base-url ${baseUrl}`, + sessionResponse: compactObject(session.body) + }); + run = await updateRun(context, run, { stage: "agent-running", agent: submittedAgent }); + const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: accepted.body, resultUrl, run, agent: submittedAgent }); const agent: AgentRunStage = clean({ stageStatus: result.stageStatus, baseUrl, @@ -406,6 +615,10 @@ async function runAgentTaskStage(context: CaseContext, run: PreparedCaseRun) { result: compactObject(result.body), polls: result.polls, timedOut: result.timedOut, + resultUrl, + lastPollAt: result.lastPollAt, + lastHttpStatus: result.lastHttpStatus, + nextPollCommand: `hwlab-cli client agent result ${traceId} --base-url ${baseUrl}`, sessionResponse: compactObject(session.body), error: result.error }); @@ -479,29 +692,32 @@ async function submitAgentTask(context: CaseContext, input: { baseUrl: string; p }, "agent_task_submit"); } -async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any }) { +async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage }) { 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}`; + const resultUrl = input.resultUrl || (resultPath.startsWith("http") ? resultPath : `${input.baseUrl}${resultPath}`); let polls = 0; let lastBody: any = null; let lastStatus = 0; + let lastPollAt = ""; while (Date.now() - startedAt < timeoutMs) { polls += 1; + lastPollAt = context.now(); 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 }; + if (input.run && input.agent) await updateRun(context, input.run, { stage: "agent-running", agent: { ...input.agent, stageStatus: "running", result: compactObject(lastBody), polls, resultUrl, lastPollAt, lastHttpStatus: lastStatus } }); + if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error }; const status = text(lastBody?.status); if (status && status !== "running") { - return { stageStatus: status, body: lastBody, polls, timedOut: false, error: null }; + return { stageStatus: status, body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, 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) } }; + return { stageStatus: "timeout", body: lastBody, polls, timedOut: true, lastPollAt, lastHttpStatus: lastStatus, error: { code: "agent_task_timeout", traceId: input.traceId, timeoutMs, lastHttpStatus: lastStatus, lastBody: compactObject(lastBody) } }; } async function collectAgentDiff(context: CaseContext, run: PreparedCaseRun) { @@ -560,7 +776,7 @@ async function requestSubjectCommand(context: CaseContext, run: PreparedCaseRun, 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); + await writeRunState(nextRun); return nextRun; } @@ -899,6 +1115,135 @@ async function writeJson(file: string, value: any) { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } +async function writeRunState(run: PreparedCaseRun) { + const current = await readJsonIfExists(path.join(run.runDir, "run.json")); + const control = current?._control && typeof current._control === "object" ? current._control : {}; + await writeJson(path.join(run.runDir, "run.json"), { ...run, _control: control }); +} + +async function writeRunControl(context: CaseContext, run: PreparedCaseRun, patch: Record) { + const file = path.join(run.runDir, "run.json"); + const current = await readJsonIfExists(file); + const currentControl = current?._control && typeof current._control === "object" ? current._control : {}; + const currentRun = current && typeof current === "object" ? current : {}; + const control = clean({ ...currentControl, ...patch, updatedAt: context.now() }); + const nextRun = { ...currentRun, ...run, status: text(control.status) || run.status, stage: text(control.stage) || run.stage, updatedAt: context.now(), _control: control } as PreparedCaseRun & { _control: Record }; + await writeJson(file, nextRun); + return { run: nextRun as PreparedCaseRun, control }; +} + +async function readRunById(context: CaseContext, runId: string) { + return readRunFromDir(path.resolve(context.cwd, path.join(stateRoot(context), runId))); +} + +async function readRunFromDir(runDir: string) { + return JSON.parse(await readFile(path.join(runDir, "run.json"), "utf8")) as PreparedCaseRun; +} + +async function readJsonIfExists(file: string) { + try { return JSON.parse(await readFile(file, "utf8")); } catch { return null; } +} + +function controlFromRun(run: any) { + return run?._control && typeof run._control === "object" ? run._control : {}; +} + +async function loadControlRunSkeleton(context: CaseContext, caseId: string, runId: string, runDir: string) { + const existing = await readJsonIfExists(path.join(runDir, "run.json")); + if (existing) return existing as PreparedCaseRun; + return { + caseId, + runId, + runDir, + caseRepo: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath ?? context.env.HWLAB_CASE_REPO), + caseDir: "", + caseFile: "", + sourceSpecPath: "", + specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), + apiUrl: text(context.parsed.apiUrl ?? context.parsed.apiBaseUrl ?? context.parsed.runtimeApiUrl ?? context.env.HWLAB_RUNTIME_API_URL) || DEFAULT_API_URL, + compileOnly: true, + createdAt: context.now(), + updatedAt: context.now(), + definition: {}, + subject: { repoLocalPath: "", commitId: "", subdir: "", worktreePath: "", workspacePath: "", setup: {} }, + status: "running", + stage: "worker-running" + } as PreparedCaseRun; +} + +function workerPassthroughArgs(context: CaseContext, caseId: string, runId: string, runDir: string) { + const source = context.argv ?? []; + const args: string[] = []; + for (let index = 0; index < source.length; index += 1) { + const item = source[index] ?? ""; + if (item === "case" || item === "run" || item === "start" || item === caseId) continue; + if (["--run-id", "--run-dir"].includes(item)) { index += 1; continue; } + if (item.startsWith("--run-id=") || item.startsWith("--run-dir=")) continue; + args.push(item); + } + return [caseId, "--run-id", runId, "--run-dir", runDir, ...args]; +} + +function startDetachedProcess(input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) { + closeSync(openSync(input.stdoutPath, "a")); + closeSync(openSync(input.stderrPath, "a")); + const stdout = openSync(input.stdoutPath, "a"); + const stderr = openSync(input.stderrPath, "a"); + const child = spawn(input.command, input.args, { cwd: input.cwd, env: input.env as NodeJS.ProcessEnv, detached: true, stdio: ["ignore", stdout, stderr] }); + child.unref(); + closeSync(stdout); + closeSync(stderr); + return { pid: child.pid ?? 0 }; +} + +async function fileSize(file: string) { + try { return (await stat(file)).size; } catch { return 0; } +} + +async function readTail(file: string, maxBytes: number) { + try { + const content = await readFile(file); + const start = Math.max(0, content.length - maxBytes); + return content.subarray(start).toString("utf8"); + } catch { + return ""; + } +} + +function inferRunStage(run: PreparedCaseRun) { + if (run.evidencePath) return "build-completed"; + if (run.agentDiff) return "agent-diff-collected"; + if (run.agent) return run.agent.stageStatus === "submitted" || run.agent.stageStatus === "running" ? "agent-running" : `agent-${run.agent.stageStatus}`; + if (run.subject?.worktreePath) return "prepared"; + return "queued"; +} + +function elapsedMs(startedAt: unknown, completedAt: unknown) { + const start = Date.parse(text(startedAt)); + if (!Number.isFinite(start)) return null; + const end = completedAt ? Date.parse(text(completedAt)) : Date.now(); + return Number.isFinite(end) ? Math.max(0, end - start) : null; +} + +function compactCaseRunResult(collected: any) { + return { + ok: true, + action: "case.run", + status: "completed", + caseId: collected.run.caseId, + runId: collected.run.runId, + runDir: collected.run.runDir, + compileOnly: true, + collect: collected.summary, + evidencePath: path.join(collected.run.runDir, "evidence.json"), + evidence: compactObject(collected.evidence) + }; +} + +function errorSummary(error: unknown) { + return { code: typeof (error as any)?.code === "string" ? (error as any).code : "caserun_worker_failed", message: error instanceof Error ? error.message : String(error), details: (error as any)?.details ?? null }; +} + function commandVisibility(command: string[]) { return command.map((item) => item.includes("/") || item.includes("\\") ? path.basename(item) || item : item); } diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 7d99b656..f82b7d54 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -31,6 +31,7 @@ type CliOptions = { now?: () => string; sleep?: (ms: number) => Promise; runProcess?: (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; + startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; }; export async function main(argv = process.argv.slice(2), options: CliOptions = {}) { @@ -53,7 +54,9 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) { cwd: options.cwd ?? process.cwd(), now, sleep: options.sleep ?? wait, - runProcess: options.runProcess + runProcess: options.runProcess, + startProcess: options.startProcess, + argv }; 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) };