Files
openclaw/src/node-host/worker-support.ts
T
Peter Steinberger bb5c649e31 fix: bound pending approvals and preserve delivery order (#118252)
* refactor: centralize pending request lifecycles

Share timeout and settlement registries across worker, LSP, node-host, and approval fanout paths. Bound plugin conversation-binding approvals and preserve terminal notification ordering during slow delivery.

* style: satisfy pending registry lint rules

* test: await deferred plugin approval delivery

* test: satisfy forwarder type-aware lint
2026-08-02 17:30:53 -07:00

128 lines
4.3 KiB
TypeScript

import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayClientRequestOptions } from "../gateway/client.js";
import { createPendingRequestRegistry } from "../shared/pending-request-registry.js";
import type { NodeHostClient } from "./client.js";
import type { NodeInvokeRequestPayload } from "./invoke.js";
type NodeHostWorkerGatewayResponse =
| { type: "gateway-response"; id: string; ok: true; result: unknown }
| { type: "gateway-response"; id: string; ok: false; error: string };
type NodeHostWorkerInput =
| { type: "invoke"; request: NodeInvokeRequestPayload }
| { type: "invoke-input"; invokeId: string; seq: number; payloadJSON: string }
| { type: "invoke-cancel"; invokeId: string }
| NodeHostWorkerGatewayResponse
| { type: "stop" };
export function parseNodeHostWorkerInput(line: string): NodeHostWorkerInput | null {
try {
const parsed = asRecord(JSON.parse(line));
const type = typeof parsed?.type === "string" ? parsed.type : "";
if (type === "invoke") {
const request = asRecord(parsed?.request);
if (
request &&
typeof request.id === "string" &&
typeof request.nodeId === "string" &&
typeof request.command === "string"
) {
return { type, request: request as NodeInvokeRequestPayload };
}
return null;
}
if (type === "gateway-response") {
const id = typeof parsed?.id === "string" ? parsed.id : "";
if (!id) {
return null;
}
return parsed?.ok === true
? { type, id, ok: true, result: parsed.result }
: {
type,
id,
ok: false,
error: typeof parsed?.error === "string" ? parsed.error : "Gateway request failed",
};
}
if (type === "invoke-input") {
const invokeId = typeof parsed?.invokeId === "string" ? parsed.invokeId : "";
const seq = typeof parsed?.seq === "number" ? parsed.seq : -1;
const payloadJSON = typeof parsed?.payloadJSON === "string" ? parsed.payloadJSON : null;
return invokeId && Number.isInteger(seq) && seq >= 0 && payloadJSON !== null
? { type, invokeId, seq, payloadJSON }
: null;
}
if (type === "invoke-cancel") {
const invokeId = typeof parsed?.invokeId === "string" ? parsed.invokeId : "";
return invokeId ? { type, invokeId } : null;
}
return type === "stop" ? { type } : null;
} catch {
return null;
}
}
export class NodeHostWorkerBridgeClient implements NodeHostClient {
private nextRequestId = 1;
private readonly pending = createPendingRequestRegistry<string, unknown, undefined>();
constructor(private readonly writeMessage: (message: unknown) => void) {}
async request<T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: GatewayClientRequestOptions,
): Promise<T> {
if (method === "node.invoke.result") {
this.writeMessage({ type: "invoke-result", result: params ?? {} });
return {} as T;
}
if (method === "node.event") {
this.writeMessage({ type: "node-event", event: params ?? {} });
return {} as T;
}
const id = `gateway-${this.nextRequestId++}`;
const timeoutMs = resolveTimerTimeoutMs(opts?.timeoutMs, 15_000);
const pending = this.pending.add(id, {
value: undefined,
timeoutMs,
timeoutError: () => new Error(`Gateway request timed out: ${method}`),
});
if (!pending) {
throw new Error(`Gateway request id collision: ${id}`);
}
this.writeMessage({ type: "gateway-request", id, method, params: params ?? {}, timeoutMs });
return (await pending.promise) as T;
}
handleResponse(message: NodeHostWorkerGatewayResponse): boolean {
const pending = this.pending.take(message.id);
if (!pending) {
return false;
}
if (message.ok) {
pending.resolve(message.result);
} else {
pending.reject(new Error(message.error));
}
return true;
}
close(): void {
this.pending.rejectAll(new Error("node-host worker stopped"));
}
}
export async function stopNodeHostWorkerFromSignal(
input: { close(): void },
stop: (exitCode: number) => Promise<void>,
exitCode: number,
): Promise<void> {
const stopped = stop(exitCode);
input.close();
await stopped;
}