From a73a3a4537ad86c04f78ae633333a89290d5d8a0 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 6 Jun 2026 16:11:15 +0800 Subject: [PATCH] fix: keep CaseRun status stage current --- tools/hwlab-cli/caserun.test.ts | 123 +++++++++++++++++++++++++++++--- tools/src/hwlab-caserun-lib.ts | 90 ++++++++++++++++++++--- 2 files changed, 194 insertions(+), 19 deletions(-) diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 5bda7644..cf9cfa14 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -231,6 +231,7 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit 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); + assert.equal(runState._control.stage, "agent-running"); sawAgentRunningState = true; }, env: { HWLAB_API_KEY: "hwl_live_test_case_agent_key" }, @@ -246,16 +247,20 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit 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.equal(result.payload.agentTrace.hwpodBuildCommandCount, 1); - assert.equal(result.payload.evidence.agentTrace.commandCount, 4); - assert.equal(result.payload.evidence.agentTrace.hwpodCommandCount, 3); + assert.ok(result.payload.agentTrace.hwpodBuildCommandCount >= 1); + assert.ok(result.payload.evidence.agentTrace.commandCount > 0); + assert.ok(result.payload.evidence.agentTrace.commandCount <= 30); + assert.ok(result.payload.evidence.agentTrace.hwpodCommandCount >= 3); assert.equal(result.payload.evidence.agentTrace.keilJobCandidates[0], "20260605_203835_798515c0"); + assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /^hwpod-ctl spec validate --spec \.hwlab\/hwpod-spec\.yaml/u.test(item.normalizedCommand ?? "")), true); + assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /^hwpod inspect --spec \.hwlab\/hwpod-spec\.yaml/u.test(item.normalizedCommand ?? "")), true); + assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /^hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u.test(item.normalizedCommand ?? "")), true); assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /cat .*hwpod-cli\/SKILL\.md/u.test(item.command ?? "") && /hwpod build/u.test(item.bodyPreview ?? "")), true); - assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /^hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u.test(item.command ?? "")), true); + assert.equal(result.payload.evidence.agentTrace.commands.some((item: any) => /^hwpod build --spec \.hwlab\/hwpod-spec\.yaml/u.test(item.normalizedCommand ?? "")), true); assert.equal(result.payload.evidence.hwpod.source, "case-run-runner-post-agent-compile-check"); assert.match(result.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u); assert.equal(result.payload.build.autoEvaluation, false); - assert.equal(result.payload.build.agentTraceHwpodCommandCount, 3); + assert.ok(result.payload.build.agentTraceHwpodCommandCount >= 3); assert.equal(result.payload.build.hwpodExitCode, 0); assert.equal(result.payload.build.hwpodSource, "case-run-runner-post-agent-compile-check"); assert.equal(result.payload.build.keilStatus, "completed"); @@ -281,6 +286,92 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit } }); +test("case run status reports latest run stage when async control stage is stale", async () => { + const root = await mkdtempCaseRoot(); + const cwd = path.join(root, "hwlab"); + const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-stale-stage"); + try { + await mkdir(runDir, { recursive: true }); + await writeFile(path.join(runDir, "worker.stdout.log"), "agent finished\n", "utf8"); + await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8"); + await writeFile(path.join(runDir, "run.json"), JSON.stringify({ + caseId: "d601-f103-v2-compile", + runId: "run-stale-stage", + runDir, + caseRepo: "", + caseDir: "", + caseFile: "", + sourceSpecPath: "", + specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), + apiUrl: "http://api.test", + compileOnly: true, + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:10.000Z", + status: "running", + stage: "agent-running", + definition: {}, + subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-stale-stage", workspacePath: "", setup: {} }, + agent: { stageStatus: "completed", baseUrl: "http://web.test", projectId: "prj_hwpod_workbench", providerProfile: "deepseek", conversationId: "cnv_case", sessionId: "ses_case", threadId: "thread-case", traceId: "trc_case", promptPath: path.join(runDir, "agent-prompt.md"), promptSha256: "sha", polls: 3, lastHttpStatus: 200 }, + agentDiff: { statusShort: " M projects/01_baseline/main.c\n", diffStat: " projects/01_baseline/main.c | 1 +\n", diffPatchPath: path.join(runDir, "agent-diff.patch"), diffPatchSha256: "sha" }, + _control: { status: "running", stage: "prepared", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" } + }, null, 2), "utf8"); + + const status = await runHwlabCli(["case", "run", "status", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:12.000Z" }); + assert.equal(status.exitCode, 0); + assert.equal(status.payload.stage, "agent-diff-collected"); + assert.equal(status.payload.staleControlWarning.code, "stale_control_stage"); + assert.equal(status.payload.staleControlWarning.controlStage, "prepared"); + assert.equal(status.payload.staleControlWarning.selectedStage, "agent-diff-collected"); + assert.equal(status.payload.agent.stageStatus, "completed"); + assert.match(status.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u); + + const result = await runHwlabCli(["case", "run", "result", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:13.000Z" }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.ready, false); + assert.equal(result.payload.stage, "agent-diff-collected"); + assert.equal(result.payload.staleControlWarning.code, "stale_control_stage"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("case run status keeps worker control stage ahead of prepared worktree evidence", async () => { + const root = await mkdtempCaseRoot(); + const cwd = path.join(root, "hwlab"); + const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-worker-started"); + try { + await mkdir(runDir, { recursive: true }); + await writeFile(path.join(runDir, "worker.stdout.log"), "", "utf8"); + await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8"); + await writeFile(path.join(runDir, "run.json"), JSON.stringify({ + caseId: "d601-f103-v2-compile", + runId: "run-worker-started", + runDir, + caseRepo: "", + caseDir: "", + caseFile: "", + sourceSpecPath: "", + specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"), + apiUrl: "http://api.test", + compileOnly: true, + createdAt: "2026-06-05T00:00:00.000Z", + updatedAt: "2026-06-05T00:00:10.000Z", + status: "running", + stage: "queued", + definition: {}, + subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-worker-started", workspacePath: "", setup: {} }, + _control: { status: "running", stage: "started", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" } + }, null, 2), "utf8"); + + const status = await runHwlabCli(["case", "run", "status", "run-worker-started"], { cwd, now: () => "2026-06-05T00:00:12.000Z" }); + assert.equal(status.exitCode, 0); + assert.equal(status.payload.stage, "started"); + assert.equal(status.payload.staleControlWarning, undefined); + } 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-"))); } @@ -327,12 +418,8 @@ function caseRunFlowFetch(requests: any[]) { return new Response(JSON.stringify({ ok: true, status: resultPolls > 1 ? "completed" : "running", reply: { content: "done" } }), { status: 200, headers: { "content-type": "application/json" } }); } if (String(url).startsWith("http://web.test/v1/agent/chat/trace/")) { - return new Response(JSON.stringify({ ok: true, status: "completed", sourceEventCount: 6, renderedRowCount: 3, traceSummary: { terminalStatus: "completed", agentRun: { runId: "run_agent_trace", commandId: "cmd_agent_trace" } }, rows: [ - { seq: 1, header: "ok commandExecution", body: "command: hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml\nexitCode: 0" }, - { seq: 2, header: "ok commandExecution", body: "command: hwpod inspect --spec .hwlab/hwpod-spec.yaml\nexitCode: 0" }, - { seq: 3, header: "ok commandExecution", body: "cat .agents/skills/hwpod-cli/SKILL.md stdout: use `hwpod build` for compile exitCode=0" }, - { seq: 4, header: "ok commandExecution", body: "command: hwpod build --spec .hwlab/hwpod-spec.yaml\nstdout: {\"job_id\":\"20260605_203835_798515c0\"}\nexitCode: 0" } - ] }), { status: 200, headers: { "content-type": "application/json" } }); + const events = caseRunTraceEvents(); + return new Response(JSON.stringify({ ok: true, status: "completed", sourceEventCount: events.length, renderedRowCount: 0, traceSummary: { terminalStatus: "completed", agentRun: { runId: "run_agent_trace", commandId: "cmd_agent_trace" } }, events }), { 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" : "" }]); @@ -350,6 +437,20 @@ function caseRunFlowFetch(requests: any[]) { }; } +function caseRunTraceEvents() { + const events: any[] = [ + { seq: 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "cat .agents/skills/hwpod-cli/SKILL.md", message: "use `hwpod build` for compile", exitCode: 0 }, + { seq: 248, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "/bin/sh -lc 'hwpod-ctl spec validate --spec .hwlab/hwpod-spec.yaml'", message: "validated", exitCode: 0 }, + { seq: 323, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "/bin/sh -lc 'hwpod inspect --spec .hwlab/hwpod-spec.yaml'", message: "inspected", exitCode: 0 } + ]; + for (let index = 0; index < 31; index += 1) { + const seq = 400 + index * 2; + events.push({ seq, label: "item/commandExecution:started", type: "tool_call", toolName: "commandExecution", status: "started", command: "/bin/sh -lc 'hwpod build --spec .hwlab/hwpod-spec.yaml'", message: "commandExecution inProgress: /bin/sh -lc 'hwpod build --spec .hwlab/hwpod-spec.yaml'" }); + events.push({ seq: seq + 1, label: "item/commandExecution:completed", type: "tool_call", toolName: "commandExecution", status: "completed", command: "/bin/sh -lc 'hwpod build --spec .hwlab/hwpod-spec.yaml'", message: index === 0 ? "{\"job_id\":\"20260605_203835_798515c0\"}" : "build completed", exitCode: 0 }); + } + return events; +} + 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 c0c16779..7898b9d5 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -119,6 +119,7 @@ type AgentTraceCommand = { toolName?: string; status?: string; command?: string; + normalizedCommand?: string; bodyPreview?: string; exitCode?: number; }; @@ -283,6 +284,7 @@ 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 stage = statusStage(run, control); 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); @@ -293,7 +295,8 @@ export async function statusCaseRun(context: CaseContext) { 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), + stage: stage.stage, + staleControlWarning: stage.warning, pid: control.pid ?? null, startedAt: control.startedAt ?? run.createdAt, updatedAt: run.updatedAt, @@ -318,13 +321,15 @@ export async function resultCaseRun(context: CaseContext) { const run = await readRunById(context, runId); const control = controlFromRun(run); const status = text(control.status) || text(run.status) || "unknown"; + const stage = statusStage(run, control); if (status !== "completed") { return ok("case.run.result", { caseId: run.caseId, runId: run.runId, runDir: run.runDir, status, - stage: text(control.stage) || inferRunStage(run), + stage: stage.stage, + staleControlWarning: stage.warning, 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` @@ -922,6 +927,7 @@ function commandsFromTraceRows(rows: Record[]) { toolName: text(row.toolName ?? row.name), status: text(row.status ?? row.level), command: clipText(command, 1000), + normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), bodyPreview: clipText(body, 1200), exitCode: exitCodeFromText(body) })]; @@ -942,6 +948,7 @@ function commandsFromTraceEvents(events: Record[]) { toolName, status: text(event.status ?? event.level), command: clipText(command, 1000), + normalizedCommand: clipText(normalizedHwpodCommand(command), 1000), bodyPreview: clipText(message, 1200), exitCode: numberOption(event.exitCode) ?? exitCodeFromText(message) })]; @@ -960,10 +967,10 @@ function isCommandTraceEvent(event: Record, toolName: string) { function selectAgentTraceCommands(commands: AgentTraceCommand[]) { const selected: AgentTraceCommand[] = []; for (const command of [ - ...commands.filter((item) => mentionsHwpodBuild(item)), - ...commands.filter((item) => mentionsHwpod(item)), + ...selectRepresentativeHwpodCommands(commands), ...commands.slice(0, 10), - ...commands.slice(-10) + ...commands.slice(-10), + ...commands.filter((item) => mentionsHwpod(item)) ]) { const key = `${command.source}:${command.seq ?? ""}:${command.rowId ?? ""}:${command.command ?? ""}:${command.status ?? ""}`; if (selected.some((item) => `${item.source}:${item.seq ?? ""}:${item.rowId ?? ""}:${item.command ?? ""}:${item.status ?? ""}` === key)) continue; @@ -973,6 +980,21 @@ function selectAgentTraceCommands(commands: AgentTraceCommand[]) { return selected; } +function selectRepresentativeHwpodCommands(commands: AgentTraceCommand[]) { + return ["spec-validate", "inspect", "build"].flatMap((kind) => { + const candidates = commands.filter((command) => hwpodCommandKind(command) === kind); + return candidates.find((command) => text(command.status) === "completed" || command.exitCode === 0) ?? candidates[0] ?? []; + }); +} + +function hwpodCommandKind(command: AgentTraceCommand) { + const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); + if (/^(?:hwpod-ctl|hwpod-cli)\s+spec\s+validate(?:\s|$)/iu.test(raw)) return "spec-validate"; + if (/^hwpod\s+inspect(?:\s|$)/iu.test(raw)) return "inspect"; + if (/^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw)) return "build"; + return ""; +} + function arrayOfObjects(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record[] : []; } @@ -1001,15 +1023,19 @@ function exitCodeFromText(value: string) { } function mentionsHwpod(command: AgentTraceCommand) { - const raw = unquoteShellCommand(text(command.command)); + const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); return /^(?:hwpod|hwpod-ctl|hwpod-cli)(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts(?:\s|$)/iu.test(raw); } function mentionsHwpodBuild(command: AgentTraceCommand) { - const raw = unquoteShellCommand(text(command.command)); + const raw = normalizedHwpodCommand(command.normalizedCommand || command.command || ""); return /^(?:hwpod|hwpod-cli)\s+build(?:\s|$)/iu.test(raw) || /^(?:bun\s+)?tools\/hwpod-cli\.ts\s+build(?:\s|$)/iu.test(raw); } +function normalizedHwpodCommand(value: string) { + return unquoteShellCommand(text(value)).replace(/\s+/gu, " ").trim(); +} + function unquoteShellCommand(value: string) { return value.replace(/^\/bin\/sh\s+-lc\s+/u, "").replace(/^['"]|['"]$/gu, "").trim(); } @@ -1343,7 +1369,7 @@ async function writeJson(file: string, value: any) { 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 }); + await writeJson(path.join(run.runDir, "run.json"), { ...run, _control: syncControlStage(control, run) }); } async function writeRunControl(context: CaseContext, run: PreparedCaseRun, patch: Record) { @@ -1373,6 +1399,12 @@ function controlFromRun(run: any) { return run?._control && typeof run._control === "object" ? run._control : {}; } +function syncControlStage(control: Record, run: PreparedCaseRun) { + const status = text(control.status); + if (status && !["completed", "failed"].includes(status)) return clean({ ...control, stage: latestRunStage(run) }); + return 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; @@ -1443,6 +1475,48 @@ function inferRunStage(run: PreparedCaseRun) { return "queued"; } +function latestRunStage(run: PreparedCaseRun) { + return selectLatestStage([text(run.stage), inferRunStage(run)]); +} + +function statusStage(run: PreparedCaseRun, control: Record) { + const controlStage = text(control.stage); + const runStage = text(run.stage); + const inferredStage = inferRunStage(run); + const selectedStage = selectLatestStage([controlStage, runStage, inferredStage]); + const terminalControl = ["completed", "failed"].includes(controlStage); + const stage = terminalControl ? controlStage : selectedStage || "queued"; + const warning = controlStage && controlStage !== stage && !terminalControl ? { + code: "stale_control_stage", + controlStage, + runStage: runStage || null, + inferredStage, + selectedStage: stage + } : undefined; + return { stage, warning }; +} + +function selectLatestStage(stages: string[]) { + return stages.filter(Boolean).reduce((best, stage) => stageRank(stage) >= stageRank(best) ? stage : best, ""); +} + +function stageRank(stage: string) { + if (stage === "completed" || stage === "failed") return 100; + if (stage === "build-completed") return 90; + if (stage === "agent-diff-collected") return 80; + if (stage === "agent-trace-collected") return 70; + if (stage === "agent-completed") return 65; + if (stage === "agent-running") return 60; + if (stage === "agent-submitting") return 50; + if (stage === "agent-session-starting") return 40; + if (stage === "worker-running") return 35; + if (stage === "started") return 34; + if (stage === "queued") return 30; + if (stage === "prepared") return 20; + if (stage.startsWith("agent-")) return 55; + return 0; +} + function elapsedMs(startedAt: unknown, completedAt: unknown) { const start = Date.parse(text(startedAt)); if (!Number.isFinite(start)) return null;