diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 92189499..7b6ca0b7 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -244,6 +244,7 @@ CaseRun 的 trace 处理必须遵循 [spec-v02-code-agent-trace.md](spec-v02-cod 产物归属按两条线分开: - case registry repo 的 `runs///` 是 CaseRun 审计产物权威入口;`.state/hwlab-cli/caserun//` 只保留短连接控制和轮询所需状态,不作为长期产物入口。registry run 目录必须包含 trace manifest,manifest 只记录文件哈希、agent trace/session、subject provenance、diff 和 Keil job 字段,不引入自动评价或门禁结论。 +- `case run`、异步 worker 完成收口和 `case run result` 刷新归档后,默认必须把当前 `runs///` 目录自动 `git add`、`git commit` 并 `git push origin HEAD` 到 case registry repo;只允许提交当前 run 目录,不得顺带提交 registry 里其他历史未跟踪 run。`registrySync` 必须写入 result/summary,说明 `pushed`、`unchanged` 或失败阶段。`--no-case-repo-record` 是完全不记录 registry 的诊断出口;`--no-case-repo-git-sync` / `--no-registry-git-sync` 只用于特殊本地调试,不能作为正常 CaseRun 产物收口方式。 - HWLAB repo 的 harness、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli` 或文档改进可以按风险进入 `v0.2` 分支;单纯文档和轻量 CLI/helper 变更可直接提交,业务代码、运行面或发布链路变更走 PR 工作流。 当前边界:CaseRun 仍是无服务化短连接 CLI 编排,不负责排队、并发、评分、自动合并、长期 run retention 或 epoch 汇总;这些能力属于后续强化学习 Harness 层。CaseRun 也不负责代替 HWLAB Code Agent 完成研发任务,后续 agent-task CaseRun 必须把 prompt/session/trace/diff/evidence 作为一等输出。AgentRun 私有 GitHub repo git transport 必须具备有界失败和可观测性后,才能把单次 CaseRun 扩展为批量 runner 或 epoch 系统。 diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index 485b3050..9b43aa5f 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -281,6 +281,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[] = []; + const runProcessCalls: string[][] = []; let sawAgentRunningState = false; const result = await runHwlabCli([ "case", @@ -315,7 +316,16 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit 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: "" }), + runProcess: async (command) => { + runProcessCalls.push(command); + const gitArgs = command.slice(1); + if (command[0] === "git" && gitArgs[0] === "status") return { command, exitCode: 0, stdout: "?? runs/d601-f103-v2-compile/run-agent-flow/\n", stderr: "" }; + if (command[0] === "git" && gitArgs[0] === "add") return { command, exitCode: 0, stdout: "", stderr: "" }; + if (command[0] === "git" && gitArgs[0] === "diff") return { command, exitCode: 1, stdout: "", stderr: "" }; + if (command[0] === "git" && gitArgs[0] === "commit") return { command, exitCode: 0, stdout: "[main abc1234] data: archive caserun run-agent-flow\n", stderr: "" }; + if (command[0] === "git" && gitArgs[0] === "push") return { command, exitCode: 0, stdout: "", stderr: "To github.com:pikasTech/hwlab-case-registry.git\n" }; + return { command, exitCode: 0, stdout: JSON.stringify({ body: { results: [{ output: { stdout: JSON.stringify({ job_id: "job-compile-1" }) } }] } }), stderr: "" }; + }, fetchImpl: caseRunFlowFetch(requests) }); @@ -348,6 +358,11 @@ test("case run orchestrates agent prompt, diff capture, and compile evidence wit assert.equal(result.payload.build.hwpodSource, "case-run-runner-post-agent-compile-check"); assert.equal(result.payload.build.keilStatus, "completed"); assert.equal(result.payload.collect.autoEvaluation, false); + assert.equal(result.payload.collect.registrySync.status, "pushed"); + assert.equal(result.payload.collect.registrySync.path, "runs/d601-f103-v2-compile/run-agent-flow"); + assert.equal(runProcessCalls.some((command) => command[0] === "git" && command[1] === "add" && command.at(-1) === "runs/d601-f103-v2-compile/run-agent-flow"), true); + assert.equal(runProcessCalls.some((command) => command[0] === "git" && command[1] === "commit" && command.includes("data: archive caserun run-agent-flow")), true); + assert.equal(runProcessCalls.some((command) => command[0] === "git" && command[1] === "push" && command.includes("origin") && command.includes("HEAD")), true); assert.equal(sawAgentRunningState, true); const registryRunDir = path.join(caseRepo, "runs", "d601-f103-v2-compile", "run-agent-flow"); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index b46529e3..e092561f 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -168,6 +168,7 @@ type CaseRegistryArchive = { files: CaseRegistryArchiveFile[]; skippedFiles: string[]; manifest: Record; + registrySync?: Record | null; }; type CaseReadableAgentArchive = { @@ -241,10 +242,14 @@ export async function runCaseRun(context: CaseContext) { const resultPath = path.join(collected.run.runDir, "result.json"); const completed = await writeRunControl(context, collected.run, { status: "completed", stage: "completed", completedAt: context.now(), resultPath }); await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run })); - const finalArchive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, completed.run, collected.evidence); - const finalCollect = finalArchive ? collectSummaryFromArchive(completed.run, collected.evidence, finalArchive) : collected.summary; + let finalArchive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: false }); + let finalCollect = finalArchive ? collectSummaryFromArchive(completed.run, collected.evidence, finalArchive) : collected.summary; await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect })); - if (context.parsed.noCaseRepoRecord !== true) await archiveCaseRunArtifacts(context, completed.run, collected.evidence); + if (context.parsed.noCaseRepoRecord !== true) { + finalArchive = await archiveCaseRunArtifacts(context, completed.run, collected.evidence, { sync: true }); + finalCollect = collectSummaryFromArchive(completed.run, collected.evidence, finalArchive); + await writeJson(resultPath, compactCaseRunResult({ ...collected, run: completed.run, summary: finalCollect })); + } return ok("case.run", { caseId: collected.run.caseId, runId: collected.run.runId, @@ -321,10 +326,12 @@ async function runCaseRunWorker(context: CaseContext) { if (workerContext.parsed.noCaseRepoRecord !== true) { const evidence = await readJsonIfExists(path.join(runDir, "evidence.json")); if (evidence) { - const archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence); + let archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: false }); + payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) }; + await writeJson(path.join(runDir, "result.json"), payload); + archive = await archiveCaseRunArtifacts(workerContext, completed.run, evidence, { sync: true }); payload = { ...payload, collect: collectSummaryFromArchive(completed.run, evidence, archive) }; await writeJson(path.join(runDir, "result.json"), payload); - await archiveCaseRunArtifacts(workerContext, completed.run, evidence); } } return payload; @@ -400,11 +407,15 @@ export async function resultCaseRun(context: CaseContext) { const evidence = await readJsonIfExists(evidencePath); const resultPath = path.join(run.runDir, "result.json"); let result = await readJsonIfExists(resultPath); - let registryArchive = evidence ? await refreshCompletedRunArchive(context, run, evidence) : null; + let registryArchive = evidence ? await refreshCompletedRunArchive(context, run, evidence, { sync: false }) : null; if (registryArchive && result && typeof result === "object") { result = { ...result, collect: registryArchive.summary }; await writeJson(resultPath, result); - registryArchive = await refreshCompletedRunArchive(context, run, evidence); + registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true }); + result = { ...result, collect: registryArchive.summary }; + await writeJson(resultPath, result); + } else if (evidence) { + registryArchive = await refreshCompletedRunArchive(context, run, evidence, { sync: true }); } return ok("case.run.result", { caseId: run.caseId, @@ -549,7 +560,7 @@ export async function buildCaseRun(context: CaseContext, prepared?: PreparedCase export async function collectCaseRun(context: CaseContext, prepared?: PreparedCaseRun, knownEvidence?: any) { const run = prepared ?? await loadRunFromDirOrFail(context); const evidence = knownEvidence ?? JSON.parse(await readFile(path.join(run.runDir, "evidence.json"), "utf8")); - const archive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, run, evidence); + const archive = context.parsed.noCaseRepoRecord === true ? null : await archiveCaseRunArtifacts(context, run, evidence, { sync: !prepared }); const summary = archive ? collectSummaryFromArchive(run, evidence, archive) : { caseRepo: run.caseRepo, caseRepoRunDir: null, @@ -1563,7 +1574,7 @@ function markdownTableText(value: unknown, maxBytes = 200) { return clipText(value, maxBytes).replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ").trim(); } -async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any): Promise { +async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRun, evidence: any, options: { sync?: boolean } = {}): Promise { const runRel = caseRegistryRunRel(run); const caseRepoRunDir = path.join(run.caseRepo, runRel); await mkdir(caseRepoRunDir, { recursive: true }); @@ -1586,11 +1597,39 @@ async function archiveCaseRunArtifacts(context: CaseContext, run: PreparedCaseRu const files: CaseRegistryArchiveFile[] = []; for (const [rel, source] of materialized) files.push(await registryArtifactFileRecord(run, caseRepoRunDir, rel, source)); - const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent); const artifactManifestPath = path.join(caseRepoRunDir, "artifact-manifest.json"); + const existingManifest = await readJsonIfExists(artifactManifestPath); + const manifest = caseRunArtifactManifest(context, run, evidence, files, skippedFiles, readableAgent, existingManifest); await writeJson(artifactManifestPath, manifest); const caseRepoFiles = [...files.map((file) => artifactRel(path.join(runRel, file.path))), artifactRel(path.join(runRel, "artifact-manifest.json"))]; - return { caseRepoRunDir, artifactManifestPath, caseRepoFiles, files, skippedFiles, manifest }; + const registrySync = options.sync === true ? await syncCaseRegistryRun(context, run, runRel) : null; + return { caseRepoRunDir, artifactManifestPath, caseRepoFiles, files, skippedFiles, manifest, registrySync }; +} + +async function syncCaseRegistryRun(context: CaseContext, run: PreparedCaseRun, runRel: string) { + if (context.parsed.noCaseRepoGitSync === true || context.parsed.noRegistryGitSync === true) { + return { status: "skipped", reason: "disabled_by_cli_flag" }; + } + const rel = artifactRel(runRel); + const before = await gitProcess(context, run.caseRepo, ["status", "--short", "--", rel]); + if (before.exitCode !== 0) return { status: "failed", stage: "status_before", command: before.command, exitCode: before.exitCode, stderr: clipText(before.stderr) }; + if (!text(before.stdout)) return { status: "unchanged", path: rel }; + const add = await gitProcess(context, run.caseRepo, ["add", "--", rel]); + if (add.exitCode !== 0) return { status: "failed", stage: "add", command: add.command, exitCode: add.exitCode, stderr: clipText(add.stderr) }; + const staged = await gitProcess(context, run.caseRepo, ["diff", "--cached", "--quiet", "--", rel]); + if (staged.exitCode === 0) return { status: "unchanged_after_add", path: rel }; + if (staged.exitCode !== 1) return { status: "failed", stage: "diff_cached", command: staged.command, exitCode: staged.exitCode, stderr: clipText(staged.stderr) }; + const commitMessage = `data: archive caserun ${run.runId}`; + const commit = await gitProcess(context, run.caseRepo, ["commit", "-m", commitMessage, "--", rel]); + if (commit.exitCode !== 0) return { status: "failed", stage: "commit", command: commit.command, exitCode: commit.exitCode, stderr: clipText(commit.stderr), stdout: clipText(commit.stdout) }; + const push = await gitProcess(context, run.caseRepo, ["push", "origin", "HEAD"]); + if (push.exitCode !== 0) return { status: "failed", stage: "push", command: push.command, exitCode: push.exitCode, stderr: clipText(push.stderr), stdout: clipText(push.stdout) }; + const commitSha = text(commit.stdout.match(/\[\S+\s+([0-9a-f]{7,40})\]/iu)?.[1]); + return clean({ status: "pushed", path: rel, commitMessage, commitSha, commitStdout: clipText(commit.stdout, 1000), pushStdout: clipText(push.stdout, 1000), pushStderr: clipText(push.stderr, 1000) }); +} + +async function gitProcess(context: CaseContext, cwd: string, args: string[]) { + return (context.runProcess ?? runProcess)(["git", ...args], cwd, { ...process.env, ...context.env }); } function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive: CaseRegistryArchive) { @@ -1601,6 +1640,7 @@ function collectSummaryFromArchive(run: PreparedCaseRun, evidence: any, archive: artifactManifestPath: archive.artifactManifestPath, artifactCount: archive.files.length + 1, skippedFiles: archive.skippedFiles, + registrySync: archive.registrySync ?? null, trace: archive.manifest.trace ?? null, traceLookup: archive.manifest.traceLookup ?? agentTraceLookupForRun(run, evidence), agentStage: archive.manifest.agentStage ?? agentStageSummary(evidence), @@ -1771,12 +1811,12 @@ function renderFinalResponseArtifact(run: PreparedCaseRun, evidence: any, finalR ].join("\n")}\n`; } -async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any) { +async function refreshCompletedRunArchive(context: CaseContext, run: PreparedCaseRun, evidence?: any, options: { sync?: boolean } = {}) { if (context.parsed.noCaseRepoRecord === true) return null; if (!text(run.caseRepo)) return null; const loadedEvidence = evidence ?? await readJsonIfExists(path.join(run.runDir, "evidence.json")); if (!loadedEvidence) return null; - const archive = await archiveCaseRunArtifacts(context, run, loadedEvidence); + const archive = await archiveCaseRunArtifacts(context, run, loadedEvidence, options); return { evidence: loadedEvidence, archive, summary: collectSummaryFromArchive(run, loadedEvidence, archive) }; } @@ -1816,12 +1856,12 @@ async function registryArtifactFileRecord(run: PreparedCaseRun, caseRepoRunDir: }); } -function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive) { +function caseRunArtifactManifest(context: CaseContext, run: PreparedCaseRun, evidence: any, files: CaseRegistryArchiveFile[], skippedFiles: string[], readableAgent: CaseReadableAgentArchive, existingManifest?: any) { return clean({ contractVersion: "hwpod-case-run-artifact-manifest-v1", caseId: run.caseId, runId: run.runId, - archivedAt: context.now(), + archivedAt: text(existingManifest?.archivedAt) || context.now(), sourceRunDir: run.runDir, caseRepoRunDir: path.join(run.caseRepo, caseRegistryRunRel(run)), trace: caseRunArtifactTrace(run, evidence),