fix: 恢复 HarnessRL HWPOD 重启幂等

This commit is contained in:
root
2026-07-17 04:48:32 +02:00
parent 84dc6618e6
commit 9715992cd4
11 changed files with 315 additions and 30 deletions
+4
View File
@@ -700,6 +700,7 @@ lanes:
- deploy/runtime/boot/hwlab-harnessrl-worker.sh
env:
TZ: Asia/Shanghai
HWLAB_CASERUN_INTERNAL_API_URL: http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667
observable: true
hwlab-cloud-web:
runtimeKind: cloud-web
@@ -916,6 +917,7 @@ lanes:
timeoutMs: 2500
env:
HWLAB_CLOUD_DB_URL: secretRef:hwlab-cloud-api-v03-db/database-url
HWLAB_HWPOD_OPERATION_RETENTION_SECONDS: "604800"
HWLAB_CLOUD_DB_CONTRACT: v03-redacted-presence-only
HWLAB_ACCESS_CONTROL_REQUIRED: "1"
HWLAB_SESSION_COOKIE_SAMESITE: None
@@ -1082,6 +1084,8 @@ lanes:
HARNESSRL_ACTIVITY_RETRY_MAXIMUM_INTERVAL_MS: "30000"
HARNESSRL_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS: "5"
HARNESSRL_WORKER_HEALTH_PORT: "6676"
HWLAB_CASERUN_INTERNAL_API_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667
HWLAB_RUNTIME_INTERNAL_API_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667
OTEL_SERVICE_NAME: hwlab-harnessrl-worker
services:
- serviceId: hwlab-harnessrl-api
+4 -2
View File
@@ -2,6 +2,7 @@ import { createCloudApiServer } from "./server.ts";
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
import { drainWorkbenchRealtimeConnections } from "./server-workbench-http.ts";
import { classifyRuntimeDbError } from "../db/runtime-store.ts";
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts";
const POSTGRES_TRANSIENT_UNHANDLED_REJECTION_BOUNDARY = Symbol.for("hwlab.cloud.postgresTransientUnhandledRejectionBoundaryInstalled");
@@ -9,8 +10,9 @@ export async function createCloudApiBunServer(options: any = {}) {
const env = options.env ?? process.env;
const logger = options.logger ?? console;
installPostgresTransientUnhandledRejectionBoundary({ env, logger });
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry || createHwpodNodeWsRegistry({ ...options, env });
const innerServer = createCloudApiServer({ ...options, env, hwpodNodeWsRegistry });
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry || createHwpodNodeWsRegistry({ ...options, env, runtimeStore });
const innerServer = createCloudApiServer({ ...options, env, runtimeStore, hwpodNodeWsRegistry });
try {
await innerServer.hwlabStartupReady;
} catch (error) {
+43 -1
View File
@@ -11,6 +11,7 @@ import { test } from "bun:test";
import { createCloudApiBunServer } from "./bun-server.ts";
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
import { createMemoryHwpodOperationLedger } from "./hwpod-operation-ledger.ts";
import { createCloudApiServer } from "./server.ts";
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
@@ -452,16 +453,57 @@ test("WebSocket registry reuses one authoritative operation for concurrent dupli
plan.planId = "harnessrl-run:build:v1";
const first = registry.dispatch(plan, { requestId: "req_first" }, { timeoutMs: 5000 });
const retry = registry.dispatch(plan, { requestId: "req_retry" }, { timeoutMs: 5000 });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(dispatchCount, 1);
registry.handleBunMessage(socket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: "req_first", nodeId: "pc-host-1", result: { ok: true, status: "completed", results: [{ opId: "op_1", ok: true }] } }));
assert.deepEqual(await retry, await first);
assert.equal(registry.lookup(plan.planId)?.result?.status, "completed");
assert.equal((await registry.lookup(plan.planId))?.result?.status, "completed");
const recovered = await registry.dispatch(plan, { requestId: "req_after_completion" }, { timeoutMs: 5000 });
assert.equal(recovered.status, "completed");
assert.equal(dispatchCount, 1);
registry.closeBunSocket(socket);
});
test("WebSocket registry rebuild recovers one durable plan result without redispatch", async () => {
const records = new Map();
let dispatchCount = 0;
const firstLedger = createMemoryHwpodOperationLedger({ records, now: () => "2026-07-17T02:40:00.000Z" });
const firstRegistry = createHwpodNodeWsRegistry({ operationLedger: firstLedger, now: () => "2026-07-17T02:40:00.000Z" });
const firstSocket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; dispatchCount += 1; queueMicrotask(() => { firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, results: [] } })); }); }, close() {} };
firstRegistry.openBunSocket(firstSocket);
firstRegistry.handleBunMessage(firstSocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "restart-safe-plan" };
const firstResult = await firstRegistry.dispatch(plan, { requestId: "req_first" }, { timeoutMs: 1000 });
firstRegistry.closeBunSocket(firstSocket);
const rebuiltLedger = createMemoryHwpodOperationLedger({ records, now: () => "2026-07-17T02:41:00.000Z" });
const rebuiltRegistry = createHwpodNodeWsRegistry({ operationLedger: rebuiltLedger, now: () => "2026-07-17T02:41:00.000Z" });
const recovered = await rebuiltRegistry.dispatch(plan, { requestId: "req_rebuilt" }, { timeoutMs: 1000 });
assert.deepEqual(recovered, firstResult);
assert.equal((await rebuiltRegistry.lookup(plan.planId))?.result?.status, "completed");
assert.equal(dispatchCount, 1);
});
test("WebSocket registry retries the same plan identity after pre-accept send failure", async () => {
const records = new Map();
const plan = { ...samplePlan(), nodeId: "pc-host-1", planId: "retry-same-plan" };
const firstRegistry = createHwpodNodeWsRegistry({ operationLedger: createMemoryHwpodOperationLedger({ records }) });
const failedSocket = { send(value: string) { if (JSON.parse(value).type === "hwpod-node-ops") throw new Error("connection failed before acceptance"); }, close() {} };
firstRegistry.openBunSocket(failedSocket);
firstRegistry.handleBunMessage(failedSocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
assert.equal((await firstRegistry.dispatch(plan, { requestId: "req_failed" }, { timeoutMs: 1000 })).status, "blocked");
let acceptanceCount = 0;
const retryRegistry = createHwpodNodeWsRegistry({ operationLedger: createMemoryHwpodOperationLedger({ records }) });
const retrySocket = { send(value: string) { const message = JSON.parse(value); if (message.type !== "hwpod-node-ops") return; acceptanceCount += 1; queueMicrotask(() => { retryRegistry.handleBunMessage(retrySocket, JSON.stringify({ type: "hwpod-node-ops-accepted", requestId: message.requestId, nodeId: "pc-host-1" })); retryRegistry.handleBunMessage(retrySocket, JSON.stringify({ type: "hwpod-node-ops-result", requestId: message.requestId, nodeId: "pc-host-1", result: { ok: true, status: "completed", planId: message.plan.planId, results: [] } })); }); }, close() {} };
retryRegistry.openBunSocket(retrySocket);
retryRegistry.handleBunMessage(retrySocket, JSON.stringify({ type: "register", nodeId: "pc-host-1", maxInFlight: 1 }));
const result = await retryRegistry.dispatch(plan, { requestId: "req_retry" }, { timeoutMs: 1000 });
assert.equal(result.status, "completed");
assert.equal(result.planId, plan.planId);
assert.equal(acceptanceCount, 1);
});
test("WebSocket registry keeps readiness probes separate and rejects dispatch above maxInFlight", async () => {
const sent: any[] = [];
const socket = { send(value: string) { sent.push(JSON.parse(value)); }, close() {} };
+53 -16
View File
@@ -4,6 +4,7 @@
* Responsibility: 管理主动出站 HWPOD Node 连接、并发门禁,以及用户操作与 readiness 探测的独立摘要。
*/
import { randomUUID } from "node:crypto";
import { createHwpodOperationLedger } from "./hwpod-operation-ledger.ts";
const DEFAULT_DISPATCH_TIMEOUT_MS = 30000;
@@ -28,6 +29,7 @@ type HwpodNodeConnection = {
};
type PendingDispatch = {
planId: string;
nodeId: string;
requestId: string;
connection: HwpodNodeConnection;
@@ -49,9 +51,16 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
const now = options.now ?? (() => new Date().toISOString());
const clock = options.clock ?? (() => Date.now());
const logger = options.logger ?? console;
const env = options.env ?? process.env;
const operationLedger = options.operationLedger ?? createHwpodOperationLedger({
runtimeStore: options.runtimeStore,
now,
retentionMs: positiveInteger(env.HWLAB_HWPOD_OPERATION_RETENTION_SECONDS, 0) * 1000
});
const connections = new Map<string, HwpodNodeConnection>();
const pending = new Map<string, PendingDispatch>();
const operations = new Map<string, AuthoritativeOperation>();
const admissions = new Map<string, { nodeId: string; promise: Promise<any> }>();
const socketConnections = new WeakMap<object, HwpodNodeConnection>();
function openBunSocket(socket: any) {
@@ -89,26 +98,51 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
}
function dispatch(plan: any, requestMeta: any = {}, dispatchOptions: any = {}) {
const planId = safeText(requestMeta.operationKind) === "readiness-probe" ? "" : safeText(plan?.planId);
const nodeId = safeNodeId(plan?.nodeId);
const admission = planId ? admissions.get(planId) : null;
if (admission) {
return admission.nodeId === nodeId
? admission.promise
: Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod operation ${planId} is already owned by node ${admission.nodeId}`, "hwpod_operation_identity_conflict"));
}
const promise = dispatchOperation(plan, requestMeta, dispatchOptions);
if (planId) {
admissions.set(planId, { nodeId, promise });
void promise.finally(() => admissions.delete(planId));
}
return promise;
}
async function dispatchOperation(plan: any, requestMeta: any = {}, dispatchOptions: any = {}) {
const nodeId = safeNodeId(plan?.nodeId);
const operationKind = safeText(requestMeta.operationKind) === "readiness-probe" ? "readiness-probe" : "user-operation";
const planId = operationKind === "user-operation" ? safeText(plan?.planId) : "";
const authoritative = planId ? operations.get(planId) : null;
if (authoritative) {
if (authoritative.nodeId !== nodeId) {
return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod operation ${planId} is already owned by node ${authoritative.nodeId}`, "hwpod_operation_identity_conflict"));
return blockedDispatch(plan, requestMeta, `hwpod operation ${planId} is already owned by node ${authoritative.nodeId}`, "hwpod_operation_identity_conflict");
}
return authoritative.result === null ? authoritative.promise : Promise.resolve(authoritative.result);
return authoritative.result === null ? authoritative.promise : authoritative.result;
}
const durable = planId ? await operationLedger.get(planId) : null;
if (durable?.nodeId && durable.nodeId !== nodeId) {
return blockedDispatch(plan, requestMeta, `hwpod operation ${planId} is already owned by node ${durable.nodeId}`, "hwpod_operation_identity_conflict");
}
if (durable?.status === "completed" && durable.result) {
return durable.result;
}
const connection = nodeId ? connections.get(nodeId) : null;
if (!nodeId || !connection) {
return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId || "<missing>"} is not connected through outbound WebSocket`));
return blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId || "<missing>"} is not connected through outbound WebSocket`);
}
if (inFlightCountForNode(nodeId, pending) >= connection.maxInFlight) {
return Promise.resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} reached its maxInFlight=${connection.maxInFlight} limit`, "hwpod_node_busy"));
return blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} reached its maxInFlight=${connection.maxInFlight} limit`, "hwpod_node_busy");
}
const requestId = safeText(requestMeta.requestId) || `req_hwpod_ws_${randomUUID()}`;
const timeoutMs = positiveInteger(dispatchOptions.timeoutMs, DEFAULT_DISPATCH_TIMEOUT_MS);
const operation = operationStarted(plan, requestId, now());
if (planId) await operationLedger.start({ planId, nodeId, operation });
if (operationKind === "readiness-probe") connection.latestReadinessProbe = operation;
else connection.latestOperation = operation;
const promise = new Promise((resolve) => {
@@ -117,7 +151,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
finishPendingOperation({ nodeId, requestId, connection, operationKind, operation, timer, resolve }, "timed-out", now(), "hwpod_node_unavailable");
resolve(blockedDispatch(plan, requestMeta, `hwpod-node ${nodeId} WebSocket dispatch timed out after ${timeoutMs}ms`));
}, timeoutMs);
const waiter = { nodeId, requestId, connection, operationKind, operation, timer, resolve } satisfies PendingDispatch;
const waiter = { planId, nodeId, requestId, connection, operationKind, operation, timer, resolve } satisfies PendingDispatch;
pending.set(requestId, waiter);
try {
sendJson(connection, { type: "hwpod-node-ops", requestId, plan, requestMeta });
@@ -144,15 +178,11 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
return promise;
}
function lookup(planId: string) {
async function lookup(planId: string) {
const authoritative = operations.get(safeText(planId));
if (!authoritative) return null;
return {
planId: authoritative.planId,
nodeId: authoritative.nodeId,
operation: authoritative.operation,
result: authoritative.result
};
if (authoritative) return { planId: authoritative.planId, nodeId: authoritative.nodeId, operation: authoritative.operation, result: authoritative.result };
const durable = await operationLedger.get(safeText(planId));
return durable ? { planId: durable.planId, nodeId: durable.nodeId, operation: durable.operation, result: durable.result } : null;
}
function describe() {
@@ -217,7 +247,7 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
const previous = connections.get(nodeId);
if (previous && previous !== connection) previous.socket.close();
connections.set(nodeId, connection);
sendJson(connection, { type: "ack", requestId: "register", ok: true, message: "registered", nodeId });
sendJson(connection, { type: "ack", requestId: "register", ok: true, message: "registered", nodeId, operationRetentionSeconds: Math.floor(operationLedger.retentionMs / 1000) });
return;
}
if (messageType === "heartbeat") {
@@ -233,10 +263,15 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
sendJson(connection, { type: "ack", requestId: safeText(message.requestId) || "diagnostic", ok: true, message: "diagnostic recorded" });
return;
}
if (messageType === "hwpod-node-ops-result") completeDispatch(message);
if (messageType === "hwpod-node-ops-accepted") {
const waiter = pending.get(safeText(message.requestId));
if (waiter?.planId) void operationLedger.accept(waiter.planId);
return;
}
if (messageType === "hwpod-node-ops-result") void completeDispatch(message);
}
function completeDispatch(message: any) {
async function completeDispatch(message: any) {
const requestId = safeText(message.requestId);
const waiter = requestId ? pending.get(requestId) : null;
if (!waiter) return;
@@ -251,6 +286,8 @@ export function createHwpodNodeWsRegistry(options: any = {}) {
now(),
safeText(result.blocker?.code) || null
);
if (waiter.planId) await operationLedger.complete(waiter.planId, result);
if (waiter.planId) operations.delete(waiter.planId);
waiter.resolve(result);
}
+108
View File
@@ -0,0 +1,108 @@
type HwpodOperationLedgerRecord = {
planId: string;
nodeId: string;
operation: Record<string, unknown>;
status: "started" | "accepted" | "completed";
result: any | null;
updatedAt: string;
};
export function createHwpodOperationLedger(options: any = {}) {
const runtimeStore = options.runtimeStore;
const now = options.now ?? (() => new Date().toISOString());
const retentionMs = positiveInteger(options.retentionMs, 0);
if (!runtimeStore || typeof runtimeStore.query !== "function") return createMemoryHwpodOperationLedger({ now, retentionMs });
async function get(planId: string) {
const result = await runtimeStore.query(
"SELECT operation_json FROM hardware_operations WHERE id = $1 AND operation_type = 'hwpod.node-ops' LIMIT 1",
[planId]
);
return result.rows?.[0] ? parseRecord(result.rows[0].operation_json) : null;
}
async function write(record: HwpodOperationLedgerRecord) {
await cleanup();
await runtimeStore.query(
`INSERT INTO hardware_operations (id, project_id, requested_by, operation_type, operation_json, status, requested_at, updated_at)
VALUES ($1, NULL, 'hwlab-cloud-api', 'hwpod.node-ops', $2, $3, $4, $4)
ON CONFLICT (id) DO UPDATE SET operation_json = EXCLUDED.operation_json, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at`,
[record.planId, JSON.stringify(record), record.status, record.updatedAt]
);
return record;
}
async function start(input: Omit<HwpodOperationLedgerRecord, "status" | "result" | "updatedAt">) {
const existing = await get(input.planId);
return existing ?? write({ ...input, status: "started", result: null, updatedAt: now() });
}
async function accept(planId: string) {
const existing = await get(planId);
return existing ? write({ ...existing, status: "accepted", updatedAt: now() }) : null;
}
async function complete(planId: string, result: any) {
const existing = await get(planId);
return existing ? write({ ...existing, status: "completed", result, updatedAt: now() }) : null;
}
async function cleanup() {
if (!retentionMs) return;
await runtimeStore.query(
"DELETE FROM hardware_operations WHERE operation_type = 'hwpod.node-ops' AND updated_at < $1",
[new Date(Date.now() - retentionMs).toISOString()]
);
}
return { get, start, accept, complete, retentionMs };
}
export function createMemoryHwpodOperationLedger(options: any = {}) {
const now = options.now ?? (() => new Date().toISOString());
const retentionMs = positiveInteger(options.retentionMs, 0);
const records = options.records ?? new Map<string, HwpodOperationLedgerRecord>();
function cleanup() {
if (!retentionMs) return;
const cutoff = Date.now() - retentionMs;
for (const [planId, record] of records) if (Date.parse(record.updatedAt) < cutoff) records.delete(planId);
}
async function get(planId: string) { cleanup(); return records.get(planId) ?? null; }
async function start(input: Omit<HwpodOperationLedgerRecord, "status" | "result" | "updatedAt">) {
cleanup();
const existing = records.get(input.planId);
if (existing) return existing;
const record = { ...input, status: "started", result: null, updatedAt: now() } satisfies HwpodOperationLedgerRecord;
records.set(input.planId, record);
return record;
}
async function accept(planId: string) {
const existing = records.get(planId);
if (!existing) return null;
const record = { ...existing, status: "accepted", updatedAt: now() } satisfies HwpodOperationLedgerRecord;
records.set(planId, record);
return record;
}
async function complete(planId: string, result: any) {
const existing = records.get(planId);
if (!existing) return null;
const record = { ...existing, status: "completed", result, updatedAt: now() } satisfies HwpodOperationLedgerRecord;
records.set(planId, record);
return record;
}
return { get, start, accept, complete, retentionMs, records };
}
function parseRecord(value: unknown): HwpodOperationLedgerRecord | null {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as HwpodOperationLedgerRecord : null;
} catch {
return null;
}
}
function positiveInteger(value: unknown, fallback: number) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export async function handleHwpodNodeOpsHttp(request, response, options) {
if (request.method === "GET") {
const planId = new URL(request.url, "http://hwlab.local").searchParams.get("planId")?.trim() ?? "";
if (planId) {
const operation = options.hwpodNodeWsRegistry.lookup(planId);
const operation = await options.hwpodNodeWsRegistry.lookup(planId);
sendJson(response, operation ? 200 : 404, operation
? { ok: true, status: operation.result ? "completed" : "running", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, ...operation }
: { ok: false, status: "not-found", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, planId, error: { code: "hwpod_operation_not_found", message: `hwpod operation ${planId} was not found` } });
+1 -1
View File
@@ -172,7 +172,7 @@ export function createCloudApiServer(options = {}) {
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
});
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry || createHwpodNodeWsRegistry({ ...options, env });
const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry || createHwpodNodeWsRegistry({ ...options, env, runtimeStore });
const accessController = options.accessController || createAccessController({ ...options, env, runtimeStore, gatewayRegistry, traceStore, codeAgentChatResults });
if (typeof accessController.configureCodeAgentWorkspaceContext === "function") {
accessController.configureCodeAgentWorkspaceContext({ env, fetchImpl: options.fetchImpl, traceStore, codeAgentChatResults });
+14 -3
View File
@@ -158,12 +158,18 @@ async function idempotent(registry: HarnessRLRegistry, input: { runId: string; i
const activityName = input.identity.split(":").at(-2) ?? "activity";
const existing = await registry.getActivityResult(input.runId, activityName, input.identity);
if ((existing?.output as any)?.state === "completed") return (existing!.output as any).result;
if ((existing?.output as any)?.state === "accepted" && recover) {
const output = await recover();
if (["started", "accepted"].includes((existing?.output as any)?.state) && recover) {
let output: Record<string, unknown>;
try {
output = await recover();
} catch (error: any) {
if (!authorityNotFound(error)) throw error;
output = await execute();
}
const saved = await registry.saveActivityResult({ runId: input.runId, activityName, identity: input.identity, output: { state: "completed", authorityIdentity: input.identity, result: output } });
return activityOutput(saved);
}
if (!existing) await registry.saveActivityResult({ runId: input.runId, activityName, identity: input.identity, output: { state: "accepted", authorityIdentity: input.identity } });
if (!existing) await registry.saveActivityResult({ runId: input.runId, activityName, identity: input.identity, output: { state: "started", authorityIdentity: input.identity } });
const output = await execute();
const saved = await registry.saveActivityResult({ runId: input.runId, activityName, identity: input.identity, output: { state: "completed", authorityIdentity: input.identity, result: output } });
return activityOutput(saved);
@@ -189,6 +195,11 @@ function activityOutput(result: { output: Record<string, unknown> }) {
return (result.output as any)?.state === "completed" ? (result.output as any).result : result.output;
}
function authorityNotFound(error: any) {
const code = String(error?.code ?? "");
return code === "hwpod_operation_not_found" || code.endsWith("_not_found");
}
async function manifestRefs(summary: any) {
const manifestPath = String(summary?.artifactManifestPath ?? "");
if (!manifestPath) return { path: null, sha256: null, aggregate: null };
+26
View File
@@ -58,6 +58,32 @@ test("worker restart reuses idempotent prepare/build/collect activity results",
assert.equal(registry.activityResults.size, 6);
});
test("worker uses configured cloud-api authority and resubmits the same identity after typed not-found", async () => {
const fixture = await softwareSmokeFixture();
const registry = new MemoryRegistry();
const runId = "authority-retry";
const identity = `${runId}:agent:v1`;
await registry.createRun({ runId, caseId: fixture.caseId, workflowId: `workflow-${runId}`, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", runId), runtimeApiUrl: "https://public.example.test" });
await registry.saveActivityResult({ runId, activityName: "prepare", identity: `${runId}:prepare:v1`, output: { state: "completed", result: { mode: "hardware", prepared: { run: { runId } } } } });
await registry.saveActivityResult({ runId, activityName: "agent", identity, output: { state: "started", authorityIdentity: identity } });
let submitCount = 0;
let recoverCount = 0;
const activities = createHarnessRLActivities({
registry,
env: { HWLAB_CASERUN_INTERNAL_API_URL: "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667" },
stages: {
agent: async (context: any, run: any) => { submitCount += 1; assert.equal(context.parsed.apiUrl, "http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667"); assert.equal(context.parsed.operationIdentity, identity); return { run, agent: { traceId: "trace-authority-retry" } }; },
recoverAgent: async () => { recoverCount += 1; throw Object.assign(new Error("not found"), { code: "agentrun_task_not_found" }); }
}
});
const result = await activities.agent({ runId, identity });
assert.equal(result.agent.traceId, "trace-authority-retry");
assert.equal(recoverCount, 1);
assert.equal(submitCount, 1);
const deploy = await readFile(path.join(process.cwd(), "deploy", "deploy.yaml"), "utf8");
assert.match(deploy, /HWLAB_CASERUN_INTERNAL_API_URL: http:\/\/hwlab-cloud-api\.hwlab-v03\.svc\.cluster\.local:6667/u);
});
test("AgentRun and HWPOD crash gaps reuse stable authoritative operation identities", async () => {
const fixture = await hardwareFixture();
const registry = new MemoryRegistry();
+38 -1
View File
@@ -11,6 +11,7 @@
from __future__ import annotations
import base64
import calendar
import ctypes
import ctypes.wintypes as wt
import hashlib
@@ -61,6 +62,7 @@ LOG_DIR = CONFIG_DIR / "logs"
UPDATE_DIR = CONFIG_DIR / "update"
CONFIG_PATH = CONFIG_DIR / "config.json"
STATE_PATH = CONFIG_DIR / "state.json"
OPERATION_LEDGER_PATH = CONFIG_DIR / "hwpod-operations.json"
LOG_PATH = LOG_DIR / "hwlab-node.log"
DEFAULT_CONFIG = {
@@ -832,6 +834,7 @@ class WebSocketNodeClient:
self.manual_stop = False
self.connected = False
self.diagnostic_cursor = 0
self.operation_retention_seconds = 0
def start(self) -> None:
if self.thread and self.thread.is_alive():
@@ -972,18 +975,52 @@ class WebSocketNodeClient:
message = json.loads(text)
if message.get("type") == "ack" and message.get("requestId") == "register":
self.connected = message.get("ok") is True
self.operation_retention_seconds = safe_int(message.get("operationRetentionSeconds"), 0)
self.bus.emit("connection", "connected" if self.connected else "error")
self.bus.emit("log", f"注册结果: ok={message.get('ok')} message={message.get('message')}")
return
if message.get("type") != "hwpod-node-ops":
return
request_id = str(message.get("requestId") or "req_unknown")
plan = message.get("plan") or {}
plan_id = str(plan.get("planId") or request_id)
ledger = self._operation_ledger()
existing = ledger.get(plan_id)
self._send_json({"type": "hwpod-node-ops-accepted", "nodeId": self.executor.node_id, "requestId": request_id, "planId": plan_id, "observedAt": utc_now()})
if isinstance(existing, dict) and existing.get("status") == "completed" and isinstance(existing.get("result"), dict):
self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": existing["result"], "observedAt": utc_now()})
return
ledger[plan_id] = {"status": "accepted", "acceptedAt": utc_now(), "result": None}
self._save_operation_ledger(ledger)
try:
result = self.executor.execute(message.get("plan") or {})
result = self.executor.execute(plan)
except Exception as exc:
result = {"ok": False, "status": "failed", "results": [], "blocker": {"code": "hwpod_node_ws_run_failed", "layer": "hwpod-node", "retryable": True, "summary": str(exc)}}
ledger[plan_id] = {"status": "completed", "acceptedAt": ledger[plan_id]["acceptedAt"], "completedAt": utc_now(), "result": result}
self._save_operation_ledger(ledger)
self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": result, "observedAt": utc_now()})
def _operation_ledger(self) -> dict:
try:
value = json.loads(OPERATION_LEDGER_PATH.read_text(encoding="utf-8"))
ledger = value if isinstance(value, dict) else {}
except Exception:
ledger = {}
if self.operation_retention_seconds > 0:
cutoff = time.time() - self.operation_retention_seconds
ledger = {key: value for key, value in ledger.items() if not isinstance(value, dict) or self._operation_timestamp(value) >= cutoff}
return ledger
def _save_operation_ledger(self, ledger: dict) -> None:
atomic_write_json(OPERATION_LEDGER_PATH, ledger)
def _operation_timestamp(self, value: dict) -> float:
timestamp = str(value.get("completedAt") or value.get("acceptedAt") or "")
try:
return calendar.timegm(time.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ"))
except Exception:
return time.time()
def _flush_diagnostics(self) -> None:
for event in DIAGNOSTICS.since(self.diagnostic_cursor, 20):
self._send_json({"type": "hwpod-node-diagnostic", "nodeId": self.executor.node_id, "diagnostic": event, "observedAt": utc_now()})
+23 -5
View File
@@ -86,6 +86,9 @@ export function connectHwpodNodeWs(config: any, options: any = {}) {
let closed = false;
let attempt = 0;
let readyResolve: ((value: unknown) => void) | null = null;
const operations = new Map<string, Promise<any>>();
const operationCompletedAt = new Map<string, number>();
let operationRetentionMs = 0;
const ready = new Promise((resolve) => { readyResolve = resolve; });
function connect() {
@@ -129,19 +132,24 @@ export function connectHwpodNodeWs(config: any, options: any = {}) {
const textValue = typeof raw === "string" ? raw : await new Response(raw).text();
const message = JSON.parse(textValue);
if (message?.type === "ack" && message.requestId === "register" && readyResolve) {
operationRetentionMs = Math.max(0, (numberValue(message.operationRetentionSeconds) ?? 0) * 1000);
logWsEvent("registered", { ok: message.ok === true, message: text(message.message) || null });
readyResolve(message);
readyResolve = null;
return;
}
if (message?.type !== "hwpod-node-ops") return;
pruneOperations();
const requestId = text(message.requestId) || "req_unknown";
let resultPayload;
try {
resultPayload = await executePlan(message.plan);
} catch (error) {
resultPayload = failure("hwpod-node.ws.run", error);
const planId = text(message.plan?.planId) || requestId;
sendJson({ type: "hwpod-node-ops-accepted", nodeId, requestId, planId, observedAt: now() });
let operation = operations.get(planId);
if (!operation) {
operation = Promise.resolve().then(() => executePlan(message.plan)).catch((error) => failure("hwpod-node.ws.run", error));
operations.set(planId, operation);
void operation.finally(() => operationCompletedAt.set(planId, Date.now()));
}
const resultPayload = await operation;
sendJson({ type: "hwpod-node-ops-result", nodeId, requestId, result: resultPayload, observedAt: now() });
}
@@ -149,6 +157,16 @@ export function connectHwpodNodeWs(config: any, options: any = {}) {
sendJson({ type: "heartbeat", nodeId, at: now() });
}
function pruneOperations() {
if (!operationRetentionMs) return;
const cutoff = Date.now() - operationRetentionMs;
for (const [planId, completedAt] of operationCompletedAt) {
if (completedAt >= cutoff) continue;
operationCompletedAt.delete(planId);
operations.delete(planId);
}
}
function sendJson(value: any) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
socket.send(JSON.stringify(value));