From 2d7212822336facee1309197376a9761dba18484 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 16 Jul 2026 21:33:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(caserun):=20=E7=BA=A0=E6=AD=A3=20HWPOD=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=BA=8B=E5=AE=9E=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/server-caserun-http.test.ts | 6 ++-- internal/cloud/server-caserun-http.ts | 3 +- tools/hwlab-caserun.test.ts | 25 ++++++++++++++++- tools/src/hwlab-caserun-subject.ts | 32 +++++++++++++++++++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/internal/cloud/server-caserun-http.test.ts b/internal/cloud/server-caserun-http.test.ts index 55973e74..d167e522 100644 --- a/internal/cloud/server-caserun-http.test.ts +++ b/internal/cloud/server-caserun-http.test.ts @@ -11,17 +11,19 @@ test("CaseRun internal requests reuse the existing bootstrap admin API key only const calls: Array<{ url: string; authorization: string | null }> = []; const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => { const headers = new Headers(init.headers); - calls.push({ url: String(input), authorization: headers.get("authorization") }); + calls.push({ url: input instanceof Request ? input.url : String(input), authorization: headers.get("authorization") }); return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); }; const internalFetch = caseRunInternalFetch(fetchImpl as typeof fetch, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667", { HWLAB_BOOTSTRAP_ADMIN_API_KEY: "hwl_live_test" }); await internalFetch("http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", { method: "POST" }); await internalFetch("https://lab-dev.hwpod.com/v1/hwpod-node-ops", { method: "POST" }); + await internalFetch(new Request("http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", { headers: { authorization: "Bearer caller-token" } }), { method: "POST" }); assert.deepEqual(calls, [ { url: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", authorization: "Bearer hwl_live_test" }, - { url: "https://lab-dev.hwpod.com/v1/hwpod-node-ops", authorization: null } + { url: "https://lab-dev.hwpod.com/v1/hwpod-node-ops", authorization: null }, + { url: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops", authorization: "Bearer caller-token" } ]); }); diff --git a/internal/cloud/server-caserun-http.ts b/internal/cloud/server-caserun-http.ts index 959904b4..a8a82e3a 100644 --- a/internal/cloud/server-caserun-http.ts +++ b/internal/cloud/server-caserun-http.ts @@ -223,7 +223,8 @@ export function caseRunInternalFetch(fetchImpl: typeof fetch, internalApiUrl: st return async (input: URL | RequestInfo, init: RequestInit = {}) => { const target = new URL(typeof input === "string" || input instanceof URL ? input : input.url); if (target.origin !== internalOrigin) return fetchImpl(input, init); - const headers = new Headers(init.headers); + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init.headers).forEach((value, name) => headers.set(name, value)); if (!headers.has("authorization")) headers.set("authorization", `Bearer ${apiKey}`); return fetchImpl(input, { ...init, headers }); }; diff --git a/tools/hwlab-caserun.test.ts b/tools/hwlab-caserun.test.ts index 6f361e8d..227049ec 100644 --- a/tools/hwlab-caserun.test.ts +++ b/tools/hwlab-caserun.test.ts @@ -32,11 +32,12 @@ test("subject worktree failures preserve common Windows operation identity witho nodeId: plan.nodeId, status: "blocked", results: plan.ops.map((op) => ({ opId: op.opId, op: op.op, ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "node unavailable token=hwl_live_should_not_leak" } })), - blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "node unavailable token=hwl_live_should_not_leak" } + blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "node unavailable token=hwl_live_should_not_leak", details: { dispatchMode: "none" } } }; const failure = subjectWorktreeFailureDetails(body, plan, { stderr: "" }); assert.equal(failure.partialWrite, false); + assert.equal(failure.dispatched, false); assert.equal(failure.blocker.details.dispatched, false); assert.equal(failure.operation.nodeId, "node-d601-f103-v2"); assert.deepEqual(failure.operation.ops.map((op: any) => op.opId), plan.ops.map((op) => op.opId)); @@ -44,6 +45,28 @@ test("subject worktree failures preserve common Windows operation identity witho } }); +test("subject worktree failure facts remain null unless dispatch or writes are authoritative", () => { + const plan = { + contractVersion: "hwpod-node-ops-v1", + planId: "case_subject_facts", + hwpodId: "d601-f103-v2", + nodeId: "node-d601-f103-v2", + intent: "cmd.run", + ops: [{ opId: "op_02_subject_worktree_add", op: "cmd.run", args: {} }] + }; + const unknown = subjectWorktreeFailureDetails({ status: "blocked", results: [{ opId: "op_02_subject_worktree_add", ok: false, status: "blocked" }] }, plan, { stderr: "blocked" }); + assert.equal(unknown.dispatched, null); + assert.equal(unknown.partialWrite, null); + + const dispatched = subjectWorktreeFailureDetails({ status: "failed", blocker: { details: { dispatchMode: "websocket" } }, results: [{ opId: "op_02_subject_worktree_add", ok: false, status: "failed" }] }, plan, { stderr: "failed" }); + assert.equal(dispatched.dispatched, true); + assert.equal(dispatched.partialWrite, null); + + const written = subjectWorktreeFailureDetails({ status: "failed", dispatched: true, results: [{ opId: "op_02_subject_worktree_add", ok: true, status: "completed" }] }, plan, { stderr: "later operation failed" }); + assert.equal(written.dispatched, true); + assert.equal(written.partialWrite, true); +}); + test("CaseRun records source root baseline so pre-existing dirty files are not attributed to the run", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-source-root-")); try { diff --git a/tools/src/hwlab-caserun-subject.ts b/tools/src/hwlab-caserun-subject.ts index 5617edfe..c9de6088 100644 --- a/tools/src/hwlab-caserun-subject.ts +++ b/tools/src/hwlab-caserun-subject.ts @@ -120,7 +120,7 @@ export async function requestSubjectWorktree(context: CaseContext, input: { case const status = subjectWorktreeStatus(response.status, body, { commitId: subject.commitId, worktreePath }); if (!status.ok) { const failure = subjectWorktreeFailureDetails(body, plan, status); - throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, partialWrite: failure.partialWrite, dispatched: false, operation: failure.operation, errorSummary: failure.errorSummary, blocker: failure.blocker })); + throw cliError("subject_worktree_prepare_failed", "failed to prepare isolated subject worktree on the HWPOD node", clean({ httpStatus: response.status, nodeId: document.spec.nodeBinding.nodeId, repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath, exitCode: status.exitCode, partialWrite: failure.partialWrite, dispatched: failure.dispatched, operation: failure.operation, errorSummary: failure.errorSummary, blocker: failure.blocker })); } return clean({ httpStatus: response.status, planId: plan.planId, nodeId: document.spec.nodeBinding.nodeId, exitCode: status.exitCode, operation: subjectWorktreeOperationIdentity(body, plan), stdout: clipText(status.stdout), stderr: clipText(status.stderr), keilSidecars: keilSidecarSummary(status.stdout) }); } @@ -140,7 +140,10 @@ export function subjectWorktreeStatus(httpStatus: number, body: any, expected: { export function subjectWorktreeFailureDetails(body: any, plan: any, status: { stderr?: string }) { const results = subjectWorktreeResults(body); const mutatingOpIds = new Set(["op_02_subject_worktree_add", "op_04_keil_sidecars"]); - const partialWrite = results.length > 0 ? results.some((result: any) => mutatingOpIds.has(text(result?.opId)) && result?.ok === true) : null; + const dispatched = subjectWorktreeBooleanFact(body, results, "dispatched") ?? subjectWorktreeDispatchedFromMode(body, results); + const explicitPartialWrite = subjectWorktreeBooleanFact(body, results, "partialWrite"); + const successfulMutatingResult = results.some((result: any) => mutatingOpIds.has(text(result?.opId)) && result?.ok === true); + const partialWrite = explicitPartialWrite ?? (dispatched === false ? false : successfulMutatingResult ? true : null); const sourceBlocker = body?.blocker ?? results.find((result: any) => result?.blocker)?.blocker ?? body?.error ?? null; const errorSummary = redactSubjectWorktreeError(text(sourceBlocker?.summary ?? sourceBlocker?.message ?? status.stderr) || "HWPOD subject worktree operation failed without an error summary"); const blocker = clean({ @@ -148,9 +151,30 @@ export function subjectWorktreeFailureDetails(body: any, plan: any, status: { st layer: text(sourceBlocker?.layer) || "hwpod-node", retryable: typeof sourceBlocker?.retryable === "boolean" ? sourceBlocker.retryable : true, summary: errorSummary, - details: clean({ nodeId: plan.nodeId, planId: plan.planId, hwpodId: plan.hwpodId, partialWrite, dispatched: false }) + details: clean({ nodeId: plan.nodeId, planId: plan.planId, hwpodId: plan.hwpodId, partialWrite, dispatched }) }); - return { partialWrite, errorSummary, blocker, operation: subjectWorktreeOperationIdentity(body, plan) }; + return { partialWrite, dispatched, errorSummary, blocker, operation: subjectWorktreeOperationIdentity(body, plan) }; +} + +function subjectWorktreeBooleanFact(body: any, results: any[], field: "dispatched" | "partialWrite") { + const containers = [body, body?.body]; + const values = [ + ...containers.flatMap((container) => [container?.[field], container?.blocker?.details?.[field], container?.diagnostic?.details?.[field]]), + ...results.flatMap((result: any) => [result?.[field], result?.blocker?.details?.[field], result?.blocker?.diagnostic?.details?.[field]]) + ]; + return values.find((value) => typeof value === "boolean") ?? null; +} + +function subjectWorktreeDispatchedFromMode(body: any, results: any[]) { + const containers = [body, body?.body]; + const modes = [ + ...containers.flatMap((container) => [container?.dispatchMode, container?.blocker?.details?.dispatchMode, container?.diagnostic?.details?.dispatchMode]), + ...results.flatMap((result: any) => [result?.dispatchMode, result?.blocker?.details?.dispatchMode, result?.blocker?.diagnostic?.details?.dispatchMode]) + ].map(text).filter(Boolean); + const dispatchMode = modes[0]; + if (dispatchMode === "none") return false; + if (["websocket", "direct-url", "handler", "handler-exception"].includes(dispatchMode)) return true; + return null; } function subjectWorktreeOperationIdentity(body: any, plan: any) {