fix: keep CaseRun status stage current

This commit is contained in:
codex
2026-06-06 16:11:15 +08:00
parent 4d91f1efb8
commit a73a3a4537
2 changed files with 194 additions and 19 deletions
+112 -11
View File
@@ -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" } });
}
+82 -8
View File
@@ -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<string, unknown>[]) {
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<string, unknown>[]) {
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<string, unknown>, 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<string, unknown>[] {
return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) as Record<string, unknown>[] : [];
}
@@ -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<string, unknown>) {
@@ -1373,6 +1399,12 @@ function controlFromRun(run: any) {
return run?._control && typeof run._control === "object" ? run._control : {};
}
function syncControlStage(control: Record<string, unknown>, 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<string, unknown>) {
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;