fix(gateway): route detached announce by instance (#125946)

* test(qa): cover worker generation reload

* test(qa): anchor worker generation fixture

* test(qa): strengthen worker generation proof

* test(qa): stabilize terminal reply smoke waits

* test(qa): widen terminal reply CI budget

* test(qa): drop superseded timeout workaround

* fix(gateway): route detached announce by instance
This commit is contained in:
Peter Steinberger
2026-08-18 17:14:43 -07:00
committed by GitHub
parent 10610d9f63
commit fe816d69ef
7 changed files with 893 additions and 23 deletions
@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readQaScenarioById, readQaScenarioExecutionConfig } from "./scenario-catalog.js";
import { requireFlowScenario } from "./scenario-catalog.test-utils.js";
@@ -53,4 +55,43 @@ describe("QA inference scenario catalog", () => {
});
expect(scenario.execution.flow?.steps).toHaveLength(1);
});
it("keeps worker inference provider stages on one admitted generation", () => {
const scenario = readQaScenarioById("worker-inference-generation-reload");
const scriptPath = path.join(
import.meta.dirname,
"../../../test/e2e/qa-lab/runtime/worker-inference-generation-reload.ts",
);
const fixturePath = path.join(
import.meta.dirname,
"../../../test/e2e/qa-lab/runtime/fixtures/worker-inference-generation-provider/index.js",
);
const script = fs.readFileSync(scriptPath, "utf8");
const fixture = fs.readFileSync(fixturePath, "utf8");
expect(scenario.execution).toMatchObject({
kind: "script",
path: "test/e2e/qa-lab/runtime/worker-inference-generation-reload.ts",
timeoutMs: 900_000,
});
expect(scenario.successCriteria).toContain(
"Releasing B overrides the placement's stale A lifecycle scope and constructs the provider factory, stream policy, wrapper, and stream execution entirely from generation B with B's prepared model and runtime credential.",
);
expect(script).toContain("createPairedNodeWorkerHost");
expect(script).toContain("startQaMockOpenAiServer");
expect(script).toContain(
'const OWNERSHIP_STAGES = ["factory", "policy", "wrapper", "execution"]',
);
expect(script).toContain("hotPublishGeneration");
expect(script).toContain("startAuthInspectingProxy");
expect(script).toContain("runtimeCredentialGenerations");
expect(script).toContain("replyCounts[reply] !== 1");
expect(script).not.toContain("createWorkerInferenceExecutor");
expect(fixture).toContain('from "openclaw/plugin-sdk/llm"');
expect(fixture).toContain("prepareRuntimeAuth");
expect(fixture).toContain("createStreamFn");
expect(fixture).toContain("prepareExtraParams");
expect(fixture).toContain("wrapStreamFn");
expect(fixture).not.toContain("src/");
});
});
@@ -0,0 +1,32 @@
title: Worker inference generation reload
scenario:
id: worker-inference-generation-reload
surface: gateway
category: gateway.session-apis
coverage:
secondary:
- plugins.activation-hot-reload
- gateway.config-reload-hot-reload
objective: Prove Gateway-owned worker inference keeps its admitted provider generation when a newer generation hot-publishes after model and auth preparation but before provider stream construction.
successCriteria:
- A managed-worktree session dispatches to a real paired-node worker through an isolated Gateway, establishes generation A's placement lifecycle, and uses mock-openai for provider transport.
- After generation B publishes, a second turn resolves B's model and source credential, then pauses in its final provider-owned runtime-auth preparation while generation C hot-publishes in the same Gateway process.
- Releasing B overrides the placement's stale A lifecycle scope and constructs the provider factory, stream policy, wrapper, and stream execution entirely from generation B with B's prepared model and runtime credential.
- A subsequent turn on the same worker placement constructs the same four stages entirely from generation C, and all three turns persist their exact replies once.
docsRefs:
- docs/gateway/cloud-workers.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- src/gateway/worker-environments/inference-runtime.ts
- src/agents/simple-completion-runtime.ts
- test/e2e/qa-lab/runtime/worker-inference-generation-reload.ts
- test/e2e/qa-lab/runtime/fixtures/worker-inference-generation-provider/index.js
execution:
kind: script
path: test/e2e/qa-lab/runtime/worker-inference-generation-reload.ts
summary: Establishes a generation A paired-node worker placement, hot-publishes C while a generation B turn is paused in runtime-auth preparation, and proves B overrides the stale placement scope before a fresh C turn.
timeoutMs: 900000
args:
- --artifact-base
- ${outputDir}
@@ -1,17 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { WRITE_SCOPE } from "../../../gateway/method-scopes.js";
import { createGatewayMethodRegistry } from "../../../gateway/methods/registry.js";
import { createGatewayInstanceRuntime } from "../../../gateway/server-instance-runtime.js";
import type {
GatewayRequestContext,
GatewayRequestHandlers,
} from "../../../gateway/server-methods/types.js";
import { dispatchSubagentAnnounceAgent } from "./subagent-announce-delivery.runtime.js";
function createContext(): GatewayRequestContext {
function createContext(handlers: GatewayRequestHandlers): GatewayRequestContext {
return {
deps: {},
getRuntimeConfig: () => ({}),
getGatewayMethodRegistry: () => createRegistry(handlers),
logGateway: {
warn: vi.fn(),
error: vi.fn(),
@@ -34,30 +34,16 @@ function createRegistry(handlers: GatewayRequestHandlers) {
}
describe("subagent announce Gateway instance dispatch", () => {
let closeRuntime: (() => void) | undefined;
afterEach(() => {
closeRuntime?.();
closeRuntime = undefined;
});
it("delivers a detached announce after the originating request scope has ended", async () => {
const context = createContext();
it("delivers a detached announce through its explicit instance resolver", async () => {
const context = createContext({
agent: ({ respond }) => respond(true, { raw: true }),
});
const idempotencyKey = "detached-subagent-announce";
context.dedupe.set(`agent:${idempotencyKey}`, {
ts: Date.now(),
ok: true,
payload: { runId: "announce-run", status: "ok", summary: "delivered" },
});
const runtime = createGatewayInstanceRuntime({
getContext: () => context,
getMethodRegistry: () =>
createRegistry({
agent: ({ respond }) => respond(true, { raw: true }),
}),
isDispatchAvailable: () => true,
});
closeRuntime = runtime.close;
await expect(
dispatchSubagentAnnounceAgent(
@@ -65,7 +51,11 @@ describe("subagent announce Gateway instance dispatch", () => {
message: "Process one completed child result.",
idempotencyKey,
},
{ expectFinal: true, forceSyntheticClient: true },
{
expectFinal: true,
forceSyntheticClient: true,
resolveGatewayContext: () => context,
},
),
).resolves.toEqual({ runId: "announce-run", status: "ok", summary: "delivered" });
});
@@ -5,7 +5,7 @@
* IO without changing the announce logic itself.
*/
import { loadSessionEntry } from "../../../config/sessions/session-accessor.js";
export { dispatchGatewayLifecycleMethod as dispatchGatewayMethodInProcess } from "../../../gateway/server-recovery-runtime-context.js";
export { dispatchGatewayMethodInProcess } from "../../../gateway/server-plugin-in-process-dispatch.js";
export { getRuntimeConfig } from "../../../config/config.js";
export {
resolveAgentIdFromSessionKey,
@@ -0,0 +1,138 @@
import { appendFileSync, mkdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { streamSimple } from "openclaw/plugin-sdk/llm";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const PLUGIN_ID = "qa-worker-generation";
const PROVIDER_ID = PLUGIN_ID;
const MODEL_ID = "qa-worker-generation-model";
function requireConfig(pluginConfig) {
const generation = pluginConfig?.generation;
const tracePath = pluginConfig?.tracePath;
const barrierPath = pluginConfig?.barrierPath;
const mockProviderBaseUrl = pluginConfig?.mockProviderBaseUrl;
if (
(generation !== "A" && generation !== "B" && generation !== "C") ||
typeof tracePath !== "string" ||
!tracePath ||
typeof barrierPath !== "string" ||
!barrierPath ||
typeof mockProviderBaseUrl !== "string" ||
!mockProviderBaseUrl
) {
throw new Error("qa worker generation fixture config is incomplete");
}
return { generation, tracePath, barrierPath, mockProviderBaseUrl };
}
function createTraceWriter(tracePath, generation) {
mkdirSync(path.dirname(tracePath), { recursive: true });
return (event, facts = {}) => {
appendFileSync(
tracePath,
`${JSON.stringify({ event, generation, ...facts, at: Date.now() })}\n`,
"utf8",
);
};
}
async function readBarrierState(barrierPath) {
return await readFile(barrierPath, "utf8")
.then((value) => value.trim())
.catch(() => "");
}
async function waitForBarrierRelease(barrierPath) {
const deadline = Date.now() + 120_000;
while ((await readBarrierState(barrierPath)) !== "released") {
if (Date.now() >= deadline) {
throw new Error("qa worker generation barrier release timed out");
}
await sleep(25);
}
}
export default definePluginEntry({
id: PLUGIN_ID,
name: "QA worker generation fixture",
description: "Exercises worker inference provider ownership across plugin reload.",
register(api) {
const { generation, tracePath, barrierPath, mockProviderBaseUrl } = requireConfig(
api.pluginConfig,
);
const trace = createTraceWriter(tracePath, generation);
const sourceCredential = api.config.models?.providers?.[PROVIDER_ID]?.apiKey;
if (typeof sourceCredential !== "string" || !sourceCredential) {
throw new Error("qa worker generation provider requires a direct credential");
}
const runtimeCredential = `qa-worker-runtime-${generation}`;
trace("registered", { registrationMode: api.registrationMode });
api.registerReload({
hotPrefixes: [`plugins.entries.${PLUGIN_ID}.config`],
});
api.registerProvider({
id: PROVIDER_ID,
label: "QA worker generation provider",
auth: [],
normalizeResolvedModel: ({ model }) => {
trace("model", { modelMatches: model.id === MODEL_ID });
return {
...model,
baseUrl: mockProviderBaseUrl,
params: {
...model.params,
fixtureBaseUrl: mockProviderBaseUrl,
fixtureGeneration: generation,
},
};
},
prepareRuntimeAuth: async ({ apiKey, model }) => {
const shouldWait = (await readBarrierState(barrierPath)) === "armed";
trace("auth-prepare", {
modelGenerationMatches: model.params?.fixtureGeneration === generation,
sourceAuthMatchesGeneration: apiKey === sourceCredential,
waited: shouldWait,
});
if (shouldWait) {
await waitForBarrierRelease(barrierPath);
trace("auth-prepare-released", { waited: true });
}
trace("auth-ready", { runtimeCredentialGeneration: generation });
return { apiKey: runtimeCredential };
},
createStreamFn: ({ model }) => {
trace("factory", {
modelGenerationMatches: model.params?.fixtureGeneration === generation,
});
return streamSimple;
},
prepareExtraParams: ({ model }) => {
trace("policy", {
modelGenerationMatches: model?.params?.fixtureGeneration === generation,
});
return {};
},
wrapStreamFn: ({ model, streamFn }) => {
trace("wrapper", {
modelGenerationMatches: model?.params?.fixtureGeneration === generation,
streamPresent: Boolean(streamFn),
});
if (!streamFn) {
return undefined;
}
return (streamModel, context, options) => {
trace("execution", {
authPresent: typeof options?.apiKey === "string" && options.apiKey.length > 0,
baseUrlMatchesGeneration: streamModel.baseUrl === mockProviderBaseUrl,
modelGenerationMatches: streamModel.params?.fixtureGeneration === generation,
});
return streamFn(streamModel, context, options);
};
},
});
},
});
@@ -0,0 +1,20 @@
{
"id": "qa-worker-generation",
"name": "QA worker generation fixture",
"description": "Exercises worker inference provider ownership across plugin reload.",
"activation": {
"onStartup": true
},
"providers": ["qa-worker-generation"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"required": ["generation", "tracePath", "barrierPath", "mockProviderBaseUrl"],
"properties": {
"generation": { "type": "string", "enum": ["A", "B", "C"] },
"tracePath": { "type": "string", "minLength": 1 },
"barrierPath": { "type": "string", "minLength": 1 },
"mockProviderBaseUrl": { "type": "string", "minLength": 1 }
}
}
}
@@ -0,0 +1,649 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime";
import {
QA_EVIDENCE_FILENAME,
startQaGatewayChild,
startQaMockOpenAiServer,
type QaEvidenceSummaryJson,
} from "../../../../extensions/qa-lab/api.js";
import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js";
import {
MODEL_REF as DEFAULT_MOCK_MODEL_REF,
PROOF_TIMEOUT_MS,
waitFor,
} from "./cloud-worker-midturn-loss-fixture.js";
import workerGenerationProviderFixture from "./fixtures/worker-inference-generation-provider/index.js";
import {
closeWireServer,
connectWireClient,
createPairedNodeWorkerHost,
createPublishedWireWorkspace,
type PairedNodeWorkerHost,
type PublishedWireWorkspace,
type WireGateway,
wireMessageText,
} from "./paired-node-worker-wire-fixture.js";
import { createQaScriptEvidenceWriter } from "./script-evidence.js";
const SCENARIO_ID = "worker-inference-generation-reload";
const VERDICT_FILE = `${SCENARIO_ID}-verdict.json`;
const PLUGIN_ID = "qa-worker-generation";
const PROVIDER_ID = PLUGIN_ID;
const MODEL_ID = "qa-worker-generation-model";
const MODEL_REF = `${PROVIDER_ID}/${MODEL_ID}`;
const SESSION_KEY = "agent:qa:worker-inference-generation-reload";
const SOURCE_CREDENTIAL_PREFIX = "qa-worker-source";
const GENERATION_A_REPLY = "WORKER-GENERATION-A-OK";
const GENERATION_B_REPLY = "WORKER-GENERATION-B-OK";
const GENERATION_C_REPLY = "WORKER-GENERATION-C-OK";
const OWNERSHIP_STAGES = ["factory", "policy", "wrapper", "execution"] as const;
type Generation = "A" | "B" | "C";
type ProducerOptions = { artifactBase: string; repoRoot: string };
type TraceEvent = {
event: string;
generation: Generation;
at: number;
waited?: boolean;
authPresent?: boolean;
baseUrlMatchesGeneration?: boolean;
modelGenerationMatches?: boolean;
sourceAuthMatchesGeneration?: boolean;
streamPresent?: boolean;
};
type TurnResult = { runId?: string; status?: string; summary?: string };
function parseOptions(argv: readonly string[]): ProducerOptions {
const index = argv.indexOf("--artifact-base");
const artifactBase = index >= 0 ? argv[index + 1] : undefined;
if (!artifactBase) {
throw new Error("--artifact-base is required");
}
return { artifactBase: path.resolve(artifactBase), repoRoot: process.cwd() };
}
async function readTrace(tracePath: string): Promise<TraceEvent[]> {
return await fs
.readFile(tracePath, "utf8")
.then((text) =>
text
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as TraceEvent),
)
.catch(() => []);
}
function classifyAuthorization(value: string | undefined): Generation | "unknown" {
const credential = value?.replace(/^Bearer\s+/iu, "");
for (const generation of ["A", "B", "C"] as const) {
if (credential === `qa-worker-runtime-${generation}`) {
return generation;
}
}
return "unknown";
}
async function startAuthInspectingProxy(targetBaseUrl: string) {
const authGenerations: Array<Generation | "unknown"> = [];
const server = createServer((request, response) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
const body = Buffer.concat(chunks);
if (request.method === "POST") {
authGenerations.push(classifyAuthorization(request.headers.authorization));
}
const headers = new Headers();
for (const [name, value] of Object.entries(request.headers)) {
if (value === undefined || ["connection", "content-length", "host"].includes(name)) {
continue;
}
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
}
const upstream = await fetch(new URL(request.url ?? "/", targetBaseUrl), {
method: request.method,
headers,
...(body.length > 0 ? { body } : {}),
});
const responseHeaders: Record<string, string> = {};
upstream.headers.forEach((value, name) => {
if (!["connection", "content-length", "transfer-encoding"].includes(name)) {
responseHeaders[name] = value;
}
});
response.writeHead(upstream.status, responseHeaders);
response.end(Buffer.from(await upstream.arrayBuffer()));
})().catch((error) => {
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
response.end(error instanceof Error ? error.message : String(error));
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("worker auth inspection proxy did not bind");
}
return {
authGenerations,
baseUrl: `http://127.0.0.1:${address.port}`,
async stop() {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
function buildGenerationConfig(params: {
config: OpenClawConfig;
generation: Generation;
pluginDir: string;
tracePath: string;
barrierPath: string;
mockProviderBaseUrl: string;
}): OpenClawConfig {
const { config, generation, pluginDir, tracePath, barrierPath, mockProviderBaseUrl } = params;
const providerConfig = config.models?.providers?.[PROVIDER_ID];
return {
...config,
plugins: {
...config.plugins,
enabled: true,
allow: [...new Set([...(config.plugins?.allow ?? []), PLUGIN_ID])],
load: {
...config.plugins?.load,
paths: [...new Set([...(config.plugins?.load?.paths ?? []), pluginDir])],
},
entries: {
...config.plugins?.entries,
[PLUGIN_ID]: {
...config.plugins?.entries?.[PLUGIN_ID],
enabled: true,
config: {
generation,
tracePath,
barrierPath,
mockProviderBaseUrl,
},
},
},
},
models: {
...config.models,
providers: {
...config.models?.providers,
[PROVIDER_ID]: {
...providerConfig,
api: "openai-responses",
apiKey: `${SOURCE_CREDENTIAL_PREFIX}-${generation}`,
baseUrl: mockProviderBaseUrl,
request: { ...providerConfig?.request, allowPrivateNetwork: true },
models: [
{
id: MODEL_ID,
name: "QA worker generation model",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32_768,
contextTokens: 32_768,
maxTokens: 256,
},
],
},
},
},
agents: {
...config.agents,
defaults: {
...config.agents?.defaults,
model: { primary: MODEL_REF, fallbacks: [] },
models: {
...config.agents?.defaults?.models,
[MODEL_REF]: { agentRuntime: { id: "openclaw" } },
},
},
},
nodeHost: {
...config.nodeHost,
workerRuns: { enabled: true },
},
};
}
async function hotPublishGeneration(params: {
gateway: WireGateway;
tracePath: string;
generation: Exclude<Generation, "A">;
}): Promise<{ pidBefore: number; pidAfter: number }> {
const before = (await params.gateway.call("system.info", {})) as { pid?: number };
const config = JSON.parse(await fs.readFile(params.gateway.configPath, "utf8")) as OpenClawConfig;
const pluginEntry = config.plugins?.entries?.[PLUGIN_ID];
const providerConfig = config.models?.providers?.[PROVIDER_ID];
if (!pluginEntry?.config || !providerConfig) {
throw new Error("generation A config was not installed before hot publish");
}
const next: OpenClawConfig = {
...config,
plugins: {
...config.plugins,
entries: {
...config.plugins?.entries,
[PLUGIN_ID]: {
...pluginEntry,
config: { ...pluginEntry.config, generation: params.generation },
},
},
},
models: {
...config.models,
providers: {
...config.models?.providers,
[PROVIDER_ID]: {
...providerConfig,
apiKey: `${SOURCE_CREDENTIAL_PREFIX}-${params.generation}`,
},
},
},
};
await fs.writeFile(params.gateway.configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
await waitFor(`generation ${params.generation} provider registration`, async () => {
const registrations = (await readTrace(params.tracePath)).filter(
(event) => event.event === "registered" && event.generation === params.generation,
);
return registrations.length > 0 ? registrations : undefined;
});
const after = (await params.gateway.call("system.info", {})) as { pid?: number };
if (!Number.isSafeInteger(before.pid) || after.pid !== before.pid) {
throw new Error(
`generation ${params.generation} hot publish replaced the Gateway: ${before.pid} -> ${after.pid}`,
);
}
return { pidBefore: before.pid!, pidAfter: after.pid! };
}
async function startTurn(operator: GatewayClient, reply: string): Promise<string> {
const runId = `${SCENARIO_ID}-${randomUUID()}`;
const started = await operator.request<TurnResult>("chat.send", {
sessionKey: SESSION_KEY,
message: `Reply exactly: ${reply}`,
deliver: false,
idempotencyKey: runId,
});
if (started.status !== "started" || started.runId !== runId) {
throw new Error(`chat.send did not start ${reply}: ${JSON.stringify(started)}`);
}
return runId;
}
async function waitForTurn(operator: GatewayClient, runId: string): Promise<void> {
const result = await operator.request<TurnResult>(
"agent.wait",
{ runId, timeoutMs: PROOF_TIMEOUT_MS },
{ timeoutMs: PROOF_TIMEOUT_MS + 5_000 },
);
if (result.status !== "ok") {
throw new Error(`worker turn failed: ${JSON.stringify(result)}`);
}
}
async function readHistoryReplyCounts(operator: GatewayClient): Promise<Record<string, number>> {
const history = await operator.request<{ messages?: unknown[] }>("chat.history", {
sessionKey: SESSION_KEY,
limit: 100,
});
return Object.fromEntries(
[GENERATION_A_REPLY, GENERATION_B_REPLY, GENERATION_C_REPLY].map((reply) => [
reply,
(history.messages ?? []).filter(
(message) =>
(message as { role?: unknown }).role === "assistant" &&
wireMessageText(message) === reply,
).length,
]),
);
}
async function waitForHistoryReply(operator: GatewayClient, reply: string): Promise<void> {
await waitFor(`one worker history reply ${reply}`, async () => {
const count = (await readHistoryReplyCounts(operator))[reply] ?? 0;
if (count > 1) {
throw new Error(`worker history persisted ${count} copies of ${reply}`);
}
return count === 1 ? true : undefined;
});
}
function requireGenerationOwnership(params: {
events: readonly TraceEvent[];
generation: Generation;
waited: boolean;
}) {
const authEvents = params.events.filter((event) => event.event === "auth-prepare");
const authReadyEvents = params.events.filter((event) => event.event === "auth-ready");
const stages = params.events.filter((event) =>
OWNERSHIP_STAGES.includes(event.event as (typeof OWNERSHIP_STAGES)[number]),
);
if (
authEvents.length !== 1 ||
authEvents[0]?.generation !== params.generation ||
authEvents[0]?.waited !== params.waited ||
authEvents[0]?.modelGenerationMatches !== true ||
authEvents[0]?.sourceAuthMatchesGeneration !== true ||
authReadyEvents.length !== 1 ||
authReadyEvents[0]?.generation !== params.generation
) {
throw new Error(
`unexpected ${params.generation} auth preparation: ${JSON.stringify(authEvents)}`,
);
}
if (
stages.length !== OWNERSHIP_STAGES.length ||
stages.some(
(event, index) =>
event.event !== OWNERSHIP_STAGES[index] || event.generation !== params.generation,
)
) {
throw new Error(`worker inference crossed provider generations: ${JSON.stringify(stages)}`);
}
const [factory, policy, wrapper, execution] = stages;
if (
factory?.modelGenerationMatches !== true ||
policy?.modelGenerationMatches !== true ||
wrapper?.modelGenerationMatches !== true ||
wrapper?.streamPresent !== true ||
execution?.modelGenerationMatches !== true ||
execution?.authPresent !== true ||
execution?.baseUrlMatchesGeneration !== true
) {
throw new Error(`worker inference ownership facts were incomplete: ${JSON.stringify(stages)}`);
}
return stages.map((event) => `${event.event}:${event.generation}`);
}
async function readMockRequests(baseUrl: string) {
const response = await fetch(`${baseUrl}/debug/requests?after=0`);
if (!response.ok) {
throw new Error(`mock request inspection failed: HTTP ${response.status}`);
}
return (await response.json()) as Array<{
model?: string;
outcome?: string;
allInputText?: string;
}>;
}
async function runProof(options: ProducerOptions) {
if (workerGenerationProviderFixture.id !== PLUGIN_ID) {
throw new Error("worker generation fixture id does not match its configured plugin id");
}
// openclaw-temp-dir: standalone QA producer owns and removes this fixture root.
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-worker-generation-"));
const tracePath = path.join(options.artifactBase, `${SCENARIO_ID}-trace.jsonl`);
const barrierPath = path.join(root, "auth-preparation-barrier");
const pluginDir = path.join(
options.repoRoot,
"test/e2e/qa-lab/runtime/fixtures/worker-inference-generation-provider",
);
const mock = await startQaMockOpenAiServer({ modelRefs: [MODEL_REF] });
const authProxy = await startAuthInspectingProxy(mock.baseUrl);
let gateway: WireGateway | undefined;
let operator: GatewayClient | undefined;
let worker: PairedNodeWorkerHost | undefined;
let published: PublishedWireWorkspace | undefined;
let proofError: unknown;
let verdict: Record<string, unknown> | undefined;
try {
await fs.mkdir(options.artifactBase, { recursive: true });
await fs.rm(tracePath, { force: true });
await fs.writeFile(barrierPath, "released\n", "utf8");
published = await createPublishedWireWorkspace(root);
gateway = await startQaGatewayChild({
repoRoot: options.repoRoot,
useRepoCli: true,
providerBaseUrl: `${authProxy.baseUrl}/v1`,
providerMode: "mock-openai",
primaryModel: MODEL_REF,
alternateModel: DEFAULT_MOCK_MODEL_REF,
transportBaseUrl: "http://127.0.0.1",
controlUiEnabled: false,
mutateConfig: (config) =>
buildGenerationConfig({
config,
generation: "A",
pluginDir,
tracePath,
barrierPath,
mockProviderBaseUrl: `${authProxy.baseUrl}/v1`,
}),
});
await waitFor("generation A provider registration", async () => {
const registrations = (await readTrace(tracePath)).filter(
(event) => event.event === "registered" && event.generation === "A",
);
return registrations.length > 0 ? registrations : undefined;
});
operator = await connectWireClient({ gateway, role: "operator", identity: null });
worker = await createPairedNodeWorkerHost({ gateway, operator, root, bundlePrewarm: true });
await operator.request("sessions.create", {
key: SESSION_KEY,
agentId: "qa",
worktree: true,
worktreeName: `worker-generation-${randomUUID().slice(0, 8)}`,
worktreeBaseRef: "main",
cwd: published.source,
});
const dispatched = (await gateway.call(
"sessions.dispatch",
{ key: SESSION_KEY, deviceId: worker.identity.deviceId },
{ timeoutMs: PROOF_TIMEOUT_MS },
)) as { placement?: { state?: string; environmentId?: string; generation?: number } };
if (dispatched.placement?.state !== "active") {
throw new Error(`worker session did not dispatch: ${JSON.stringify(dispatched)}`);
}
const traceCursorA = (await readTrace(tracePath)).length;
const runA = await startTurn(operator, GENERATION_A_REPLY);
await waitForTurn(operator, runA);
await waitForHistoryReply(operator, GENERATION_A_REPLY);
const eventsA = (await readTrace(tracePath)).slice(traceCursorA);
const stagesA = requireGenerationOwnership({ events: eventsA, generation: "A", waited: false });
const hotPublishB = await hotPublishGeneration({ gateway, tracePath, generation: "B" });
await fs.writeFile(barrierPath, "armed\n", "utf8");
const traceCursorB = (await readTrace(tracePath)).length;
const runB = await startTurn(operator, GENERATION_B_REPLY);
await waitFor("generation B auth preparation barrier", async () => {
const events = (await readTrace(tracePath)).slice(traceCursorB);
return events.some((event) => event.event === "auth-prepare" && event.waited === true)
? events
: undefined;
});
const hotPublishC = await hotPublishGeneration({ gateway, tracePath, generation: "C" });
await fs.writeFile(barrierPath, "released\n", "utf8");
await waitForTurn(operator, runB);
await waitForHistoryReply(operator, GENERATION_B_REPLY);
const eventsB = (await readTrace(tracePath)).slice(traceCursorB);
const stagesB = requireGenerationOwnership({ events: eventsB, generation: "B", waited: true });
if (
!eventsB.some((event) => event.event === "auth-prepare-released" && event.generation === "B")
) {
throw new Error("generation B auth preparation did not resume after generation C published");
}
const traceCursorC = (await readTrace(tracePath)).length;
const runC = await startTurn(operator, GENERATION_C_REPLY);
await waitForTurn(operator, runC);
await waitForHistoryReply(operator, GENERATION_C_REPLY);
const eventsC = (await readTrace(tracePath)).slice(traceCursorC);
const stagesC = requireGenerationOwnership({ events: eventsC, generation: "C", waited: false });
const requests = await readMockRequests(mock.baseUrl);
const requestFacts = requests.map((request) => ({
model: request.model,
outcome: request.outcome,
generationA: String(request.allInputText ?? "").includes(GENERATION_A_REPLY),
generationB: String(request.allInputText ?? "").includes(GENERATION_B_REPLY),
generationC: String(request.allInputText ?? "").includes(GENERATION_C_REPLY),
}));
if (
requests.length !== 3 ||
requests.some((request) => request.outcome !== "success") ||
requestFacts[0]?.generationA !== true ||
requestFacts[0]?.generationB !== false ||
requestFacts[0]?.generationC !== false ||
requestFacts[1]?.generationA !== true ||
requestFacts[1]?.generationB !== true ||
requestFacts[1]?.generationC !== false ||
requestFacts[2]?.generationA !== true ||
requestFacts[2]?.generationB !== true ||
requestFacts[2]?.generationC !== true
) {
throw new Error(`unexpected mock-openai worker requests: ${JSON.stringify(requestFacts)}`);
}
if (JSON.stringify(authProxy.authGenerations) !== JSON.stringify(["A", "B", "C"])) {
throw new Error(
`worker inference used unexpected runtime credential generations: ${JSON.stringify(authProxy.authGenerations)}`,
);
}
const replyCounts = await readHistoryReplyCounts(operator);
if (
[GENERATION_A_REPLY, GENERATION_B_REPLY, GENERATION_C_REPLY].some(
(reply) => replyCounts[reply] !== 1,
)
) {
throw new Error(`worker history reply counts were not exact: ${JSON.stringify(replyCounts)}`);
}
verdict = {
status: "pass",
providerMode: "mock-openai",
sessionKey: SESSION_KEY,
placement: dispatched.placement,
hotPublishes: { generationB: hotPublishB, generationC: hotPublishC },
generationA: { reply: GENERATION_A_REPLY, stages: stagesA },
generationB: { reply: GENERATION_B_REPLY, stages: stagesB },
generationC: { reply: GENERATION_C_REPLY, stages: stagesC },
runtimeCredentialGenerations: authProxy.authGenerations,
replyCounts,
requestFacts,
tracePath,
};
await fs.writeFile(
path.join(options.artifactBase, VERDICT_FILE),
`${JSON.stringify(verdict, null, 2)}\n`,
"utf8",
);
} catch (error) {
proofError = error;
}
const cleanup = await Promise.allSettled([
operator?.stopAndWait({ timeoutMs: 1_000 }) ?? Promise.resolve(),
worker?.stop() ?? Promise.resolve(),
gateway?.stop() ?? Promise.resolve(),
published ? closeWireServer(published.server) : Promise.resolve(),
authProxy.stop(),
mock.stop(),
fs.rm(root, { recursive: true, force: true }),
]);
const cleanupFailures = cleanup.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (cleanupFailures.length > 0) {
proofError = new AggregateError(
proofError ? [proofError, ...cleanupFailures] : cleanupFailures,
"worker inference generation proof cleanup failed",
proofError ? { cause: proofError } : undefined,
);
}
if (proofError) {
throw proofError;
}
if (!verdict) {
throw new Error("worker inference generation proof produced no verdict");
}
return verdict;
}
async function runProducer(options: ProducerOptions): Promise<QaEvidenceSummaryJson> {
const writer = createQaScriptEvidenceWriter({
artifactBase: options.artifactBase,
logFileName: `${SCENARIO_ID}.log`,
primaryModel: MODEL_REF,
providerMode: "mock-openai",
repoRoot: options.repoRoot,
target: {
id: SCENARIO_ID,
title: "Worker inference generation reload",
sourcePath: `qa/scenarios/runtime/${SCENARIO_ID}.yaml`,
docsRefs: ["docs/gateway/cloud-workers.md", "docs/concepts/qa-e2e-automation.md"],
codeRefs: [
"src/gateway/worker-environments/inference-runtime.ts",
"src/agents/simple-completion-runtime.ts",
"test/e2e/qa-lab/runtime/worker-inference-generation-reload.ts",
],
},
});
const startedAt = Date.now();
try {
const verdict = await runProof(options);
writer.appendLog(`pass: ${JSON.stringify(verdict)}\n`);
return await writer.write({
artifacts: [
{ filePath: VERDICT_FILE, kind: "verdict" },
{ filePath: `${SCENARIO_ID}-trace.jsonl`, kind: "trace" },
],
details:
"A baseline worker turn established the placement lifecycle; generation B then retained factory, policy, wrapper, and execution ownership while C hot-published, and the next turn used C",
durationMs: Math.max(1, Date.now() - startedAt),
status: "pass",
});
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
writer.appendLog(`fail: ${details}\n`);
return await writer.write({
details,
durationMs: Math.max(1, Date.now() - startedAt),
status: "fail",
});
}
}
async function main(argv: readonly string[]) {
const options = parseOptions(argv);
const evidence = await runProducer(options);
const status = evidence.entries[0]?.result.status;
console.log(`Worker inference generation evidence: ${QA_EVIDENCE_FILENAME}`);
console.log(
`Worker inference generation verdict: ${path.join(options.artifactBase, VERDICT_FILE)}`,
);
if (status === "pass") {
console.log((await fs.readFile(path.join(options.artifactBase, VERDICT_FILE), "utf8")).trim());
}
return status === "pass" ? 0 : 1;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main(process.argv.slice(2))
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}