test(plugins): add lifecycle matrix coverage

Add plugin lifecycle matrix Docker E2E coverage, resource metrics, fixture registry version support, and gauntlet handling for bundled plugin ids / required config.
This commit is contained in:
Vincent Koc
2026-05-03 01:18:31 -07:00
committed by GitHub
parent 2ffdb5d248
commit ea45950a9d
10 changed files with 381 additions and 24 deletions

View File

@@ -74,6 +74,7 @@ Use focused lanes while iterating:
```bash
pnpm test:docker:plugins
pnpm test:docker:plugin-lifecycle-matrix
pnpm test:docker:plugin-update
pnpm test:docker:upgrade-survivor
pnpm test:docker:published-upgrade-survivor
@@ -89,6 +90,10 @@ Important lanes:
dependencies, npm update no-ops, local ClawHub fixture installs and update
no-ops, marketplace update behavior, and Claude-bundle enable/inspect. Set
`OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to keep the ClawHub block hermetic/offline.
- `test:docker:plugin-lifecycle-matrix` installs the candidate package in a bare
container, runs an npm plugin through install, inspect, disable, enable,
explicit upgrade, explicit downgrade, and uninstall after deleting the plugin
code. It logs RSS and CPU metrics for each phase.
- `test:docker:plugin-update` validates that an unchanged installed plugin does
not reinstall or lose install metadata during `openclaw plugins update`.
- `test:docker:upgrade-survivor` installs the candidate tarball over a dirty

View File

