fix: stabilize live and docker test lanes

This commit is contained in:
Peter Steinberger
2026-04-03 21:41:26 +01:00
parent 5d3edb1d40
commit 0204b8dd28
17 changed files with 329 additions and 146 deletions

View File

@@ -14,7 +14,7 @@ RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt
git
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY ui/package.json ./ui/package.json
COPY packages ./packages
COPY extensions ./extensions

View File

@@ -27,7 +27,7 @@ COPY --chown=appuser:appuser scripts/postinstall-bundled-plugins.mjs scripts/npm
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
pnpm install --frozen-lockfile
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts openclaw.mjs ./
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts vitest.performance-config.ts vitest.shared.config.ts vitest.bundled-plugin-paths.ts openclaw.mjs ./
COPY --chown=appuser:appuser src ./src
COPY --chown=appuser:appuser test ./test
COPY --chown=appuser:appuser scripts ./scripts

View File

@@ -45,7 +45,9 @@ start_gateway() {
gateway_pid=$!
for _ in $(seq 1 120); do
if grep -q "listening on ws://" "$log_file"; then
# Gateway startup logs changed; accept both the legacy listener line and the
# current structured ready line so this smoke stays stable across formats.
if grep -Eq "listening on ws://|\\[gateway\\] ready \\(" "$log_file"; then
return 0
fi
if ! kill -0 "$gateway_pid" 2>/dev/null; then

View File

@@ -11,7 +11,9 @@ import {
} from "./bundle-mcp.test-harness.js";
import { runCliAgent } from "./cli-runner.js";
const E2E_TIMEOUT_MS = 20_000;
// This e2e spins a real stdio MCP server plus a spawned CLI process, which is
// notably slower under Docker and cold Vitest imports.
const E2E_TIMEOUT_MS = 40_000;
describe("runCliAgent bundle MCP e2e", () => {
it(

View File

@@ -1167,6 +1167,8 @@ describe("createOpenAIWebSocketStreamFn", () => {
releaseWsSession("sess-store-default");
releaseWsSession("sess-store-compat");
releaseWsSession("sess-max-tokens-zero");
releaseWsSession("sess-runtime-fallback");
releaseWsSession("sess-drop");
openAIWsStreamTesting.setDepsForTest();
});
@@ -1424,6 +1426,35 @@ describe("createOpenAIWebSocketStreamFn", () => {
}
});
it("falls back to HTTP when WebSocket errors before any output in auto mode", async () => {
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-runtime-fallback");
const stream = streamFn(
modelStub as Parameters<typeof streamFn>[0],
contextStub as Parameters<typeof streamFn>[1],
{ transport: "auto" } as Parameters<typeof streamFn>[2],
);
await new Promise((r) => setImmediate(r));
const manager = MockManager.lastInstance!;
manager.simulateEvent({
type: "error",
message: "temporary upstream glitch",
code: "ws_runtime_error",
});
const events: Array<{ type?: string; message?: { content?: Array<{ text?: string }> } }> = [];
for await (const ev of await resolveStream(stream)) {
events.push(ev as { type?: string; message?: { content?: Array<{ text?: string }> } });
}
expect(streamSimpleCalls.length).toBeGreaterThanOrEqual(1);
expect(manager.closeCallCount).toBeGreaterThanOrEqual(1);
expect(events.filter((event) => event.type === "start")).toHaveLength(1);
expect(events.some((event) => event.type === "error")).toBe(false);
const doneEvent = events.find((event) => event.type === "done");
expect(doneEvent?.message?.content?.[0]?.text).toBe("http fallback response");
});
it("tracks previous_response_id across turns (incremental send)", async () => {
const sessionId = "sess-incremental";
const streamFn = createOpenAIWebSocketStreamFn("sk-test", sessionId);
@@ -1924,12 +1955,12 @@ describe("createOpenAIWebSocketStreamFn", () => {
expect(sent.tool_choice).toBe("auto");
});
it("rejects promise when WebSocket drops mid-request", async () => {
it("keeps explicit websocket mode surfacing mid-request drops", async () => {
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-drop");
const stream = streamFn(
modelStub as Parameters<typeof streamFn>[0],
contextStub as Parameters<typeof streamFn>[1],
{} as Parameters<typeof streamFn>[2],
{ transport: "websocket" } as Parameters<typeof streamFn>[2],
);
// Let the send go through, then simulate connection drop before response.completed
await new Promise<void>((resolve) => {

View File

@@ -222,6 +222,15 @@ function resolveWsWarmup(options: Parameters<StreamFn>[2]): boolean {
return warmup === true;
}
function resetWsSession(params: { sessionId: string; session: WsSession }): void {
try {
params.session.manager.close();
} catch {
/* ignore */
}
wsRegistry.delete(params.sessionId);
}
async function runWarmUp(params: {
manager: OpenAIWebSocketManager;
modelId: string;
@@ -523,12 +532,7 @@ export function createOpenAIWebSocketStreamFn(
);
// Fully reset session state so the next WS turn doesn't use stale
// previous_response_id or lastContextLength from before the failure.
try {
session.manager.close();
} catch {
/* ignore */
}
wsRegistry.delete(sessionId);
resetWsSession({ sessionId, session });
return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal);
}
@@ -543,72 +547,101 @@ export function createOpenAIWebSocketStreamFn(
// ── 5. Wait for response.completed ───────────────────────────────────
const capturedContextLength = context.messages.length;
let sawWsOutput = false;
await new Promise<void>((resolve, reject) => {
// Honour abort signal
const abortHandler = () => {
cleanup();
reject(new Error("aborted"));
};
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
signal?.addEventListener("abort", abortHandler, { once: true });
// If the WebSocket drops mid-request, reject so we don't hang forever.
const closeHandler = (code: number, reason: string) => {
cleanup();
reject(
new Error(`WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`),
);
};
session.manager.on("close", closeHandler);
const cleanup = () => {
signal?.removeEventListener("abort", abortHandler);
session.manager.off("close", closeHandler);
unsubscribe();
};
const unsubscribe = session.manager.onMessage((event) => {
if (event.type === "response.completed") {
try {
await new Promise<void>((resolve, reject) => {
// Honour abort signal
const abortHandler = () => {
cleanup();
// Update session state
session.lastContextLength = capturedContextLength;
// Build and emit the assistant message
const assistantMsg = buildAssistantMessageFromResponse(event.response, {
api: model.api,
provider: model.provider,
id: model.id,
});
const reason: Extract<StopReason, "stop" | "length" | "toolUse"> =
assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop";
eventStream.push({ type: "done", reason, message: assistantMsg });
resolve();
} else if (event.type === "response.failed") {
cleanup();
const errMsg = event.response?.error?.message ?? "Response failed";
reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`));
} else if (event.type === "error") {
cleanup();
reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`));
} else if (event.type === "response.output_text.delta") {
// Stream partial text updates for responsive UI
const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({
model,
content: [{ type: "text", text: event.delta }],
stopReason: "stop",
});
eventStream.push({
type: "text_delta",
contentIndex: 0,
delta: event.delta,
partial: partialMsg,
});
reject(new Error("aborted"));
};
if (signal?.aborted) {
reject(new Error("aborted"));
return;
}
signal?.addEventListener("abort", abortHandler, { once: true });
// If the WebSocket drops mid-request, reject so we don't hang forever.
const closeHandler = (code: number, reason: string) => {
cleanup();
reject(
new Error(
`WebSocket closed mid-request (code=${code}, reason=${reason || "unknown"})`,
),
);
};
session.manager.on("close", closeHandler);
const cleanup = () => {
signal?.removeEventListener("abort", abortHandler);
session.manager.off("close", closeHandler);
unsubscribe();
};
const unsubscribe = session.manager.onMessage((event) => {
if (
event.type === "response.output_item.added" ||
event.type === "response.output_item.done" ||
event.type === "response.content_part.added" ||
event.type === "response.content_part.done" ||
event.type === "response.output_text.delta" ||
event.type === "response.output_text.done" ||
event.type === "response.function_call_arguments.delta" ||
event.type === "response.function_call_arguments.done"
) {
sawWsOutput = true;
}
if (event.type === "response.completed") {
cleanup();
// Update session state
session.lastContextLength = capturedContextLength;
// Build and emit the assistant message
const assistantMsg = buildAssistantMessageFromResponse(event.response, {
api: model.api,
provider: model.provider,
id: model.id,
});
const reason: Extract<StopReason, "stop" | "length" | "toolUse"> =
assistantMsg.stopReason === "toolUse" ? "toolUse" : "stop";
eventStream.push({ type: "done", reason, message: assistantMsg });
resolve();
} else if (event.type === "response.failed") {
cleanup();
const errMsg = event.response?.error?.message ?? "Response failed";
reject(new Error(`OpenAI WebSocket response failed: ${errMsg}`));
} else if (event.type === "error") {
cleanup();
reject(new Error(`OpenAI WebSocket error: ${event.message} (code=${event.code})`));
} else if (event.type === "response.output_text.delta") {
// Stream partial text updates for responsive UI
const partialMsg: AssistantMessage = buildAssistantMessageWithZeroUsage({
model,
content: [{ type: "text", text: event.delta }],
stopReason: "stop",
});
eventStream.push({
type: "text_delta",
contentIndex: 0,
delta: event.delta,
partial: partialMsg,
});
}
});
});
});
} catch (wsRunErr) {
if (transport !== "websocket" && !signal?.aborted && !sawWsOutput) {
log.warn(
`[ws-stream] session=${sessionId} runtime failure before output; falling back to HTTP. error=${String(wsRunErr)}`,
);
resetWsSession({ sessionId, session });
return fallbackToHttp(model, context, options, apiKey, eventStream, opts.signal, {
suppressStart: true,
});
}
throw wsRunErr;
}
};
queueMicrotask(() =>
@@ -638,18 +671,22 @@ export function createOpenAIWebSocketStreamFn(
async function fallbackToHttp(
model: Parameters<StreamFn>[0],
context: Parameters<StreamFn>[1],
options: Parameters<StreamFn>[2],
streamOptions: Parameters<StreamFn>[2],
apiKey: string,
eventStream: AssistantMessageEventStreamLike,
signal?: AbortSignal,
fallbackOptions?: { suppressStart?: boolean },
): Promise<void> {
const mergedOptions = {
...options,
...streamOptions,
apiKey,
...(signal ? { signal } : {}),
};
const httpStream = openAIWsStreamDeps.streamSimple(model, context, mergedOptions);
for await (const event of httpStream) {
if (fallbackOptions?.suppressStart && event.type === "start") {
continue;
}
eventStream.push(event);
}
}

View File

@@ -96,7 +96,7 @@ describe("docker build cache layout", () => {
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.\/$/m,
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m,
),
).toBeLessThan(installIndex);
expect(
@@ -114,7 +114,7 @@ describe("docker build cache layout", () => {
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts openclaw\.mjs \.\/$/m,
/^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts vitest\.e2e\.config\.ts vitest\.performance-config\.ts vitest\.shared\.config\.ts vitest\.bundled-plugin-paths\.ts openclaw\.mjs \.\/$/m,
),
).toBeGreaterThan(installIndex);
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m)).toBeGreaterThan(
@@ -160,4 +160,19 @@ describe("docker build cache layout", () => {
installIndex,
);
});
it("copies .npmrc before install in the cleanup smoke image", async () => {
const dockerfile = await readRepoFile("scripts/docker/cleanup-smoke/Dockerfile");
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
expect(
indexOfPattern(
dockerfile,
/^COPY(?:\s+--chown=\S+)?\s+package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \.npmrc \.\/$/m,
),
).toBeLessThan(installIndex);
expect(indexOfPattern(dockerfile, /^COPY(?:\s+--chown=\S+)?\s+\.\s+\.$/m)).toBeGreaterThan(
installIndex,
);
});
});

View File

@@ -76,6 +76,7 @@ const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([
const GATEWAY_LIVE_EXEC_READ_NONCE_MISS_SKIP_MODEL_KEYS = new Set([
"google/gemini-3.1-flash-lite-preview",
]);
const GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS = new Set(["google/gemini-3-flash-preview"]);
const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels();
const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS);
const QUIET_LIVE_LOGS = process.env.OPENCLAW_LIVE_TEST_QUIET !== "0";
@@ -594,28 +595,43 @@ function isPromptProbeMiss(error: string): boolean {
return msg.includes("not meaningful:") || msg.includes("missing required keywords:");
}
function shouldSkipToolNonceProbeMiss(provider: string): boolean {
return (
function shouldSkipToolNonceProbeMissForLiveModel(modelKey?: string): boolean {
if (!modelKey) {
return false;
}
if (GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(modelKey)) {
return true;
}
const [provider, ...rest] = modelKey.split("/");
if (
provider === "anthropic" ||
provider === "minimax" ||
provider === "opencode" ||
provider === "opencode-go" ||
provider === "xai" ||
provider === "zai"
);
) {
return true;
}
if (provider !== "google" || rest.length === 0) {
return false;
}
const normalizedKey = `${provider}/${normalizeGoogleModelId(rest.join("/"))}`;
return GATEWAY_LIVE_TOOL_NONCE_MISS_SKIP_MODEL_KEYS.has(normalizedKey);
}
describe("shouldSkipToolNonceProbeMiss", () => {
describe("shouldSkipToolNonceProbeMissForLiveModel", () => {
it.each([
{ provider: "anthropic", expected: true },
{ provider: "minimax", expected: true },
{ provider: "opencode", expected: true },
{ provider: "opencode-go", expected: true },
{ provider: "xai", expected: true },
{ provider: "zai", expected: true },
{ provider: "openai", expected: false },
])("returns $expected for $provider", ({ provider, expected }) => {
expect(shouldSkipToolNonceProbeMiss(provider)).toBe(expected);
{ modelKey: "anthropic/claude-opus-4-6", expected: true },
{ modelKey: "minimax/minimax-m1", expected: true },
{ modelKey: "opencode/big-pickle", expected: true },
{ modelKey: "opencode-go/glm-5", expected: true },
{ modelKey: "xai/grok-4.1-fast", expected: true },
{ modelKey: "zai/glm-4.7", expected: true },
{ modelKey: "google/gemini-3-flash-preview", expected: true },
{ modelKey: "openai/gpt-5.2", expected: false },
])("returns $expected for $modelKey", ({ modelKey, expected }) => {
expect(shouldSkipToolNonceProbeMissForLiveModel(modelKey)).toBe(expected);
});
});
@@ -1724,9 +1740,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
logProgress(`${progressLabel}: skip (exec/read workspace isolation)`);
break;
}
if (shouldSkipToolNonceProbeMiss(model.provider) && isToolNonceProbeMiss(message)) {
if (shouldSkipToolNonceProbeMissForLiveModel(modelKey) && isToolNonceProbeMiss(message)) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (${model.provider} tool probe nonce miss)`);
logProgress(`${progressLabel}: skip (${modelKey} tool probe nonce miss)`);
break;
}
if (isMissingProfileError(message)) {

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts";
import e2eConfig from "../../vitest.e2e.config.ts";
describe("e2e vitest config", () => {
it("runs as a standalone config instead of inheriting unit projects", () => {
expect(e2eConfig.test?.projects).toBeUndefined();
});
it("includes e2e test globs and runtime setup", () => {
expect(e2eConfig.test?.include).toEqual([
"test/**/*.e2e.test.ts",
"src/**/*.e2e.test.ts",
BUNDLED_PLUGIN_E2E_TEST_GLOB,
]);
expect(e2eConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts");
});
});

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "../../vitest.bundled-plugin-paths.ts";
import liveConfig from "../../vitest.live.config.ts";
describe("live vitest config", () => {
it("runs as a standalone config instead of inheriting unit projects", () => {
expect(liveConfig.test?.projects).toBeUndefined();
});
it("includes live test globs and runtime setup", () => {
expect(liveConfig.test?.include).toEqual([
"src/**/*.live.test.ts",
BUNDLED_PLUGIN_LIVE_TEST_GLOB,
]);
expect(liveConfig.test?.setupFiles).toContain("test/setup-openclaw-runtime.ts");
});
});

View File

@@ -2,7 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import * as tar from "tar";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runCommandWithTimeout } from "../process/exec.js";
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
import {
expectIntegrityDriftRejected,
@@ -10,8 +9,10 @@ import {
} from "../test-utils/npm-spec-install-test-helpers.js";
import { installPluginFromNpmSpec, PLUGIN_INSTALL_ERROR_CODE } from "./install.js";
const runCommandWithTimeoutMock = vi.fn();
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: vi.fn(),
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
let suiteTempRoot = "";
@@ -127,7 +128,7 @@ afterAll(() => {
});
beforeEach(() => {
vi.clearAllMocks();
runCommandWithTimeoutMock.mockReset();
vi.unstubAllEnvs();
});
@@ -137,7 +138,7 @@ describe("installPluginFromNpmSpec", () => {
const extensionsDir = path.join(stateDir, "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");
let packTmpDir = "";
@@ -180,7 +181,7 @@ describe("installPluginFromNpmSpec", () => {
expect(result.npmResolution?.integrity).toBe("sha512-plugin-test");
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
calls: run.mock.calls as Array<[unknown, unknown]>,
expectedSpec: "@openclaw/voice-call@0.0.1",
});
@@ -205,7 +206,7 @@ describe("installPluginFromNpmSpec", () => {
});
const archiveBuffer = fs.readFileSync(archivePath);
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
let packTmpDir = "";
const packedName = "dangerous-plugin-1.0.0.tgz";
run.mockImplementation(async (argv, opts) => {
@@ -253,7 +254,7 @@ describe("installPluginFromNpmSpec", () => {
),
).toBe(true);
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
calls: run.mock.calls as Array<[unknown, unknown]>,
expectedSpec: "dangerous-plugin@1.0.0",
});
expect(packTmpDir).not.toBe("");
@@ -270,7 +271,7 @@ describe("installPluginFromNpmSpec", () => {
});
it("aborts when integrity drift callback rejects the fetched artifact", async () => {
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
mockNpmPackMetadataResult(run, {
id: "@openclaw/voice-call@0.0.1",
name: "@openclaw/voice-call",
@@ -295,7 +296,7 @@ describe("installPluginFromNpmSpec", () => {
});
it("classifies npm package-not-found errors with a stable error code", async () => {
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
run.mockResolvedValue({
code: 1,
stdout: "",
@@ -326,7 +327,7 @@ describe("installPluginFromNpmSpec", () => {
};
{
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
mockNpmPackMetadataResult(run, prereleaseMetadata);
const result = await installPluginFromNpmSpec({
@@ -340,10 +341,10 @@ describe("installPluginFromNpmSpec", () => {
}
}
vi.clearAllMocks();
runCommandWithTimeoutMock.mockReset();
{
const run = vi.mocked(runCommandWithTimeout);
const run = runCommandWithTimeoutMock;
let packTmpDir = "";
const packedName = "voice-call-0.0.2-beta.1.tgz";
const voiceCallArchiveBuffer = readVoiceCallArchiveBuffer("0.0.1");
@@ -378,7 +379,7 @@ describe("installPluginFromNpmSpec", () => {
expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
expectSingleNpmPackIgnoreScriptsCall({
calls: run.mock.calls,
calls: run.mock.calls as Array<[unknown, unknown]>,
expectedSpec: "@openclaw/voice-call@beta",
});
expect(packTmpDir).not.toBe("");

View File

@@ -47,6 +47,12 @@ type LaneState = {
generation: number;
};
type ActiveTaskWaiter = {
activeTaskIds: Set<number>;
resolve: (value: { drained: boolean }) => void;
timeout?: ReturnType<typeof setTimeout>;
};
function isExpectedNonErrorLaneFailure(err: unknown): boolean {
return err instanceof Error && err.name === "LiveSessionModelSwitchError";
}
@@ -61,6 +67,7 @@ function getQueueState() {
return resolveGlobalSingleton(COMMAND_QUEUE_STATE_KEY, () => ({
gatewayDraining: false,
lanes: new Map<string, LaneState>(),
activeTaskWaiters: new Set<ActiveTaskWaiter>(),
nextTaskId: 1,
}));
}
@@ -99,6 +106,38 @@ function completeTask(state: LaneState, taskId: number, taskGeneration: number):
return true;
}
function hasPendingActiveTasks(taskIds: Set<number>): boolean {
const queueState = getQueueState();
for (const state of queueState.lanes.values()) {
for (const taskId of state.activeTaskIds) {
if (taskIds.has(taskId)) {
return true;
}
}
}
return false;
}
function resolveActiveTaskWaiter(waiter: ActiveTaskWaiter, result: { drained: boolean }): void {
const queueState = getQueueState();
if (!queueState.activeTaskWaiters.delete(waiter)) {
return;
}
if (waiter.timeout) {
clearTimeout(waiter.timeout);
}
waiter.resolve(result);
}
function notifyActiveTaskWaiters(): void {
const queueState = getQueueState();
for (const waiter of Array.from(queueState.activeTaskWaiters)) {
if (waiter.activeTaskIds.size === 0 || !hasPendingActiveTasks(waiter.activeTaskIds)) {
resolveActiveTaskWaiter(waiter, { drained: true });
}
}
}
function drainLane(lane: string) {
const state = getLaneState(lane);
if (state.draining) {
@@ -136,6 +175,7 @@ function drainLane(lane: string) {
const result = await entry.task();
const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
if (completedCurrentGeneration) {
notifyActiveTaskWaiters();
diag.debug(
`lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.activeTaskIds.size} queued=${state.queue.length}`,
);
@@ -155,6 +195,7 @@ function drainLane(lane: string) {
);
}
if (completedCurrentGeneration) {
notifyActiveTaskWaiters();
pump();
}
entry.reject(err);
@@ -263,6 +304,9 @@ export function resetCommandQueueStateForTest(): void {
const queueState = getQueueState();
queueState.gatewayDraining = false;
queueState.lanes.clear();
for (const waiter of Array.from(queueState.activeTaskWaiters)) {
resolveActiveTaskWaiter(waiter, { drained: true });
}
queueState.nextTaskId = 1;
}
@@ -296,6 +340,7 @@ export function resetAllLanes(): void {
for (const lane of lanesToDrain) {
drainLane(lane);
}
notifyActiveTaskWaiters();
}
/**
@@ -320,9 +365,6 @@ export function getActiveTaskCount(): number {
* already executing are waited on.
*/
export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolean }> {
// Keep shutdown/drain checks responsive without busy looping.
const POLL_INTERVAL_MS = 50;
const deadline = Date.now() + timeoutMs;
const queueState = getQueueState();
const activeAtStart = new Set<number>();
for (const state of queueState.lanes.values()) {
@@ -331,36 +373,22 @@ export function waitForActiveTasks(timeoutMs: number): Promise<{ drained: boolea
}
}
if (activeAtStart.size === 0) {
return Promise.resolve({ drained: true });
}
if (timeoutMs <= 0) {
return Promise.resolve({ drained: false });
}
return new Promise((resolve) => {
const check = () => {
if (activeAtStart.size === 0) {
resolve({ drained: true });
return;
}
let hasPending = false;
for (const state of queueState.lanes.values()) {
for (const taskId of state.activeTaskIds) {
if (activeAtStart.has(taskId)) {
hasPending = true;
break;
}
}
if (hasPending) {
break;
}
}
if (!hasPending) {
resolve({ drained: true });
return;
}
if (Date.now() >= deadline) {
resolve({ drained: false });
return;
}
setTimeout(check, POLL_INTERVAL_MS);
const waiter: ActiveTaskWaiter = {
activeTaskIds: activeAtStart,
resolve,
};
check();
waiter.timeout = setTimeout(() => {
resolveActiveTaskWaiter(waiter, { drained: false });
}, timeoutMs);
queueState.activeTaskWaiters.add(waiter);
notifyActiveTaskWaiters();
});
}

View File

@@ -0,0 +1,5 @@
export const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
export const BUNDLED_PLUGIN_PATH_PREFIX = `${BUNDLED_PLUGIN_ROOT_DIR}/`;
export const BUNDLED_PLUGIN_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.test.ts`;
export const BUNDLED_PLUGIN_E2E_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.e2e.test.ts`;
export const BUNDLED_PLUGIN_LIVE_TEST_GLOB = `${BUNDLED_PLUGIN_ROOT_DIR}/**/*.live.test.ts`;

View File

@@ -1,6 +1,6 @@
import os from "node:os";
import { defineConfig } from "vitest/config";
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
import { BUNDLED_PLUGIN_E2E_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
import baseConfig from "./vitest.config.ts";
const base = baseConfig as unknown as Record<string, unknown>;
@@ -15,8 +15,14 @@ const e2eWorkers =
: defaultWorkers;
const verboseE2E = process.env.OPENCLAW_E2E_VERBOSE === "1";
const baseTest =
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
const baseTestWithProjects =
(baseConfig as { test?: { exclude?: string[]; projects?: string[]; setupFiles?: string[] } })
.test ?? {};
const { projects: _projects, ...baseTest } = baseTestWithProjects as {
exclude?: string[];
projects?: string[];
setupFiles?: string[];
};
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.e2e.test.ts");
export default defineConfig({

View File

@@ -1,4 +1,4 @@
import { BUNDLED_PLUGIN_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
import { BUNDLED_PLUGIN_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
import { extensionExcludedChannelTestGlobs } from "./vitest.channel-paths.mjs";
import { loadPatternListFromEnv } from "./vitest.pattern-file.ts";
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";

View File

@@ -1,10 +1,15 @@
import { defineConfig } from "vitest/config";
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./scripts/lib/bundled-plugin-paths.mjs";
import { BUNDLED_PLUGIN_LIVE_TEST_GLOB } from "./vitest.bundled-plugin-paths.ts";
import baseConfig from "./vitest.config.ts";
const base = baseConfig as unknown as Record<string, unknown>;
const baseTest =
const baseTestWithProjects =
(baseConfig as { test?: { exclude?: string[]; setupFiles?: string[] } }).test ?? {};
const { projects: _projects, ...baseTest } = baseTestWithProjects as {
exclude?: string[];
projects?: string[];
setupFiles?: string[];
};
const exclude = (baseTest.exclude ?? []).filter((p) => p !== "**/*.live.test.ts");
export default defineConfig({

View File

@@ -1,11 +1,11 @@
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs";
import {
BUNDLED_PLUGIN_ROOT_DIR,
BUNDLED_PLUGIN_TEST_GLOB,
} from "./scripts/lib/bundled-plugin-paths.mjs";
import { pluginSdkSubpaths } from "./scripts/lib/plugin-sdk-entries.mjs";
} from "./vitest.bundled-plugin-paths.ts";
import { loadVitestExperimentalConfig } from "./vitest.performance-config.ts";
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));