@@ -615,7 +615,7 @@ These Docker runners split into two buckets:
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. Profiles are ordered by breadth: `smoke`, `package`, `product`, and `full`. See [Testing updates and plugins](/help/testing-updates-plugins) for the package/update/plugin contract, published-upgrade survivor matrix, release defaults, and failure triage.
- Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command.
- Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:upgrade-survivor`, `test:docker:published-upgrade-survivor`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, `test:docker:plugin-lifecycle-matrix`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
@@ -645,8 +645,9 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
- Plugins (install/update smoke for local path, `file:`, npm registry with hoisted dependencies, git moving refs, ClawHub kitchen-sink, marketplace updates, and Claude-bundle enable/inspect): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
Set `OPENCLAW_PLUGINS_E2E_CLAWHUB=0` to skip the ClawHub block, or override the default kitchen-sink package/runtime pair with `OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC` and `OPENCLAW_PLUGINS_E2E_CLAWHUB_ID`. Without `OPENCLAW_CLAWHUB_URL`/`CLAWHUB_URL`, the test uses a hermetic local ClawHub fixture server.
- Plugin update unchanged smoke: `pnpm test:docker:plugin-update` (script: `scripts/e2e/plugin-update-unchanged-docker.sh`)
- Plugin lifecycle matrix smoke: `pnpm test:docker:plugin-lifecycle-matrix` installs the packed OpenClaw tarball in a bare container, installs an npm plugin, toggles enable/disable, upgrades and downgrades it through a local npm registry, deletes the installed code, then verifies uninstall still removes stale state while logging RSS/CPU metrics for each lifecycle phase.
- Config reload metadata smoke: `pnpm test:docker:config-reload` (script: `scripts/e2e/config-reload-source-docker.sh`)
- Plugins: `pnpm test:docker:plugins` covers install/update smoke for local path, `file:`, npm registry with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. `pnpm test:docker:plugin-update` covers unchanged update behavior for installed plugins.
- Plugins: `pnpm test:docker:plugins` covers install/update smoke for local path, `file:`, npm registry with hoisted dependencies, git moving refs, ClawHub fixtures, marketplace updates, and Claude-bundle enable/inspect. `pnpm test:docker:plugin-update` covers unchanged update behavior for installed plugins. `pnpm test:docker:plugin-lifecycle-matrix` covers resource-tracked npm plugin install, enable, disable, upgrade, downgrade, and missing-code uninstall.
To prebuild and reuse the shared functional image manually:

View File

@@ -1549,6 +1549,7 @@
"test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh",
"test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh",
"test:docker:pi-bundle-mcp-tools": "bash scripts/e2e/pi-bundle-mcp-tools-docker.sh",
"test:docker:plugin-lifecycle-matrix": "bash scripts/e2e/plugin-lifecycle-matrix-docker.sh",
"test:docker:plugin-update": "bash scripts/e2e/plugin-update-unchanged-docker.sh",
"test:docker:plugins": "bash scripts/e2e/plugins-docker.sh",
"test:docker:published-upgrade-survivor": "env OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-openclaw@latest} bash scripts/e2e/upgrade-survivor-docker.sh",

View File

@@ -389,11 +389,11 @@ function runPluginLifecycle(params) {
const commands = [
{
phase: "install",
args: ["install", plugin.dir, "--link", "--dangerously-force-unsafe-install"],
args: ["install", plugin.id],
},
{ phase: "inspect", args: ["inspect", plugin.id, "--json"] },
{ phase: "disable", args: ["disable", plugin.id] },
{ phase: "enable", args: ["enable", plugin.id] },
...(plugin.hasRequiredConfigFields ? [] : [{ phase: "enable", args: ["enable", plugin.id] }]),
{ phase: "doctor", args: ["doctor"] },
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
];

View File

@@ -0,0 +1,138 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const [summaryPath, phase, separator, command, ...args] = process.argv.slice(2);
if (!summaryPath || !phase || separator !== "--" || !command) {
console.error("usage: measure.mjs <summary.tsv> <phase> -- <command> [args...]");
process.exit(2);
}
const pageSize = Number.parseInt(process.env.OPENCLAW_PROC_PAGE_SIZE || "4096", 10);
const clockTicks = Number.parseInt(process.env.OPENCLAW_PROC_CLK_TCK || "100", 10);
const pollMs = Number.parseInt(process.env.OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS || "100", 10);
if (!fs.existsSync("/proc")) {
console.error("plugin lifecycle resource sampler requires Linux /proc");
process.exit(2);
}
function readProcSnapshot() {
const stats = new Map();
for (const entry of fs.readdirSync("/proc", { withFileTypes: true })) {
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) {
continue;
}
const pid = Number.parseInt(entry.name, 10);
const statPath = path.join("/proc", entry.name, "stat");
try {
const raw = fs.readFileSync(statPath, "utf8");
const closeParen = raw.lastIndexOf(")");
if (closeParen === -1) {
continue;
}
const fields = raw
.slice(closeParen + 2)
.trim()
.split(/\s+/u);
const ppid = Number.parseInt(fields[1] ?? "", 10);
const userTicks = Number.parseInt(fields[11] ?? "", 10);
const systemTicks = Number.parseInt(fields[12] ?? "", 10);
const rssPages = Number.parseInt(fields[21] ?? "", 10);
if (
!Number.isFinite(ppid) ||
!Number.isFinite(userTicks) ||
!Number.isFinite(systemTicks) ||
!Number.isFinite(rssPages)
) {
continue;
}
stats.set(pid, {
ppid,
cpuTicks: userTicks + systemTicks,
rssBytes: Math.max(0, rssPages) * pageSize,
});
} catch {
// Processes can exit while /proc is being scanned.
}
}
return stats;
}
function descendantsOf(rootPid, stats) {
const children = new Map();
for (const [pid, stat] of stats.entries()) {
const siblings = children.get(stat.ppid) ?? [];
siblings.push(pid);
children.set(stat.ppid, siblings);
}
const seen = new Set([rootPid]);
const queue = [rootPid];
for (let index = 0; index < queue.length; index += 1) {
for (const child of children.get(queue[index]) ?? []) {
if (!seen.has(child)) {
seen.add(child);
queue.push(child);
}
}
}
return seen;
}
function sample(rootPid) {
const stats = readProcSnapshot();
const pids = descendantsOf(rootPid, stats);
let rssBytes = 0;
let cpuTicks = 0;
for (const pid of pids) {
const stat = stats.get(pid);
if (!stat) {
continue;
}
rssBytes += stat.rssBytes;
cpuTicks += stat.cpuTicks;
}
return { rssBytes, cpuTicks };
}
const started = performance.now();
const child = spawn(command, args, {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
let maxRssBytes = 0;
let maxCpuTicks = 0;
const updateMetrics = () => {
if (!child.pid) {
return;
}
const current = sample(child.pid);
maxRssBytes = Math.max(maxRssBytes, current.rssBytes);
maxCpuTicks = Math.max(maxCpuTicks, current.cpuTicks);
};
updateMetrics();
const interval = setInterval(updateMetrics, pollMs);
child.on("exit", (code, signal) => {
updateMetrics();
clearInterval(interval);
const wallMs = performance.now() - started;
const cpuSeconds = maxCpuTicks / clockTicks;
const maxRssKb = Math.round(maxRssBytes / 1024);
const cpuCoreRatio = wallMs > 0 ? cpuSeconds / (wallMs / 1000) : 0;
fs.appendFileSync(
summaryPath,
`${phase}\t${maxRssKb}\t${cpuSeconds.toFixed(3)}\t${wallMs.toFixed(0)}\t${cpuCoreRatio.toFixed(3)}\t${signal ?? ""}\n`,
);
console.log(
`plugin lifecycle resource: phase=${phase} max_rss_kb=${maxRssKb} cpu_s=${cpuSeconds.toFixed(3)} wall_ms=${wallMs.toFixed(0)} cpu_core_ratio=${cpuCoreRatio.toFixed(3)}`,
);
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});

View File

@@ -0,0 +1,96 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const home = os.homedir();
function openclawPath(...parts) {
return path.join(home, ".openclaw", ...parts);
}
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return {};
}
}
function records() {
const index = readJson(openclawPath("plugins", "installs.json"));
return index.installRecords ?? index.records ?? {};
}
function recordFor(pluginId) {
return records()[pluginId];
}
function config() {
return readJson(process.env.OPENCLAW_CONFIG_PATH ?? openclawPath("openclaw.json"));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function assertVersion(pluginId, version) {
const record = recordFor(pluginId);
assert(record, `install record missing for ${pluginId}`);
assert(record.source === "npm", `expected npm source for ${pluginId}, got ${record.source}`);
assert(
record.resolvedVersion === version || record.version === version,
`expected ${pluginId} record version ${version}, got ${JSON.stringify(record)}`,
);
assert(record.installPath, `install path missing for ${pluginId}`);
const packageJson = readJson(path.join(record.installPath, "package.json"));
assert(
packageJson.version === version,
`expected installed package version ${version}, got ${packageJson.version}`,
);
}
function assertEnabled(pluginId, expectedRaw) {
const expected = expectedRaw === "true";
const entry = config().plugins?.entries?.[pluginId];
assert(entry?.enabled === expected, `expected ${pluginId} enabled=${expected}`);
}
function printInstallPath(pluginId) {
const record = recordFor(pluginId);
assert(record?.installPath, `install path missing for ${pluginId}`);
process.stdout.write(record.installPath);
}
function assertUninstalled(pluginId) {
const cfg = config();
const record = recordFor(pluginId);
assert(!record, `install record still present for ${pluginId}`);
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
const loadPaths = cfg.plugins?.load?.paths ?? [];
assert(
!loadPaths.some((entry) => String(entry).includes(pluginId)),
`load path still references ${pluginId}: ${loadPaths.join(", ")}`,
);
}
const [command, pluginId, arg] = process.argv.slice(2);
switch (command) {
case "assert-version":
assertVersion(pluginId, arg);
break;
case "assert-enabled":
assertEnabled(pluginId, arg);
break;
case "install-path":
printInstallPath(pluginId);
break;
case "assert-uninstalled":
assertUninstalled(pluginId);
break;
default:
throw new Error(`unknown plugin lifecycle matrix probe command: ${command ?? "<missing>"}`);
}

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
source scripts/lib/openclaw-e2e-instance.sh
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
openclaw_e2e_install_package /tmp/openclaw-plugin-lifecycle-install.log "mounted OpenClaw package" /tmp/npm-prefix
package_root="$(openclaw_e2e_package_root /tmp/npm-prefix)"
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
export PATH="/tmp/npm-prefix/bin:$PATH"
export npm_config_loglevel=error
export npm_config_fund=false
export npm_config_audit=false
source scripts/e2e/lib/plugins/fixtures.sh
plugin_id="lifecycle-claw"
package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
mkdir -p "$resource_dir"
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
run_measured() {
local phase="$1"
shift
echo "Running plugin lifecycle phase: $phase"
node "$measure" "$summary_tsv" "$phase" -- "$@"
}
pack_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-pack.XXXXXX")"
registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id"
run_measured disable node "$entry" plugins disable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" false
run_measured enable node "$entry" plugins enable "$plugin_id"
node "$probe" assert-enabled "$plugin_id" true
run_measured upgrade-v2 node "$entry" plugins update "$package_name@2.0.0"
node "$probe" assert-version "$plugin_id" 2.0.0
run_measured downgrade-v1 node "$entry" plugins update "$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0
install_path="$(node "$probe" install-path "$plugin_id")"
rm -rf "$install_path"
if [[ -e "$install_path" ]]; then
echo "Failed to remove plugin code before missing-code uninstall: $install_path" >&2
exit 1
fi
run_measured missing-code-uninstall node "$entry" plugins uninstall "$plugin_id" --force
node "$probe" assert-uninstalled "$plugin_id"
echo "Plugin lifecycle resource summary:"
cat "$summary_tsv"
echo "Plugin lifecycle matrix passed."

View File

@@ -18,33 +18,42 @@ for (let index = 0; index < packageArgs.length; index += 3) {
const version = packageArgs[index + 1];
const tarballPath = packageArgs[index + 2];
const archive = fs.readFileSync(tarballPath);
packages.set(packageName, {
const existing = packages.get(packageName) ?? {
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
packageName,
latestVersion: version,
versions: new Map(),
};
existing.latestVersion = version;
existing.versions.set(version, {
archive,
dependencies: packageName === "@openclaw/demo-plugin-npm" ? { "is-number": "7.0.0" } : {},
encodedPackageName: encodeURIComponent(packageName).replace("%40", "@"),
integrity: `sha512-${crypto.createHash("sha512").update(archive).digest("base64")}`,
packageName,
shasum: crypto.createHash("sha1").update(archive).digest("hex"),
tarballName: path.basename(tarballPath),
version,
});
packages.set(packageName, existing);
}
const metadataFor = (entry, baseUrl) => ({
name: entry.packageName,
"dist-tags": { latest: entry.version },
versions: {
[entry.version]: {
dependencies: entry.dependencies,
name: entry.packageName,
version: entry.version,
dist: {
integrity: entry.integrity,
shasum: entry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${entry.tarballName}`,
"dist-tags": { latest: entry.latestVersion },
versions: Object.fromEntries(
[...entry.versions.entries()].map(([version, versionEntry]) => [
version,
{
dependencies: versionEntry.dependencies,
name: entry.packageName,
version,
dist: {
integrity: versionEntry.integrity,
shasum: versionEntry.shasum,
tarball: `${baseUrl}/${entry.encodedPackageName}/-/${versionEntry.tarballName}`,
},
},
},
},
]),
),
});
function findPackageForPath(pathname) {
@@ -54,11 +63,13 @@ function findPackageForPath(pathname) {
function findTarballForPath(pathname) {
for (const entry of packages.values()) {
const prefix = `/${entry.encodedPackageName}/-/`;
if (
pathname.toLowerCase().startsWith(prefix.toLowerCase()) &&
pathname.endsWith(`/${entry.tarballName}`)
) {
return entry;
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
continue;
}
for (const versionEntry of entry.versions.values()) {
if (pathname.endsWith(`/${versionEntry.tarballName}`)) {
return versionEntry;
}
}
}
return undefined;

View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Bare package-level plugin lifecycle matrix with resource metrics.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-plugin-lifecycle-matrix-e2e" OPENCLAW_PLUGIN_LIFECYCLE_MATRIX_E2E_IMAGE)"
SKIP_BUILD="${OPENCLAW_PLUGIN_LIFECYCLE_MATRIX_E2E_SKIP_BUILD:-0}"
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz plugin-lifecycle-matrix "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
docker_e2e_package_mount_args "$PACKAGE_TGZ"
docker_e2e_build_or_reuse "$IMAGE_NAME" plugin-lifecycle-matrix "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 plugin-lifecycle-matrix empty)"
echo "Running plugin lifecycle matrix Docker E2E..."
docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
"$IMAGE_NAME" \
bash scripts/e2e/lib/plugin-lifecycle-matrix/sweep.sh
echo "Plugin lifecycle matrix Docker E2E passed."

View File

@@ -255,6 +255,14 @@ export const mainLanes = [
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update", {
stateScenario: "empty",
}),
npmLane(
"plugin-lifecycle-matrix",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-lifecycle-matrix",
{
stateScenario: "empty",
timeoutMs: 12 * 60 * 1000,
},
),
serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload", {
stateScenario: "empty",
}),