chore: cover worker state in published upgrade tests (#149487)

* test: add worker state upgrade survivor cells

Add opt-in Projects Doctor and terminal task/flow restoration cells using the unchanged published updater, verified package bytes, and the canonical survivor lifecycle. Preserve failed synthetic state until the outer Docker owner has joined.

Validation: 161 focused tests, selected changed checks, and P2 review. Actual package upgrade cells remain separately qualified against frozen candidate artifacts.

* test: activate taskflow survivor fixture on Gateway startup

* test: clean worker runtime with container ownership

* fix(test): preserve Docker status through exit traps
This commit is contained in:
Peter Steinberger 2026-09-15 21:51:41 -07:00 • committed by GitHub
parent e75cc4dd91
commit ec66133ba0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1869 additions and 18 deletions

View file

@ -116,10 +116,13 @@ const repositoryScriptEntries = [
"scripts/e2e/lib/upgrade-survivor/missing-configured-plugin-migration.mjs!",
"scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs!",
"scripts/e2e/lib/upgrade-survivor/probe-volume-gateway.mjs!",
"scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs!",
"scripts/e2e/lib/upgrade-survivor/recovery-cleanup.mjs!",
"scripts/e2e/lib/upgrade-survivor/schema-expectation.mjs!",
// update-restart-auth.sh installs this manager/launch adapter into the fixture bin directory.
"scripts/e2e/lib/upgrade-survivor/systemd-fixture.mjs!",
"scripts/e2e/lib/upgrade-survivor/taskflow-restoration.mjs!",
"scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs!",
"scripts/e2e/lib/upgrade-survivor/mobile-pairing-client.mts!",
"scripts/e2e/lib/upgrade-survivor/watchos-direct-node.mjs!",
"scripts/embedded-run-abort-leak.ts!",

View file

@ -185,6 +185,23 @@ Those default release runs pin this scenario to the published 2026.9.4 driver,
including when the source candidate still reports version 2026.9.4; other
scenarios retain their existing baseline selection.
The opt-in `projects-doctor` and `taskflow-restoration` scenarios require the exact
published `openclaw@2026.9.4` baseline and a frozen candidate tarball. They use
isolated state, manual restart, and no live providers or registry companion fixtures;
neither runs through `reported-issues` or `far-reaching`. Both verify the original
published driver and installed candidate payload bytes, including when their version
strings are equal. Select one with `OPENCLAW_UPGRADE_SURVIVOR_SCENARIO` and set
`OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=openclaw@2026.9.4`.
`projects-doctor` preserves one registered project and one configured workspace,
then runs the real `doctor --lint --only core/doctor/project-clone-shape --json`
twice. It checks stored rows, schema, sentinels, and read-only snapshot cleanup.
`taskflow-restoration` preserves three terminal tasks and two flows, starts a fresh
candidate Gateway, exercises awaited task SDK reads through a synthetic local plugin,
and reads two task pages on the same Gateway connection. Complete task, delivery,
and flow records are checked again after Gateway shutdown. These cells cover
terminal persisted state; they do not exercise active task recovery or provider work.
The `legacy-operator-state` scenario uses the published baseline's own CLI to
create a second agent, allowlist exec approvals, and two command cron jobs: one
without an explicit agent and one owned by `ops`. It leaves `systemAgent`

View file

@ -42,6 +42,8 @@ const SCENARIOS = new Set([
"configured-plugin-installs",
"missing-configured-plugin-migration",
"custom-plugin-siblings",
"projects-doctor",
"taskflow-restoration",
"stale-source-plugin-shadow",
"prerelease-plugin-registry",
"tilde-log-path",
@ -1664,6 +1666,13 @@ function assertSuccessfulUpdateJson([file, expectedVersion, observationRoot]) {
const result = readUpdateJson(file, observationRoot);
const plugins = result?.postUpdate?.plugins;
assert(result?.status === "ok", `update did not report ok: ${String(result?.status)}`);
if (["projects-doctor", "taskflow-restoration"].includes(getScenario())) {
assertStrict.equal(
result.before?.version,
"2026.9.4",
"Worker cell used the wrong published driver",
);
}
const expectedMissingPluginFailure =
getScenario() === "missing-configured-plugin-migration"
? assertExpectedMissingCodexOutcome(result, expectedVersion)

View file

@ -0,0 +1,405 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveWorkerCellExport } from "./worker-cell-package.mjs";
// These chunks belong to the integrity-pinned published 2026.9.4 package.
const BASELINE_CHUNKS = {
"project-registry-D_ymI9b-.mjs":
"89ba1a94f0238ff7e5c9ca7d4b42bc2dcb7c666c042c67a3fb79a44a832713f5",
"kysely-sync-CrjZjQJR.mjs": "529a18c25ace3d1e8d40a630f5ffb30857aa0684d54ae39f4f2518424ceae13b",
"openclaw-state-db-DoQEJuhr.mjs":
"d3a52e65d0bca8993cbb6d48ae5d51e475e3aced8c7b9e15da77733f4fc8abcf",
"openclaw-state-db-m8z7pMTn.mjs":
"0efee81689942591023d3d68bc67cdab21a86c38bafc7bb275f03d4d62d3ce10",
"openclaw-state-db-cache-BXmHZWzx.mjs":
"6e8c7fe9bb935220a23b2193b0132f2e3686b64941aebd233493464fb06a72a1",
};
const STAGES = new Set([
"baseline",
"after-update",
"before-doctor",
"after-doctor",
"before-repeat",
"after-repeat",
]);
function digest(file) {
assert(fs.lstatSync(file).isFile(), `Expected a regular fixture file: ${file}`);
return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { flag: "wx" });
}
function within(root, file) {
const relative = path.relative(root, file);
return (
relative !== "" &&
relative !== ".." &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative)
);
}
function context() {
const required = (key) => {
const value = process.env[key];
assert(value && path.isAbsolute(value), `${key} must be an isolated absolute path`);
return fs.realpathSync(value);
};
const root = required("OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT");
const artifacts = required("OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT");
const stateDir = required("OPENCLAW_STATE_DIR");
const configPath = process.env.OPENCLAW_CONFIG_PATH;
assert(
configPath && within(stateDir, path.resolve(configPath)),
"Config must belong to the scenario state",
);
assert(within(root, stateDir), "State must belong to the scenario runtime");
const tempRoots = [required("TMPDIR"), required("XDG_CACHE_HOME")];
assert(
tempRoots.every((dir) => within(root, dir)),
"Snapshot roots must belong to the scenario runtime",
);
return {
root,
artifacts,
stateDir,
configPath,
tempRoots,
databasePath: path.join(stateDir, "state", "openclaw.sqlite"),
manifestPath: path.join(artifacts, "projects-inventory.json"),
};
}
function snapshotPath(ctx, stage) {
assert(STAGES.has(stage), `Unknown Projects snapshot stage: ${stage}`);
return path.join(ctx.artifacts, `projects-${stage}.json`);
}
function fixture(ctx) {
const expected = readJson(ctx.manifestPath);
assert.equal(expected.root, ctx.root, "Projects fixture belongs to another runtime");
assert.equal(
expected.stateDir,
ctx.stateDir,
"Projects fixture belongs to another state directory",
);
assert.equal(expected.configPath, ctx.configPath, "Projects fixture belongs to another config");
return expected;
}
function retainedSnapshots(roots) {
const retained = [];
const visit = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (/^openclaw-(?:sqlite-readonly-|doctor-lint-state-)/.test(entry.name)) {
retained.push(file);
}
if (entry.isDirectory()) {
visit(file);
}
}
};
for (const root of new Set(roots)) {
visit(root);
}
return retained.toSorted((a, b) => a.localeCompare(b));
}
function sqliteFamily(databasePath) {
return Object.fromEntries(
["", "-wal", "-shm", "-journal"].flatMap((suffix) => {
const file = `${databasePath}${suffix}`;
return fs.existsSync(file) ? [[suffix || "main", digest(file)]] : [];
}),
);
}
export function assertProjectsInventory(inventory, baseline) {
assert.deepEqual(inventory.rows, baseline.rows, "Persisted project inventory changed");
assert.deepEqual(inventory.schema, baseline.schema, "Projects table schema changed");
assert.equal(inventory.workspace, baseline.workspace, "Configured workspace changed");
assert.deepEqual(inventory.sentinels, baseline.sentinels, "Project or workspace files changed");
}
export function assertProjectsDoctorResult(report, before, after) {
assert.equal(report.ok, true, "Projects Doctor reported an unhealthy result");
assert.equal(report.checksRun, 1, "Projects Doctor must run exactly the selected check");
assert.deepEqual(report.findings, [], "Projects Doctor skipped or failed inventory inspection");
assertProjectsInventory(after, before);
assert.deepEqual(
after.sqliteFamily,
before.sqliteFamily,
"Read-only Doctor changed SQLite files",
);
assert.equal(after.configHash, before.configHash, "Read-only Doctor rewrote the config");
assert.deepEqual(
after.retainedSnapshots,
before.retainedSnapshots,
"Doctor retained a private snapshot",
);
}
async function artifactPreservingReader(ctx, stage, packageRoot) {
assert(
packageRoot && path.isAbsolute(packageRoot),
"Snapshot requires the installed package root",
);
const identity = readJson(
path.join(
ctx.artifacts,
stage === "baseline" ? "baseline-package-identity.json" : "installed-package-identity.json",
),
);
assert.equal(
fs.realpathSync(path.join(packageRoot, "openclaw.mjs")),
identity.cli,
"Snapshot package differs from the verified installed CLI",
);
const symbol = "withExistingOpenClawStateDatabaseArtifactPreservingReadOnly";
const matches = [];
for (const [relative, expected] of Object.entries(identity.files)) {
if (!/^dist\/openclaw-state-db-readonly-[\w-]+\.mjs$/.test(relative)) {
continue;
}
const file = path.join(packageRoot, relative);
assert(fs.lstatSync(file).isFile(), `Expected a regular snapshot owner: ${file}`);
const bytes = fs.readFileSync(file);
const sha256 = createHash("sha256").update(bytes).digest("hex");
assert.equal(sha256, expected.sha256, `Installed snapshot owner changed: ${relative}`);
const alias = resolveWorkerCellExport(bytes.toString("utf8"), symbol);
if (alias) {
matches.push({ file, relative, sha256, alias });
}
}
assert.equal(
matches.length,
1,
"Expected exactly one installed artifact-preserving state reader",
);
const binding = matches[0];
const module = await import(pathToFileURL(binding.file).href);
const read = module[binding.alias];
assert.equal(typeof read, "function", "Installed artifact-preserving state reader is missing");
return {
read,
evidence: { file: binding.relative, sha256: binding.sha256, export: binding.alias },
};
}
async function snapshot(ctx, stage, packageRoot) {
const expected = fixture(ctx);
const familyBefore = sqliteFamily(ctx.databasePath);
const reader = await artifactPreservingReader(ctx, stage, packageRoot);
const observed = reader.read(
({ db }) => ({
rows: db
.prepare(
"SELECT id, display_name, repo_root, origin_url, source, created_at_ms, updated_at_ms FROM projects ORDER BY id",
)
.all()
.map((row) => Object.assign({}, row)),
schema: db
.prepare(
"SELECT type, name, sql FROM sqlite_schema WHERE tbl_name = 'projects' ORDER BY type, name",
)
.all()
.map((row) => Object.assign({}, row)),
}),
{ path: ctx.databasePath, env: process.env },
);
assert(observed, "Projects state database is missing");
const familyAfter = sqliteFamily(ctx.databasePath);
assert.deepEqual(
familyAfter,
familyBefore,
"Inventory observer changed SQLite files; Doctor mutation evidence is inconclusive",
);
const config = readJson(ctx.configPath);
const result = {
...observed,
observer: reader.evidence,
workspace: config.agents?.defaults?.workspace,
sentinels: Object.fromEntries(
Object.keys(expected.sentinels).map((file) => [file, digest(file)]),
),
configHash: digest(ctx.configPath),
sqliteFamily: familyAfter,
retainedSnapshots: retainedSnapshots(ctx.tempRoots),
};
assert.deepEqual(result.rows, [expected.row], "Expected exactly the seeded registered project");
assert.equal(result.workspace, expected.workspace, "Configured workspace changed");
assert.deepEqual(result.sentinels, expected.sentinels, "Synthetic project files changed");
assert.deepEqual(result.retainedSnapshots, [], "A private SQLite/Doctor snapshot remains");
if (stage !== "baseline") {
assertProjectsInventory(result, readJson(snapshotPath(ctx, "baseline")));
}
writeJson(snapshotPath(ctx, stage), result);
}
async function seed(ctx, packageRoot) {
assert(
packageRoot && path.isAbsolute(packageRoot),
"Seed requires the installed published package root",
);
const manifest = readJson(path.join(packageRoot, "package.json"));
assert.equal(manifest.name, "openclaw");
assert.equal(manifest.version, "2026.9.4", "Fixture must be created by the published baseline");
assert(!fs.existsSync(ctx.manifestPath), "Projects fixture was already seeded");
for (const [file, expectedHash] of Object.entries(BASELINE_CHUNKS)) {
assert.equal(
digest(path.join(packageRoot, "dist", file)),
expectedHash,
`Published module changed: ${file}`,
);
}
const workspace = path.join(ctx.root, "workspace");
const registered = path.join(ctx.root, "registered");
const sentinels = {};
for (const [dir, text] of [
[workspace, "workspace survives update — 東京\n"],
[registered, "registered project survives update — λ\n"],
]) {
fs.mkdirSync(dir, { recursive: true });
assert(
!fs.existsSync(path.join(dir, ".git")),
"Projects fixture must not contain a Git checkout",
);
const file = path.join(dir, "PROJECTS-PROOF.txt");
fs.writeFileSync(file, text, { flag: "wx" });
sentinels[file] = digest(file);
}
const cfg = {
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token: "projects-doctor-survivor-token" },
controlUi: { enabled: false },
},
agents: { defaults: { workspace, heartbeat: { every: "0m" } } },
plugins: { enabled: false },
};
fs.writeFileSync(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`);
const row = {
id: "doctor-upgrade-registered",
display_name: "Doctor upgrade inventory — 東京",
repo_root: registered,
origin_url: null,
source: "registered",
created_at_ms: 1789430400000,
updated_at_ms: 1789430400000,
};
const load = (file) => import(pathToFileURL(path.join(packageRoot, "dist", file)).href);
const state = await load("openclaw-state-db-m8z7pMTn.mjs");
const { n: listProjectRegistry } = await load("project-registry-D_ymI9b-.mjs");
const { i: getNodeSqliteKysely, n: executeSqliteQuerySync } = await load(
"kysely-sync-CrjZjQJR.mjs",
);
const options = { path: ctx.databasePath, env: process.env };
try {
// The published list owner bootstraps its own schema without probing Git.
const initial = listProjectRegistry(cfg, options);
assert.equal(
initial.length,
1,
"Expected only the configured workspace before fixture insertion",
);
assert.equal(initial[0].source, "workspace");
state.runOpenClawStateWriteTransaction(
({ db }) => {
executeSqliteQuerySync(db, getNodeSqliteKysely(db).insertInto("projects").values(row));
},
options,
{ operationLabel: "projects.upgrade.fixture" },
);
const inventory = listProjectRegistry(cfg, options);
assert.equal(inventory.length, 2);
assert.deepEqual(
inventory.find((project) => project.id === row.id),
{
id: row.id,
displayName: row.display_name,
repoRoot: row.repo_root,
source: row.source,
},
);
assert.equal(inventory.find((project) => project.source === "workspace")?.repoRoot, workspace);
} finally {
state.closeOpenClawStateDatabaseByPath(ctx.databasePath);
}
assert.equal(
state.isOpenClawStateDatabaseOpen(ctx.databasePath),
false,
"Published fixture writer did not close",
);
writeJson(ctx.manifestPath, {
root: ctx.root,
stateDir: ctx.stateDir,
configPath: ctx.configPath,
baselineVersion: manifest.version,
baselineChunks: BASELINE_CHUNKS,
row,
workspace,
sentinels,
});
await snapshot(ctx, "baseline", packageRoot);
}
async function main() {
const [mode, ...args] = process.argv.slice(2);
const ctx = context();
if (mode === "seed") {
assert.equal(args.length, 1);
await seed(ctx, args[0]);
} else if (mode === "snapshot") {
assert.equal(args.length, 2);
await snapshot(ctx, args[0], args[1]);
} else if (mode === "assert-doctor") {
assert.equal(args.length, 3);
const [reportPath, before, after] = args;
assertProjectsDoctorResult(
readJson(reportPath),
readJson(snapshotPath(ctx, before)),
readJson(snapshotPath(ctx, after)),
);
writeJson(path.join(ctx.artifacts, `projects-${after}-assertion.json`), {
ok: true,
before,
after,
});
} else if (mode === "assert-final") {
assert.equal(args.length, 0);
for (const stage of STAGES) {
assert(fs.existsSync(snapshotPath(ctx, stage)), `Missing Projects phase: ${stage}`);
}
for (const stage of ["after-doctor", "after-repeat"]) {
assert.equal(readJson(path.join(ctx.artifacts, `projects-${stage}-assertion.json`)).ok, true);
}
const baseline = readJson(snapshotPath(ctx, "baseline"));
const final = readJson(snapshotPath(ctx, "after-repeat"));
assertProjectsInventory(final, baseline);
writeJson(path.join(ctx.artifacts, "projects-doctor-result.json"), {
ok: true,
baselineVersion: "2026.9.4",
storedProjects: final.rows.length,
workspace: final.workspace,
doctorInvocations: 2,
});
} else {
throw new Error(`Unknown Projects Doctor fixture mode: ${mode}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
await main();
}

View file

@ -1,4 +1,8 @@
#!/usr/bin/env bash
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
exec /bin/bash "$0" "$@"
fi
set -Eeuo pipefail
# Signal traps inherit the foreground command's redirections. Keep harness stdout separate so the
# final summary location cannot corrupt a command artifact when the run is interrupted.
@ -10,6 +14,10 @@ source scripts/e2e/lib/upgrade-survivor/plugin-dependency-fixtures.sh
source scripts/e2e/lib/upgrade-survivor/backup-rollback.sh
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
WORKER_CELL=0
if [ "$SCENARIO" = "projects-doctor" ] || [ "$SCENARIO" = "taskflow-restoration" ]; then
WORKER_CELL=1
fi
export npm_config_loglevel=error
export npm_config_fund=false
@ -43,7 +51,7 @@ if [ "$SCENARIO" = "mobile-pairing-reconnect" ]; then
node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))'
)"
fi
if [ "$SCENARIO" = "watchos-direct-node" ] || [ "$SCENARIO" = "mobile-pairing-reconnect" ]; then
if [ "$SCENARIO" = "watchos-direct-node" ] || [ "$SCENARIO" = "mobile-pairing-reconnect" ] || [ "$WORKER_CELL" = "1" ]; then
unset OPENAI_API_KEY DISCORD_BOT_TOKEN TELEGRAM_BOT_TOKEN
else
export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"
@ -69,6 +77,12 @@ chmod 700 "$RUNTIME_ROOT"
export TMPDIR="${OPENCLAW_UPGRADE_SURVIVOR_TMPDIR:-$RUNTIME_ROOT/tmp}"
export OPENCLAW_TEST_STATE_TMPDIR="${OPENCLAW_UPGRADE_SURVIVOR_TEST_STATE_TMPDIR:-$RUNTIME_ROOT/state-tmp}"
mkdir -p "$TMPDIR" "$OPENCLAW_TEST_STATE_TMPDIR"
if [ "$WORKER_CELL" = "1" ]; then
export XDG_CACHE_HOME="$RUNTIME_ROOT/xdg-cache"
export OPENCLAW_SKIP_CRON=1
export OPENCLAW_SKIP_STARTUP_MODEL_PREWARM=1
mkdir -p "$XDG_CACHE_HOME"
fi
if [ "$SCENARIO" = "legacy-operator-state" ]; then
export npm_config_prefix="$RUNTIME_ROOT/npm-prefix"
else
@ -1942,11 +1956,89 @@ assertAgentReplyContainsMarker(process.argv[2], process.argv[3]);
NODE
}
prepare_worker_cell_package() {
node scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs candidate "$(package_root)" "$CANDIDATE_SPEC"
OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_COMMIT="$(node -e \
'process.stdout.write(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).buildInfo.commit)' \
"$ARTIFACT_ROOT/candidate-package-identity.json")"
export OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_COMMIT
}
assert_worker_cell_update() {
if [ "$update_outcome" != "success" ] || [ "$update_repair_required" != "0" ]; then
echo "$SCENARIO requires successful original-driver update without follow-up repair" >&2
return 1
fi
node scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs installed "$(package_root)" "$CANDIDATE_SPEC"
}
run_projects_doctor() {
local stage="$1"
openclaw_e2e_maybe_timeout "$COMMAND_TIMEOUT" env \
-u OPENCLAW_UPDATE_IN_PROGRESS \
-u OPENCLAW_UPDATE_POST_CORE_CONVERGENCE \
-u OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE \
-u OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR \
openclaw doctor --lint --only core/doctor/project-clone-shape --json \
>"$ARTIFACT_ROOT/projects-doctor-$stage.json" 2>"$ARTIFACT_ROOT/projects-doctor-$stage.err"
}
validate_worker_cell() {
if [ "$WORKER_CELL" != "1" ]; then
return 0
fi
if [ "$BASELINE_RAW" != "openclaw@2026.9.4" ] || [ "$CANDIDATE_KIND" != "tarball" ] ||
[ "$UPDATE_RESTART_MODE" != "manual" ] || [ "$ROOT_MANAGED_VPS" != "0" ] || [ "$LIVE_OPENAI" != "0" ]; then
echo "$SCENARIO requires published openclaw@2026.9.4, a candidate tarball, isolated manual restart, and no live provider" >&2
return 1
fi
}
phase storage-preflight storage_preflight
phase validate-update-restart-mode validate_update_restart_mode
phase validate-worker-cell validate_worker_cell
phase reset-run-state reset_run_state
phase install-baseline install_baseline
phase initialize-state initialize_state
if [ "$WORKER_CELL" = "1" ]; then
phase worker-baseline-identity node scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs baseline "$(package_root)"
if [ "$SCENARIO" = "projects-doctor" ]; then
phase seed-projects-inventory node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs seed "$(package_root)"
else
phase seed-taskflow node scripts/e2e/lib/upgrade-survivor/taskflow-restoration.mjs seed --package-root "$(package_root)"
fi
phase validate-baseline-config validate_baseline_config
phase resolve-worker-candidate resolve_candidate_version
phase worker-candidate-identity prepare_worker_cell_package
phase update-worker-candidate update_candidate
phase assert-worker-installed-identity assert_worker_cell_update
if [ "$SCENARIO" = "projects-doctor" ]; then
phase projects-after-update node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs snapshot after-update "$(package_root)"
phase projects-before-doctor node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs snapshot before-doctor "$(package_root)"
phase projects-doctor run_projects_doctor first
phase projects-after-doctor node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs snapshot after-doctor "$(package_root)"
phase assert-projects-doctor node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs \
assert-doctor "$ARTIFACT_ROOT/projects-doctor-first.json" before-doctor after-doctor
phase projects-before-repeat node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs snapshot before-repeat "$(package_root)"
phase projects-doctor-repeat run_projects_doctor repeat
phase projects-after-repeat node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs snapshot after-repeat "$(package_root)"
phase assert-projects-doctor-repeat node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs \
assert-doctor "$ARTIFACT_ROOT/projects-doctor-repeat.json" before-repeat after-repeat
phase assert-projects-preservation node scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs assert-final
else
phase gateway-start start_gateway
phase gateway-probes check_gateway_probes
phase taskflow-sdk-and-pages node scripts/e2e/lib/upgrade-survivor/taskflow-restoration.mjs probe \
--package-root "$(package_root)" --url ws://127.0.0.1:18789 \
--expected-commit "$OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_COMMIT"
phase gateway-stop stop_gateway
phase assert-taskflow-persistence node scripts/e2e/lib/upgrade-survivor/taskflow-restoration.mjs assert-state \
--package-root "$(package_root)" --expected-commit "$OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_COMMIT"
fi
run_completed="1"
echo "Upgrade survivor Docker E2E passed baseline=${baseline_spec} scenario=${SCENARIO} candidate=${candidate_version}."
exit 0
fi
if [ "$SCENARIO" = "workshop-doctor-recovery" ]; then
if [ "$baseline_spec" != "openclaw@2026.9.4" ]; then
echo "workshop-doctor-recovery requires the exact published openclaw@2026.9.4 baseline" >&2

View file

@ -0,0 +1,234 @@
import assert from "node:assert/strict";
export const TASKFLOW_PLUGIN_ID = "taskflow-survivor";
export const TASKFLOW_PLUGIN_MANIFEST = {
id: TASKFLOW_PLUGIN_ID,
activation: { onStartup: true },
configSchema: { type: "object", properties: {}, additionalProperties: false },
};
export const TASKFLOW_METHOD = "taskflow-survivor.read";
export const TASKFLOW_OWNER = "agent:main:taskflow-update-cell";
export const TASKFLOW_TASK_IDS = [1, 2, 3].map((index) => `update-cell-task-0${index}`);
export const TASKFLOW_FLOW_IDS = ["update-cell-flow-managed", "update-cell-flow-mirrored"];
export function createTaskflowFixture(now) {
assert(Number.isSafeInteger(now) && now > 3003, "Invalid fixture timestamp");
const tasks = TASKFLOW_TASK_IDS.map((taskId, offset) => {
const index = offset + 1;
return {
taskId,
runtime: "cli",
sourceId: `update-cell-source-${index}`,
requesterSessionKey: TASKFLOW_OWNER,
ownerKey: TASKFLOW_OWNER,
scopeKind: "session",
parentFlowId: TASKFLOW_FLOW_IDS[index === 3 ? 1 : 0],
agentId: "main",
requesterAgentId: "main",
runId: `update-cell-run-0${index}`,
label: `Task/flow survivor ${index} — 東京`,
task: `Task/flow survivor ${index} — 東京`,
status: "succeeded",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
createdAt: now - 3000 - index,
startedAt: now - 2000 - index,
endedAt: now - 1000 - index,
lastEventAt: now - 1000 - index,
cleanupAfter: now + 86_400_000,
toolUseCount: index,
lastToolName: "synthetic-fixture",
progressSummary: `Progress ${index} — 東京`,
terminalSummary: `Completed ${index} — 東京`,
terminalOutcome: "succeeded",
detail: {
fixture: "taskflow-update-cell",
index,
payload: { enabled: true, optional: null },
tags: ["persisted", "東京"],
},
};
});
const flows = TASKFLOW_FLOW_IDS.map((flowId, index) => {
const flow = {
flowId,
syncMode: index === 0 ? "managed" : "task_mirrored",
ownerKey: TASKFLOW_OWNER,
revision: index === 0 ? 7 : 3,
status: "succeeded",
notifyPolicy: "silent",
goal: `Flow survivor ${index} — 東京`,
currentStep: "Complete",
stateJson: { fixture: "taskflow-update-cell", index, payload: [true, null, "東京"] },
createdAt: now - 4000 - index,
updatedAt: tasks[index === 0 ? 0 : 2].endedAt,
endedAt: tasks[index === 0 ? 0 : 2].endedAt,
};
if (index === 0) {
flow.controllerId = "update-cell-controller";
}
return flow;
});
const deliveryStates = tasks.map((task) => ({
taskId: task.taskId,
lastNotifiedEventAt: task.endedAt,
}));
return { tasks, flows, deliveryStates };
}
// Compare JSON-visible owner records; optional absent fields are not durable values.
export function normalizeTaskflowSnapshot(snapshot) {
return Object.fromEntries(
["tasks", "flows", "deliveryStates"].map((key) => {
const id = key === "flows" ? "flowId" : "taskId";
const records = snapshot[key] instanceof Map ? [...snapshot[key].values()] : snapshot[key];
assert(Array.isArray(records), `Missing ${key} snapshot`);
const serializedRecords = JSON.stringify(records);
return [key, JSON.parse(serializedRecords).toSorted((a, b) => a[id].localeCompare(b[id]))];
}),
);
}
export function assertTaskflowSnapshot(actual, expected) {
assert.deepEqual(normalizeTaskflowSnapshot(actual), normalizeTaskflowSnapshot(expected));
}
function runView(task) {
const { taskId, requesterSessionKey, scopeKind, parentFlowId, task: title } = task;
const fields = [
"runtime",
"sourceId",
"ownerKey",
"agentId",
"runId",
"label",
"status",
"deliveryStatus",
"notifyPolicy",
"createdAt",
"startedAt",
"endedAt",
"lastEventAt",
"cleanupAfter",
"progressSummary",
"terminalSummary",
"terminalOutcome",
];
return {
id: taskId,
sessionKey: requesterSessionKey,
scope: scopeKind,
flowId: parentFlowId,
title,
...Object.fromEntries(fields.map((key) => [key, task[key]])),
};
}
function flowView(flow) {
return {
id: flow.flowId,
...Object.fromEntries(
[
"ownerKey",
"status",
"notifyPolicy",
"goal",
"currentStep",
"createdAt",
"updatedAt",
"endedAt",
].map((key) => [key, flow[key]]),
),
};
}
function summary(total) {
return {
total,
active: 0,
terminal: total,
failures: 0,
byStatus: {
queued: 0,
running: 0,
succeeded: total,
failed: 0,
timed_out: 0,
cancelled: 0,
lost: 0,
},
byRuntime: { subagent: 0, acp: 0, cron: 0, cli: total },
};
}
export function assertTaskflowSdkReads(actual, fixture) {
assert.equal(actual.ownerKey, TASKFLOW_OWNER);
const runs = fixture.tasks.map(runView);
assert.deepEqual(actual.runs, runs);
assert.deepEqual(actual.runDetails, runs);
assert.deepEqual(actual.resolvedRuns, runs);
assert.deepEqual(actual.flows, fixture.flows.map(flowView));
assert.deepEqual(actual.managedFlow, fixture.flows[0]);
for (const [index, flow] of fixture.flows.entries()) {
const tasks = fixture.tasks.filter((task) => task.parentFlowId === flow.flowId).map(runView);
assert.deepEqual(actual.flowDetails[index], {
...flowView(flow),
state: flow.stateJson,
tasks,
taskSummary: summary(tasks.length),
});
assert.deepEqual(actual.flowSummaries[index], summary(tasks.length));
}
}
export function assertTaskflowGatewayReads(pages, details, fixture) {
assert.equal(pages.length, 2);
assert.equal(pages[0].tasks.length, 2);
assert.equal(pages[1].tasks.length, 1);
assert.equal(typeof pages[0].nextCursor, "string");
assert(pages[0].nextCursor.length > 0);
assert.equal(pages[1].nextCursor, undefined);
assert.deepEqual(
pages.flatMap((page) => page.tasks.map((task) => task.id)),
TASKFLOW_TASK_IDS,
);
assert.equal(details.length, fixture.tasks.length);
for (const [index, task] of fixture.tasks.entries()) {
const listed = pages.flatMap((page) => page.tasks)[index];
const detailed = details[index].task;
for (const result of [listed, detailed]) {
assert.equal(result.id, task.taskId);
assert.equal(result.taskId, task.taskId);
assert.equal(result.title, task.label);
assert.equal(result.kind, task.runtime);
assert.equal(result.status, "completed");
assert.equal(result.execution.state, "finished");
for (const key of [
"runtime",
"ownerKey",
"agentId",
"runId",
"sourceId",
"createdAt",
"startedAt",
"endedAt",
"toolUseCount",
"lastToolName",
"progressSummary",
"terminalSummary",
"deliveryStatus",
"terminalOutcome",
]) {
assert.deepEqual(result[key], task[key], `Gateway ${task.taskId}.${key}`);
}
assert.equal(result.flowId, task.parentFlowId);
assert.equal(result.sessionKey, task.requesterSessionKey);
assert.equal(result.updatedAt, task.lastEventAt);
// The protocol advertises a requester-session history route even without a transcript.
assert.equal(result.hasTranscript, true);
}
assert.equal(detailed.prompt, task.task);
assert.equal(detailed.result, task.terminalSummary);
assert.equal(listed.prompt, undefined);
}
}

View file

@ -0,0 +1,63 @@
import {
TASKFLOW_FLOW_IDS,
TASKFLOW_METHOD,
TASKFLOW_OWNER,
TASKFLOW_PLUGIN_ID,
TASKFLOW_TASK_IDS,
} from "./taskflow-restoration-fixture.mjs";
export default {
id: TASKFLOW_PLUGIN_ID,
register(api) {
// Registration is inert on the published baseline; only the candidate is queried.
api.registerGatewayMethod(
TASKFLOW_METHOD,
async ({ respond }) => {
const tasks = api.runtime.tasks.async;
const binding = { sessionKey: TASKFLOW_OWNER, agentId: "main" };
const runs = tasks.runs.bindSession(binding);
const flows = tasks.flows.bindSession(binding);
const managed = tasks.managedFlows.bindSession(binding);
const timings = [];
const read = async (name, operation) => {
const started = performance.now();
try {
return await operation();
} finally {
timings.push({ name, elapsedMs: performance.now() - started });
}
};
const runDetails = [];
const resolvedRuns = [];
for (const [index, id] of TASKFLOW_TASK_IDS.entries()) {
runDetails.push(await read(`runs.get:${id}`, () => runs.get(id)));
resolvedRuns.push(
await read(`runs.resolve:${id}`, () => runs.resolve(`update-cell-run-0${index + 1}`)),
);
}
const flowDetails = [];
const flowSummaries = [];
for (const id of TASKFLOW_FLOW_IDS) {
flowDetails.push(await read(`flows.get:${id}`, () => flows.get(id)));
flowSummaries.push(
await read(`flows.getTaskSummary:${id}`, () => flows.getTaskSummary(id)),
);
}
respond(true, {
ownerKey: TASKFLOW_OWNER,
runtimeVersion: api.runtime.version,
stateDir: process.env.OPENCLAW_STATE_DIR,
runs: await read("runs.list", () => runs.list()),
runDetails,
resolvedRuns,
flows: await read("flows.list", () => flows.list()),
flowDetails,
flowSummaries,
managedFlow: await read("managedFlows.get", () => managed.get(TASKFLOW_FLOW_IDS[0])),
timings,
});
},
{ scope: "operator.admin" },
);
},
};

View file

@ -0,0 +1,337 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import {
assertTaskflowGatewayReads,
assertTaskflowSdkReads,
assertTaskflowSnapshot,
createTaskflowFixture,
normalizeTaskflowSnapshot,
TASKFLOW_METHOD,
TASKFLOW_PLUGIN_MANIFEST,
TASKFLOW_TASK_IDS,
} from "./taskflow-restoration-fixture.mjs";
import taskflowPlugin from "./taskflow-restoration-plugin.mjs";
import { resolveWorkerCellExport } from "./worker-cell-package.mjs";
const BASELINE_COMMIT = "3a9d69db306cd7f081e06254cb89c4bcc14a7107";
const pluginId = taskflowPlugin.id;
const BASELINE_MODULES = {
"task-registry.store.sqlite-CI1kWe-v.mjs":
"d4979372e7232d63ab7a92531b2ebab94631a74b88ac902ccbe0bcf58b5253b2",
"task-flow-registry.store.sqlite-B6eAniay.mjs":
"e04935c9d03883dc4da22af6443279754fda4d616bc85e38e59f7c2e6d1c5b66",
};
const { positionals, values } = parseArgs({
allowPositionals: true,
options: {
"package-root": { type: "string" },
"expected-commit": { type: "string" },
url: { type: "string" },
},
});
const [mode] = positionals;
assert(
positionals.length === 1 && ["seed", "assert-state", "probe"].includes(mode),
"Expected seed, assert-state, or probe",
);
assert(values["package-root"], "--package-root is required");
const packageRoot = await fs.realpath(values["package-root"]);
const artifacts = process.env.OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT;
const runtimeRoot = process.env.OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT;
const stateDir = process.env.OPENCLAW_STATE_DIR;
const configPath = process.env.OPENCLAW_CONFIG_PATH;
assert(artifacts && runtimeRoot && stateDir && configPath, "Missing isolated survivor paths");
assert(path.isAbsolute(stateDir) && path.isAbsolute(configPath), "State paths must be absolute");
assert(
path.resolve(stateDir).startsWith(`${path.resolve(runtimeRoot)}${path.sep}`),
"Taskflow state must belong to its isolated runtime",
);
assert(
path.resolve(configPath).startsWith(`${path.resolve(stateDir)}${path.sep}`),
"Taskflow config must belong to its state root",
);
const expectedFile = path.join(artifacts, "taskflow-seed.json");
const moduleEvidence = [];
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
async function readJson(file) {
return JSON.parse(await fs.readFile(file, "utf8"));
}
async function writeJson(file, value) {
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
}
async function installedFile(relative) {
const file = await fs.realpath(path.join(packageRoot, relative));
assert(file.startsWith(`${packageRoot}${path.sep}`), "Installed module escaped package root");
return file;
}
const manifest = await readJson(await installedFile("package.json"));
const build = await readJson(await installedFile("dist/build-info.json"));
assert.equal(manifest.name, "openclaw");
assert.equal(build.version, manifest.version);
const expectedCommit = mode === "seed" ? BASELINE_COMMIT : values["expected-commit"];
assert(/^[a-f0-9]{40}$/u.test(expectedCommit ?? ""), "Expected exact package commit");
assert.equal(build.commit, expectedCommit, "Installed package is not the selected source");
if (mode === "seed") {
assert.equal(manifest.version, "2026.9.4");
}
async function loadOwner(prefix, names) {
const files = (await fs.readdir(path.join(packageRoot, "dist"))).filter(
(name) => name.startsWith(`${prefix}-`) && name.endsWith(".mjs"),
);
const matches = [];
for (const name of files) {
const file = await installedFile(`dist/${name}`);
const source = await fs.readFile(file, "utf8");
const exports = names.map((symbol) => resolveWorkerCellExport(source, symbol));
if (exports.every(Boolean)) {
matches.push({ file, name, exports, sha256: digest(source) });
}
}
assert.equal(matches.length, 1, `Expected one installed ${prefix} owner`);
const match = matches[0];
if (mode === "seed") {
assert.equal(match.sha256, BASELINE_MODULES[match.name], "Published owner bytes changed");
}
moduleEvidence.push({ file: match.file, sha256: match.sha256, names, exports: match.exports });
const module = await import(pathToFileURL(match.file).href);
return Object.fromEntries(
names.map((name, index) => {
const operation = module[match.exports[index]];
assert.equal(typeof operation, "function", `Missing installed owner operation ${name}`);
return [name, operation];
}),
);
}
async function withStores(operation) {
const task = await loadOwner("task-registry.store.sqlite", [
"upsertTaskWithDeliveryStateToSqlite",
"loadTaskRegistryStateFromSqlite",
"closeTaskRegistryDatabase",
]);
const flow = await loadOwner("task-flow-registry.store.sqlite", [
"upsertTaskFlowRegistryRecordToSqlite",
"loadTaskFlowRegistryStateFromSqlite",
"closeTaskFlowRegistryDatabase",
]);
let failure;
let result;
try {
result = operation(task, flow);
} catch (error) {
failure = error;
}
const errors = failure ? [failure] : [];
for (const close of [flow.closeTaskFlowRegistryDatabase, task.closeTaskRegistryDatabase]) {
try {
close();
} catch (error) {
errors.push(error);
}
}
if (errors.length) {
throw new AggregateError(errors, "Task/flow owner operation or close failed");
}
return result;
}
function snapshot(task, flow) {
return normalizeTaskflowSnapshot({
...task.loadTaskRegistryStateFromSqlite(),
...flow.loadTaskFlowRegistryStateFromSqlite(),
});
}
async function databaseMetrics() {
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
const sizes = {};
for (const suffix of ["", "-wal", "-shm"]) {
try {
sizes[suffix || "database"] = (await fs.stat(`${databasePath}${suffix}`)).size;
} catch (error) {
if (error.code !== "ENOENT" || suffix === "") {
throw error;
}
sizes[suffix] = 0;
}
}
// Both store owners are closed. This observer never repairs or writes schema.
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(databasePath, { readOnly: true });
try {
return {
databasePath,
sizes,
schemaVersion: db.prepare("PRAGMA user_version").get().user_version,
};
} finally {
db.close();
}
}
async function seed() {
const fixture = createTaskflowFixture(Date.now());
const restored = await withStores((task, flow) => {
for (const record of fixture.flows) {
flow.upsertTaskFlowRegistryRecordToSqlite(record);
}
for (const [index, record] of fixture.tasks.entries()) {
task.upsertTaskWithDeliveryStateToSqlite({
task: record,
deliveryState: fixture.deliveryStates[index],
});
}
const actual = snapshot(task, flow);
assertTaskflowSnapshot(actual, fixture);
return actual;
});
const pluginRoot = path.join(runtimeRoot, "taskflow-plugin");
await fs.mkdir(pluginRoot, { recursive: true });
for (const file of ["taskflow-restoration-plugin.mjs", "taskflow-restoration-fixture.mjs"]) {
await fs.copyFile(new URL(file, import.meta.url), path.join(pluginRoot, file));
}
await writeJson(path.join(pluginRoot, "package.json"), {
name: "@openclaw-test/taskflow-survivor",
version: "1.0.0",
type: "module",
openclaw: { extensions: ["./taskflow-restoration-plugin.mjs"] },
});
await writeJson(path.join(pluginRoot, "openclaw.plugin.json"), TASKFLOW_PLUGIN_MANIFEST);
const token = process.env.GATEWAY_AUTH_TOKEN_REF;
assert(token, "Missing synthetic Gateway token");
await writeJson(configPath, {
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "token", token },
controlUi: { enabled: false },
},
agents: {
defaults: { workspace: path.join(runtimeRoot, "workspace"), heartbeat: { every: "0m" } },
},
plugins: {
allow: [pluginId],
load: { paths: [pluginRoot] },
entries: { [pluginId]: { enabled: true } },
},
});
await writeJson(expectedFile, {
build,
fixture,
snapshot: restored,
modules: moduleEvidence,
sha256: digest(JSON.stringify(restored)),
database: await databaseMetrics(),
});
}
async function assertState() {
const expected = await readJson(expectedFile);
const actual = await withStores(snapshot);
await writeJson(path.join(artifacts, "taskflow-after.json"), {
build,
snapshot: actual,
modules: moduleEvidence,
sha256: digest(JSON.stringify(actual)),
database: await databaseMetrics(),
});
assertTaskflowSnapshot(actual, expected.snapshot);
}
async function probe() {
assert(values.url, "--url is required");
const url = new URL(values.url);
assert(
url.protocol === "ws:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname),
"Expected isolated loopback Gateway",
);
const expected = await readJson(expectedFile);
assert(process.env.GATEWAY_AUTH_TOKEN_REF, "Missing synthetic Gateway token");
const require = createRequire(path.join(packageRoot, "package.json"));
const sdkPath = await fs.realpath(require.resolve("openclaw/plugin-sdk/gateway-runtime"));
assert(sdkPath.startsWith(`${packageRoot}${path.sep}`), "Gateway SDK escaped installed package");
const { GatewayClient } = await import(pathToFileURL(sdkPath).href);
let resolveHello;
let rejectHello;
const helloPromise = new Promise((resolve, reject) => {
resolveHello = resolve;
rejectHello = reject;
});
const client = new GatewayClient({
url: values.url,
token: process.env.GATEWAY_AUTH_TOKEN_REF,
clientName: "cli",
mode: "cli",
role: "operator",
scopes: ["operator.admin"],
deviceIdentity: null,
requestTimeoutMs: 60_000,
onHelloOk: resolveHello,
onConnectError: rejectHello,
});
const timer = setTimeout(
() => rejectHello(new Error("Taskflow Gateway connection timed out")),
60_000,
);
const started = performance.now();
const evidence = { build, sdkPath, calls: [] };
const output = path.join(artifacts, "taskflow-gateway.json");
const request = async (method, params) => {
const start = performance.now();
try {
const payload = await client.request(method, params);
evidence.calls.push({ method, params, payload, elapsedMs: performance.now() - start });
return payload;
} finally {
await writeJson(output, evidence);
}
};
try {
client.start();
const hello = await helloPromise;
// hello.auth may contain a device token. The server object carries connId/bootId/buildId.
evidence.hello = { type: hello.type, protocol: hello.protocol, server: hello.server };
assert.equal(typeof hello.server.connId, "string");
clearTimeout(timer);
const query = { limit: 2, sortBy: "updatedAt" };
const first = await request("tasks.list", query);
assert.equal(typeof first.nextCursor, "string", "First page lacks continuation");
const second = await request("tasks.list", { ...query, cursor: first.nextCursor });
const details = [];
for (const taskId of TASKFLOW_TASK_IDS) {
details.push(await request("tasks.get", { taskId }));
}
const sdk = await request(TASKFLOW_METHOD, {});
await writeJson(output, {
...evidence,
elapsedMs: performance.now() - started,
pages: [first, second],
details,
sdk,
});
assert.equal(sdk.stateDir, stateDir, "SDK reads used a different state root");
assert.equal(sdk.runtimeVersion, manifest.version);
assertTaskflowGatewayReads([first, second], details, expected.fixture);
assertTaskflowSdkReads(sdk, expected.fixture);
} finally {
clearTimeout(timer);
await client.stopAndWait();
}
}
await (mode === "seed" ? seed() : mode === "assert-state" ? assertState() : probe());
console.log(`taskflow-restoration:${mode} passed commit=${build.commit}`);

View file

@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const baselineVersion = "2026.9.4";
const baselineCommit = "3a9d69db306cd7f081e06254cb89c4bcc14a7107";
const baselineUrl = "https://registry.npmjs.org/openclaw/-/openclaw-2026.9.4.tgz";
const baselineIntegrity =
"sha512-lTQpEEe1Xm3u2PCHaPEr+vP8paGk1vLdHuzdItsNToaLI6hAqRVvgJYg+GxukJhETJp4tPy/S1Gftl4KuB8n7A==";
function hash(bytes, algorithm = "sha256", encoding = "hex") {
return createHash(algorithm).update(bytes).digest(encoding);
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
}
// npm owns dependency reification. Compare the immutable application payload,
// including its complete dist inventory, separately from installed node_modules.
export function readWorkerCellPackageIdentity(packageRoot) {
const files = {};
const visit = (relative) => {
const file = path.join(packageRoot, relative);
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink()) {
files[relative] = { symlink: fs.readlinkSync(file) };
} else if (stat.isDirectory()) {
for (const name of fs.readdirSync(file).toSorted((a, b) => a.localeCompare(b))) {
visit(path.posix.join(relative, name));
}
} else {
assert(stat.isFile(), `Unsupported package entry: ${relative}`);
files[relative] = { sha256: hash(fs.readFileSync(file)), size: stat.size };
}
};
for (const relative of ["package.json", "openclaw.mjs", "dist"]) {
visit(relative);
}
const manifest = readJson(path.join(packageRoot, "package.json"));
assert.equal(manifest.name, "openclaw");
const buildInfo = readJson(path.join(packageRoot, "dist/build-info.json"));
assert.equal(buildInfo.version, manifest.version);
assert.match(buildInfo.commit, /^[a-f0-9]{40}$/u);
return { version: manifest.version, buildInfo, files };
}
export function assertWorkerCellPackageIdentity(actual, expected) {
assert.deepEqual(
actual,
expected,
"Installed application payload differs from the frozen tarball",
);
}
export function resolveWorkerCellExport(source, name) {
const matches = [];
for (const block of source.matchAll(/export\s*\{([^}]+)\}\s*;/gu)) {
for (const specifier of block[1].split(",")) {
const parts = specifier.trim().split(/\s+as\s+/u);
if (parts[0] === name && parts.length <= 2) {
matches.push(parts[1] ?? parts[0]);
}
}
}
assert(matches.length <= 1, `Ambiguous compiled export ${name}`);
return matches[0];
}
function inspectTarball(tarball, runtimeRoot) {
const bytes = fs.readFileSync(tarball);
const sha256 = hash(bytes);
const integrity = `sha512-${hash(bytes, "sha512", "base64")}`;
const scratch = fs.mkdtempSync(path.join(runtimeRoot, "package-identity-"));
try {
execFileSync(
"tar",
[
"-xzf",
tarball,
"-C",
scratch,
"package/package.json",
"package/openclaw.mjs",
"package/dist",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
return { sha256, integrity, ...readWorkerCellPackageIdentity(path.join(scratch, "package")) };
} finally {
fs.rmSync(scratch, { recursive: true, force: true });
}
}
async function main() {
const [mode, packageRoot, candidateTarball] = process.argv.slice(2);
const artifacts = process.env.OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT;
const runtimeRoot = process.env.OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT;
assert(artifacts && runtimeRoot && packageRoot, "Missing isolated worker-cell paths");
if (mode === "baseline") {
const response = await fetch(baselineUrl);
assert(response.ok, `Published baseline download failed: ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
assert.equal(`sha512-${hash(bytes, "sha512", "base64")}`, baselineIntegrity);
const tarball = path.join(runtimeRoot, "published-driver.tgz");
fs.writeFileSync(tarball, bytes, { flag: "wx" });
const expected = inspectTarball(tarball, runtimeRoot);
assert.equal(expected.version, baselineVersion);
assert.equal(expected.buildInfo.commit, baselineCommit);
const actual = readWorkerCellPackageIdentity(packageRoot);
assertWorkerCellPackageIdentity(actual, {
version: expected.version,
buildInfo: expected.buildInfo,
files: expected.files,
});
writeJson(path.join(artifacts, "baseline-package-identity.json"), {
url: baselineUrl,
cli: fs.realpathSync(path.join(packageRoot, "openclaw.mjs")),
...expected,
});
} else if (mode === "candidate") {
assert(candidateTarball, "Missing frozen candidate tarball");
const expected = inspectTarball(candidateTarball, runtimeRoot);
assert.equal(
expected.buildInfo.commit,
process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA,
"Candidate build commit must equal the selected source SHA",
);
assert.notEqual(
expected.buildInfo.commit,
baselineCommit,
"Candidate still contains published bytes",
);
writeJson(path.join(artifacts, "candidate-package-identity.json"), expected);
} else if (mode === "installed") {
const expected = readJson(path.join(artifacts, "candidate-package-identity.json"));
assert.equal(
hash(fs.readFileSync(candidateTarball)),
expected.sha256,
"Candidate tarball changed",
);
const actual = readWorkerCellPackageIdentity(packageRoot);
assertWorkerCellPackageIdentity(actual, {
version: expected.version,
buildInfo: expected.buildInfo,
files: expected.files,
});
writeJson(path.join(artifacts, "installed-package-identity.json"), {
cli: fs.realpathSync(path.join(packageRoot, "openclaw.mjs")),
...actual,
});
} else {
throw new Error("Expected baseline, candidate, or installed package-identity mode");
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}

View file

@ -11,6 +11,7 @@ set -euo pipefail
PACKAGE_TGZ=""
AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT=""
UPGRADE_SCENARIO_STAGE=""
WORKER_RUNTIME_HOST_ROOT=""
run_completed="0"
diagnostics_ready=0
cleanup_outer() {
@ -44,6 +45,21 @@ cleanup_outer() {
if [ -n "$UPGRADE_SCENARIO_STAGE" ]; then
rm -rf "$UPGRADE_SCENARIO_STAGE"
fi
if [ -n "$WORKER_RUNTIME_HOST_ROOT" ]; then
if [ "$exit_status" -eq 0 ] && [ "$run_completed" = "1" ]; then
# The image user owns the private child and can differ from the host user.
if ! docker_e2e_docker_cmd run --rm --network none \
--entrypoint rm \
-v "$WORKER_RUNTIME_HOST_ROOT:/tmp/openclaw-worker-cleanup" \
"$IMAGE_NAME" -rf -- /tmp/openclaw-worker-cleanup/runtime ||
! rm -rf "$WORKER_RUNTIME_HOST_ROOT"; then
echo "Worker-cell runtime cleanup failed: $WORKER_RUNTIME_HOST_ROOT" >&2
exit_status=1
fi
else
echo "Preserved failed synthetic worker-cell state: $WORKER_RUNTIME_HOST_ROOT" >&2
fi
fi
if [ "$exit_status" -ne 0 ]; then
printf '[upgrade-survivor] FAILED (exit %s)\n' "$exit_status" >&2
fi
@ -141,6 +157,7 @@ SKIP_BUILD="${OPENCLAW_UPGRADE_SURVIVOR_E2E_SKIP_BUILD:-0}"
DOCKER_RUN_TIMEOUT="${OPENCLAW_UPGRADE_SURVIVOR_DOCKER_RUN_TIMEOUT:-1200s}"
BASELINE_SPEC="${OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC:-}"
SCENARIO="${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}"
SURVIVOR_RUNTIME_ROOT="${OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT:-/tmp/openclaw-upgrade-survivor-runtime}"
if [ "$OPENCLAW_FROZEN_UPGRADE_SURVIVOR_CLAWHUB_MODE" = legacy ]; then
legacy_clawhub_package="@openclaw/whatsapp"
[ "$SCENARIO" = configured-plugin-installs ] && legacy_clawhub_package="@openclaw/matrix"
@ -218,6 +235,15 @@ if [ "$SCENARIO" = "abandoned-update" ] && {
exit 1
fi
if [ "$SCENARIO" = "projects-doctor" ] || [ "$SCENARIO" = "taskflow-restoration" ]; then
if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" != "1" ] ||
[ "$BASELINE_SPEC" != "openclaw@2026.9.4" ] ||
[ "$UPDATE_RESTART_MODE" != "manual" ] || [ "$ROOT_MANAGED_VPS" != "0" ] || [ "$LIVE_OPENAI" != "0" ]; then
echo "$SCENARIO requires published openclaw@2026.9.4, manual restart, isolated state, and no live provider" >&2
exit 1
fi
fi
if [ "$SCENARIO" = "workshop-doctor-recovery" ] && {
[ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" != "1" ] ||
[ "$UPDATE_RESTART_MODE" != "manual" ] || [ "$ROOT_MANAGED_VPS" != "0" ] || [ "$LIVE_OPENAI" != "0" ];
@ -303,6 +329,10 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
fi
mkdir -p "$ARTIFACT_DIR"
if [ "$SCENARIO" = "projects-doctor" ] || [ "$SCENARIO" = "taskflow-restoration" ]; then
ARTIFACT_DIR="$(mktemp -d "$ARTIFACT_DIR/worker-run.XXXXXX")"
echo "Worker survivor artifacts: $ARTIFACT_DIR"
fi
chmod -R a+rwX "$ARTIFACT_DIR" || true
prepare_diagnostics_capture
@ -335,6 +365,11 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
CANDIDATE_SPEC="$(normalize_npm_candidate "$CANDIDATE_RAW")"
fi
if { [ "$SCENARIO" = "projects-doctor" ] || [ "$SCENARIO" = "taskflow-restoration" ]; } && [ "$CANDIDATE_KIND" != "tarball" ]; then
echo "$SCENARIO requires a frozen candidate tarball" >&2
exit 1
fi
if [ "$CANDIDATE_IS_CURRENT" = "1" ] && [ -z "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
registry_required="$(
OPENCLAW_DOCKER_ALL_LANES=published-upgrade-survivor \
@ -371,6 +406,16 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)"
if [ "$SCENARIO" = "projects-doctor" ] || [ "$SCENARIO" = "taskflow-restoration" ]; then
WORKER_RUNTIME_HOST_ROOT="$(mktemp -d "$ARTIFACT_DIR/worker-runtime.XXXXXX")"
chmod a+rwx "$WORKER_RUNTIME_HOST_ROOT"
UPGRADE_SCENARIO_ARGS+=(
-v "$WORKER_RUNTIME_HOST_ROOT:$SURVIVOR_RUNTIME_ROOT"
)
# The container user creates/owns the private child, not the host mount point.
SURVIVOR_RUNTIME_ROOT="$SURVIVOR_RUNTIME_ROOT/runtime"
fi
docker_e2e_build_or_reuse "$IMAGE_NAME" upgrade-survivor "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" "$SKIP_BUILD"
echo "Running published upgrade survivor Docker E2E..."
@ -383,7 +428,7 @@ if [ "${OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE:-0}" = "1" ]; then
-e OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE_SPEC="$CANDIDATE_SPEC" \
-e OPENCLAW_DOCKER_E2E_SELECTED_SHA="${OPENCLAW_DOCKER_E2E_SELECTED_SHA:-}" \
-e OPENCLAW_UPGRADE_SURVIVOR_SCENARIO="$SCENARIO" \
-e OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT="${OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT:-/tmp/openclaw-upgrade-survivor-runtime}" \
-e OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT="$SURVIVOR_RUNTIME_ROOT" \
-e OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE="$UPDATE_RESTART_MODE" \
-e OPENCLAW_UPGRADE_SURVIVOR_COMMAND_TIMEOUT="$COMMAND_TIMEOUT" \
-e OPENCLAW_UPGRADE_SURVIVOR_VOLUME_SESSIONS="${OPENCLAW_UPGRADE_SURVIVOR_VOLUME_SESSIONS:-}" \

View file

@ -134,7 +134,7 @@ child.on("error", (error) => {
process.exit(127);
});
' "$timeout_value" "$@"
return
return "$?"
fi
echo "timeout command not found; cannot bound Docker command after ${timeout_value}" >&2
return 127
@ -152,7 +152,8 @@ docker_e2e_docker_cmd() {
shift
docker_e2e_docker_run_resource_args "$@" || return $?
docker_e2e_docker_run_with_resource_diagnostics "$timeout_value" "$@"
return
# A bare return in an EXIT trap can restore the trap's original status.
return "$?"
fi
docker_e2e_timeout_cmd "$timeout_value" docker "$@"
}
@ -168,7 +169,7 @@ docker_e2e_docker_run_cmd() {
shift
docker_e2e_docker_run_resource_args "$@" || return $?
docker_e2e_docker_run_with_resource_diagnostics "$timeout_value" "$@"
return
return "$?"
fi
docker_e2e_timeout_cmd "$timeout_value" docker "$@"
}

View file

@ -133,6 +133,14 @@ const UPGRADE_SURVIVOR_RUNTIME_COMPANION_PACKAGES = ["@openclaw/codex"];
// Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail
// closed instead of requiring a dependency or reimplementing a JavaScript parser.
const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([
[
"f2549a057028829ff5286db89d357e5b3d4ec1f5cdb3ca07672b7e34a739b60a",
"base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"b0166f96bf3839d53ce94721b563fcf2b6604ddef04028cd8d9c7769275566c7",
"base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"7d9d7520c2c34d51fff78e542a7f539b77080bfd04648438e95aec4af3fe362e",
"base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
@ -690,6 +698,8 @@ export function requiredPrepublishPluginPackagesForLanes(poolLanes: DockerE2eLan
!scenario ||
scenario === "abandoned-update" ||
scenario === "custom-plugin-siblings" ||
scenario === "projects-doctor" ||
scenario === "taskflow-restoration" ||
scenario === "workshop-doctor-recovery"
) {
continue;

View file

@ -27,11 +27,11 @@ docker_e2e_resource_limit_temp_dir() {
local template="${TMPDIR:-/tmp}/openclaw-docker-resource-limits.XXXXXX"
if command -v mktemp >/dev/null 2>&1; then
mktemp -d "$template"
return
return "$?"
fi
if [ -x /usr/bin/mktemp ]; then
/usr/bin/mktemp -d "$template"
return
return "$?"
fi
echo "mktemp command not found; cannot create Docker resource-limit diagnostics" >&2
return 127
@ -40,11 +40,11 @@ docker_e2e_resource_limit_temp_dir() {
docker_e2e_diagnostic_bin() {
if command -v "$1" >/dev/null 2>&1; then
command -v "$1"
return
return "$?"
fi
if [ -x "/usr/bin/$1" ]; then
printf '%s\n' "/usr/bin/$1"
return
return "$?"
fi
return 1
}
@ -52,7 +52,7 @@ docker_e2e_diagnostic_bin() {
docker_e2e_remove_diagnostic_dir() {
if command -v rm >/dev/null 2>&1; then
rm -rf "$1"
return
return "$?"
fi
/bin/rm -rf "$1"
}
@ -66,7 +66,7 @@ docker_e2e_docker_run_with_resource_diagnostics() {
shift
if [ "${#DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" -eq 0 ]; then
docker_e2e_timeout_cmd "$timeout_value" docker run "$@"
return
return "$?"
fi
local diagnostic_dir=""
@ -74,7 +74,7 @@ docker_e2e_docker_run_with_resource_diagnostics() {
docker_e2e_timeout_cmd \
"$timeout_value" \
docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@"
return
return "$?"
fi
local tee_bin=""
if ! tee_bin="$(docker_e2e_diagnostic_bin tee)"; then
@ -82,7 +82,7 @@ docker_e2e_docker_run_with_resource_diagnostics() {
docker_e2e_timeout_cmd \
"$timeout_value" \
docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@"
return
return "$?"
fi
local tail_bin=""
if ! tail_bin="$(docker_e2e_diagnostic_bin tail)"; then
@ -90,7 +90,7 @@ docker_e2e_docker_run_with_resource_diagnostics() {
docker_e2e_timeout_cmd \
"$timeout_value" \
docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@"
return
return "$?"
fi
local stderr_file="${diagnostic_dir}/stderr"
local stderr_fifo="${diagnostic_dir}/stderr.pipe"
@ -102,7 +102,7 @@ docker_e2e_docker_run_with_resource_diagnostics() {
docker_e2e_timeout_cmd \
"$timeout_value" \
docker run "${DOCKER_E2E_RUN_RESOURCE_ARGS[@]}" "$@"
return
return "$?"
fi
# Some tail implementations reopen named FIFOs passed through stdin and wait for a new writer.

View file

@ -13,6 +13,8 @@ const UPGRADE_SURVIVOR_SCENARIOS = Object.freeze([
"configured-plugin-installs",
"missing-configured-plugin-migration",
"custom-plugin-siblings",
"projects-doctor",
"taskflow-restoration",
"stale-source-plugin-shadow",
"prerelease-plugin-registry",
"tilde-log-path",
@ -43,6 +45,8 @@ const scenarioMinimumBaselines = new Map([
const TRUSTED_HARNESS_OWNED_SCENARIOS = new Set([
"mobile-pairing-reconnect",
"abandoned-update",
"projects-doctor",
"taskflow-restoration",
"workshop-doctor-recovery",
]);
@ -60,6 +64,8 @@ const aggregateScenarios = UPGRADE_SURVIVOR_SCENARIOS.filter(
scenario !== "msteams-polls" &&
scenario !== "abandoned-update" &&
scenario !== "missing-configured-plugin-migration" &&
scenario !== "projects-doctor" &&
scenario !== "taskflow-restoration" &&
scenario !== "workshop-doctor-recovery" &&
scenario !== "mobile-pairing-reconnect" &&
scenario !== "watchos-direct-node" &&
@ -156,6 +162,9 @@ function comparePublishedReleaseVersion(a, b) {
export function supportsUpgradeSurvivorScenarioAtBaseline(scenario, baselineSpec) {
const version = parsePublishedReleaseVersion(baselineSpec);
if (scenario === "projects-doctor" || scenario === "taskflow-restoration") {
return baselineSpec === "openclaw@2026.9.4";
}
if (scenario === "abandoned-update" || scenario === "missing-configured-plugin-migration") {
return baselineSpec === "openclaw@2026.9.2";
}

View file

@ -2526,6 +2526,62 @@ stderr="$(<"$TMPDIR/stderr")"
execDockerSnippet(script);
});
describe.each(process.platform === "darwin" ? ["/bin/bash", "bash"] : ["/bin/bash"])(
"%s EXIT-trap Docker status",
(shell) => {
const cases = ["docker_e2e_docker_cmd", "docker_e2e_docker_run_cmd"].flatMap((helper) =>
["normal", "no-diagnostics", "node-watchdog"].flatMap((mode) =>
[
{ entryStatus: 0, commandStatus: 43 },
{ entryStatus: 42, commandStatus: 0 },
].map(({ entryStatus, commandStatus }) => ({ helper, mode, entryStatus, commandStatus })),
),
);
it.each(cases)(
"preserves $helper status $commandStatus in $mode after exit $entryStatus",
({ helper, mode, entryStatus, commandStatus }) => {
const workDir = tempDirs.make("docker-exit-status-");
writeExecutables(join(workDir, "bin"), {
timeout: PASSTHROUGH_TIMEOUT_SCRIPT,
node: `#!/bin/bash\nexec ${shellQuote(testNodeExecPath)} "$@"\n`,
docker: `#!/bin/bash\nexit ${commandStatus}\n`,
});
const setup =
mode === "no-diagnostics"
? "docker_e2e_resource_limit_temp_dir() { return 1; }"
: mode === "node-watchdog"
? "docker_e2e_timeout_bin() { return 1; }"
: "";
const script = repoShell(workDir)`
export PATH="$TMPDIR/bin:$PATH"
export OPENCLAW_DOCKER_E2E_DISABLE_RESOURCE_LIMITS=1
source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh"
${setup}
on_exit() {
trap - EXIT
set +e
if ${helper} run demo; then
observed=0
else
observed="$?"
fi
printf '%s\\n' "$observed"
exit 0
}
trap on_exit EXIT
exit ${entryStatus}
`;
const result = spawnSync(shell, ["--noprofile", "--norc", "-c", script], {
encoding: "utf8",
env: { ...process.env, BASH_ENV: "", ENV: "" },
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe(`${commandStatus}\n`);
},
);
},
);
it("uses a Node watchdog for Docker commands when timeout is unavailable", () => {
const workDir = tempDirs.make("openclaw-docker-node-timeout-");
writeExecutables(join(workDir, "bin"), {
@ -3041,7 +3097,7 @@ docker_e2e_docker_run_cmd run demo
);
expect(publishedRunner).toContain(
[
'if [ "$SCENARIO" = "watchos-direct-node" ] || [ "$SCENARIO" = "mobile-pairing-reconnect" ]; then',
'if [ "$SCENARIO" = "watchos-direct-node" ] || [ "$SCENARIO" = "mobile-pairing-reconnect" ] || [ "$WORKER_CELL" = "1" ]; then',
" unset OPENAI_API_KEY DISCORD_BOT_TOKEN TELEGRAM_BOT_TOKEN",
"else",
' export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"',

View file

@ -1456,6 +1456,32 @@ await import('./scripts/check-docker-e2e-boundaries.mts');`,
).toContain(name);
});
it.each(["projects-doctor", "taskflow-restoration"])(
"plans %s only for its exact published writer without registry or credential fixtures",
(scenario) => {
const plan = planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.9.3 2026.9.4 2026.9.5 latest",
upgradeSurvivorScenarios: scenario,
});
const name = `published-upgrade-survivor-2026.9.4-${scenario}`;
expect(plan.lanes.map(summarizeLane)).toEqual([
publishedUpgradeSurvivorLane(name, "openclaw@2026.9.4", scenario),
]);
expect(plan.requiredPrepublishPluginPackages).toEqual([]);
expect(plan.credentials).toEqual([]);
for (const alias of ["reported-issues", "far-reaching"]) {
expect(
planFor({
selectedLaneNames: ["published-upgrade-survivor"],
upgradeSurvivorBaselines: "2026.9.4",
upgradeSurvivorScenarios: alias,
}).lanes.map((lane) => lane.name),
).not.toContain(name);
}
},
);
it("keeps platform survivors out of release aliases", () => {
const scenariosFor = (
upgradeSurvivorScenarios: string,

View file

@ -1,6 +1,13 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@ -64,6 +71,19 @@ printf '{"dir":"%s"}\n' "$OPENCLAW_DOCKER_ALL_LOG_DIR/prepublish-plugin-registry
set -euo pipefail
printf '%s\n' "$*" >>"$CAPTURE_DIR/docker-args"
if [ "\${1:-}" = run ]; then
for arg in "$@"; do
case "$arg" in
*:/tmp/openclaw-worker-cleanup)
printf '%s\\0' "$@" >"$CAPTURE_DIR/docker-cleanup-args"
test -f "$CAPTURE_DIR/main-run-finished"
if [ "\${FIXTURE_CLEANUP_EXIT:-0}" != 0 ]; then
exit "$FIXTURE_CLEANUP_EXIT"
fi
rm -rf "\${arg%%:*}/runtime"
exit 0
;;
esac
done
printf '%s\\0' "$@" >"$CAPTURE_DIR/docker-run-args"
if [ -n "\${FIXTURE_PAYLOAD_SHELL:-}" ]; then
exec "$FIXTURE_PAYLOAD_SHELL" -c "\${!#}"
@ -71,12 +91,21 @@ if [ "\${1:-}" = run ]; then
fi
previous=""
for arg in "$@"; do
case "$arg" in
*/worker-runtime.*:*)
mkdir -p "\${arg%%:*}/runtime"
printf 'synthetic state' >"\${arg%%:*}/runtime/state-marker"
;;
esac
if [ "$previous" = "--cidfile" ]; then
printf 'fake-container\n' >"$arg"
fi
previous="$arg"
done
[ "\${1:-}" != run ] || exit "\${FIXTURE_RUN_EXIT:-0}"
if [ "\${1:-}" = run ]; then
touch "$CAPTURE_DIR/main-run-finished"
exit "\${FIXTURE_RUN_EXIT:-0}"
fi
`,
);
@ -108,6 +137,41 @@ done
}
describe("standalone upgrade survivor plugin registry", () => {
it("keeps synthetic state through inner finalization until the Docker owner joins", () => {
const root = tempDirs.make("worker-cell-inner-finalization-");
const runtime = join(root, "runtime");
mkdirSync(runtime);
const marker = join(runtime, "state-marker");
writeFileSync(marker, "synthetic state");
const source = readFileSync("scripts/e2e/lib/upgrade-survivor/run.sh", "utf8");
const firstPhase = source.indexOf("phase storage-preflight");
expect(firstPhase).toBeGreaterThan(0);
const runner = join(root, "inner.sh");
writeFileSync(
runner,
`${source.slice(0, firstPhase)}
cleanup() { :; }
write_summary() { :; }
run_completed=1
on_exit 0
`,
);
const result = spawnSync("bash", [runner], {
encoding: "utf8",
env: {
...process.env,
OPENCLAW_UPGRADE_SURVIVOR_BASELINE: "openclaw@2026.9.4",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "projects-doctor",
OPENCLAW_UPGRADE_SURVIVOR_LIVE_OPENAI: "0",
OPENCLAW_UPGRADE_SURVIVOR_RUNTIME_ROOT: runtime,
OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON: join(root, "artifacts", "summary.json"),
},
timeout: 30_000,
});
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(marker, "utf8")).toBe("synthetic state");
});
// macOS /bin/bash is 3.2; PATH may select a newer Bash. Exercise both owners.
describe.each(process.platform === "darwin" ? ["/bin/bash", "bash"] : ["bash"])(
"%s wrapper",
@ -273,6 +337,85 @@ describe("standalone upgrade survivor plugin registry", () => {
);
expect(existsSync(packageTarball)).toBe(true);
});
it.each(["projects-doctor", "taskflow-restoration"])(
"isolates each %s run from retained evidence and preserves a failed runtime",
(scenario) => {
const artifacts = tempDirs.make("worker-cell-retained-artifacts-");
const retained = join(artifacts, "projects-inventory.json");
writeFileSync(retained, "previous evidence");
const directories = [];
for (const exitCode of ["0", "42"]) {
const { captureDir, result } = runSurvivor({
OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR: artifacts,
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: "openclaw@2026.9.4",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: scenario,
OPENCLAW_UPGRADE_SURVIVOR_E2E_IMAGE: "worker-cleanup-fixture",
FIXTURE_RUN_EXIT: exitCode,
});
expect(result.status, result.stderr).toBe(Number(exitCode));
expect(existsSync(join(captureDir, "node-args"))).toBe(false);
const directory = result.stdout.match(/Worker survivor artifacts: ([^\n]+)/u)?.[1] ?? "";
expect(existsSync(directory)).toBe(true);
directories.push(directory);
const runtimes = readdirSync(directory).filter((name) =>
name.startsWith("worker-runtime."),
);
expect(runtimes).toHaveLength(exitCode === "0" ? 0 : 1);
const cleanupArgsPath = join(captureDir, "docker-cleanup-args");
expect(existsSync(cleanupArgsPath)).toBe(exitCode === "0");
if (exitCode === "0") {
const args = readFileSync(cleanupArgsPath, "utf8").split("\0").slice(0, -1);
expect(args[args.indexOf("--network") + 1]).toBe("none");
expect(args[args.indexOf("--entrypoint") + 1]).toBe("rm");
expect(args).not.toContain("--user");
expect(args.filter((arg) => arg === "-v")).toHaveLength(1);
expect(args[args.indexOf("-v") + 1]).toMatch(
/\/worker-runtime\.[^:]+:\/tmp\/openclaw-worker-cleanup$/u,
);
expect(args.slice(-4)).toEqual([
"worker-cleanup-fixture",
"-rf",
"--",
"/tmp/openclaw-worker-cleanup/runtime",
]);
expect(readFileSync(join(captureDir, "docker-run-args"), "utf8")).toContain(
"worker-cleanup-fixture\0",
);
}
if (exitCode !== "0") {
expect(result.stderr).toContain("Preserved failed synthetic worker-cell state:");
for (const runtime of runtimes) {
expect(readFileSync(join(directory, runtime, "runtime", "state-marker"), "utf8")).toBe(
"synthetic state",
);
}
}
}
expect(new Set(directories).size).toBe(2);
expect(readFileSync(retained, "utf8")).toBe("previous evidence");
},
);
it("fails and retains state when container-owned cleanup fails", () => {
const { captureDir, result } = runSurvivor({
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: "openclaw@2026.9.4",
OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "taskflow-restoration",
FIXTURE_CLEANUP_EXIT: "43",
});
expect(result.status, result.stderr).toBe(1);
expectFinalFailure(result.stderr, 1);
expect(result.stderr).toContain("Worker-cell runtime cleanup failed:");
const args = readFileSync(join(captureDir, "docker-cleanup-args"), "utf8").split("\0");
const mount = args[args.indexOf("-v") + 1];
if (!mount) {
throw new Error("Cleanup did not mount the synthetic runtime");
}
const runtimeRoot = mount.slice(0, mount.lastIndexOf(":"));
expect(readFileSync(join(runtimeRoot, "runtime", "state-marker"), "utf8")).toBe(
"synthetic state",
);
});
});
describe("standalone upgrade survivor live OpenAI probe", () => {

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
assertProjectsDoctorResult,
assertProjectsInventory,
} from "../../scripts/e2e/lib/upgrade-survivor/projects-doctor.mjs";
const inventory = {
rows: [{ id: "retained", source: "registered", updated_at_ms: 123 }],
schema: [{ name: "projects", sql: "fixture schema" }],
workspace: "/fixture/workspace",
sentinels: { "/fixture/workspace/PROJECTS-PROOF.txt": "original" },
sqliteFamily: { main: "database", "-wal": "wal" },
configHash: "config",
retainedSnapshots: [],
};
const clean = { ok: true, checksRun: 1, checksSkipped: 27, findings: [] };
describe("Projects upgrade Doctor evidence", () => {
it("accepts the selected check and preserved state without pinning unrelated check counts", () => {
expect(() =>
assertProjectsDoctorResult(clean, inventory, structuredClone(inventory)),
).not.toThrow();
expect(() =>
assertProjectsDoctorResult({ ...clean, checksSkipped: 28 }, inventory, inventory),
).not.toThrow();
});
it.each([
{ ...clean, ok: false },
{ ...clean, checksRun: 0 },
{ ...clean, checksRun: 2 },
{ ...clean, findings: [{ checkId: "core/doctor/project-clone-shape", severity: "warning" }] },
{ ...clean, findings: [{ checkId: "core/doctor/lint-selection", severity: "error" }] },
])("rejects a skipped, failed, or incorrectly selected Doctor result %#", (report) => {
expect(() => assertProjectsDoctorResult(report, inventory, inventory)).toThrow();
});
it.each([
{ rows: [] },
{ rows: [{ id: "retained", source: "registered", updated_at_ms: 124 }] },
{ schema: [] },
{ workspace: "/fixture/other" },
{ sentinels: {} },
])("rejects changed persisted inventory or files %#", (change) => {
expect(() => assertProjectsInventory({ ...inventory, ...change }, inventory)).toThrow();
});
it.each([
{ sqliteFamily: { main: "changed", "-wal": "wal" } },
{ sqliteFamily: { main: "database" } },
{ configHash: "rewritten" },
{ retainedSnapshots: ["/fixture/cache/openclaw-sqlite-readonly-retained"] },
])("rejects Doctor mutation or incomplete snapshot disposal %#", (change) => {
expect(() =>
assertProjectsDoctorResult(clean, inventory, { ...inventory, ...change }),
).toThrow();
});
});

View file

@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
assertTaskflowSnapshot,
createTaskflowFixture,
normalizeTaskflowSnapshot,
TASKFLOW_PLUGIN_MANIFEST,
} from "../../scripts/e2e/lib/upgrade-survivor/taskflow-restoration-fixture.mjs";
import { resolveWorkerCellExport } from "../../scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs";
import { resolveGatewayStartupPluginPlanFromRegistry } from "../../src/plugins/gateway-startup-plugin-plan.js";
import type { PluginManifestRecord } from "../../src/plugins/manifest-registry.js";
import type { PluginRegistrySnapshot } from "../../src/plugins/plugin-registry-snapshot.js";
describe("taskflow survivor evidence", () => {
it("loads the SDK fixture through the actual Gateway startup plan", () => {
const manifest: PluginManifestRecord = {
...TASKFLOW_PLUGIN_MANIFEST,
origin: "config",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: "/fixtures/taskflow-survivor",
source: "/fixtures/taskflow-survivor/taskflow-restoration-plugin.mjs",
manifestPath: "/fixtures/taskflow-survivor/openclaw.plugin.json",
};
const index: PluginRegistrySnapshot = {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "test",
generatedAtMs: 0,
installRecords: {},
diagnostics: [],
plugins: [
{
pluginId: manifest.id,
manifestPath: manifest.manifestPath,
manifestHash: "fixture",
rootDir: manifest.rootDir,
origin: manifest.origin,
enabled: true,
startup: {
sidecar: manifest.activation?.onStartup === true,
memory: false,
agentHarnesses: [],
configPaths: [],
},
compat: [],
},
],
};
const plan = resolveGatewayStartupPluginPlanFromRegistry({
config: { plugins: { allow: [manifest.id], entries: { [manifest.id]: { enabled: true } } } },
env: {},
index,
manifestRegistry: { plugins: [manifest], diagnostics: [] },
});
expect(plan.pluginIds).toEqual([manifest.id]);
});
it("compares complete persisted records regardless of owner Map insertion order", () => {
const fixture = createTaskflowFixture(1_800_000_000_000);
const snapshot = {
tasks: new Map(fixture.tasks.toReversed().map((task) => [task.taskId, task])),
flows: new Map(fixture.flows.toReversed().map((flow) => [flow.flowId, flow])),
deliveryStates: new Map(fixture.deliveryStates.map((row) => [row.taskId, row])),
};
expect(() => assertTaskflowSnapshot(snapshot, fixture)).not.toThrow();
expect(normalizeTaskflowSnapshot(snapshot)).toEqual(fixture);
const changed = structuredClone(fixture);
for (const task of changed.tasks) {
task.detail.payload.enabled = false;
}
expect(() => assertTaskflowSnapshot(changed, fixture)).toThrow();
const missing = structuredClone(fixture);
missing.deliveryStates.pop();
expect(() => assertTaskflowSnapshot(missing, fixture)).toThrow();
const revision = structuredClone(fixture);
for (const flow of revision.flows) {
flow.revision += 1;
}
expect(() => assertTaskflowSnapshot(revision, fixture)).toThrow();
});
it("seeds only settled tasks with existing parent flows and no execution owner", () => {
const now = 1_800_000_000_000;
const fixture = createTaskflowFixture(now);
expect(fixture.tasks).toHaveLength(3);
for (const task of fixture.tasks) {
expect(task.status).toBe("succeeded");
expect(task.notifyPolicy).toBe("silent");
expect(task.deliveryStatus).toBe("not_applicable");
expect(task.endedAt).toBeLessThan(now);
expect(task.cleanupAfter).toBeGreaterThan(now);
expect(task).not.toHaveProperty("executionOwner");
expect(task).not.toHaveProperty("childSessionKey");
expect(fixture.flows.some((flow) => flow.flowId === task.parentFlowId)).toBe(true);
}
});
it("resolves named or minified owner exports without substituting another symbol", () => {
expect(resolveWorkerCellExport("export { loadSnapshot, other as a };", "loadSnapshot")).toBe(
"loadSnapshot",
);
expect(
resolveWorkerCellExport("export { loadSnapshot as c, close as r };", "loadSnapshot"),
).toBe("c");
expect(
resolveWorkerCellExport("export { other as loadSnapshot };", "loadSnapshot"),
).toBeUndefined();
expect(
resolveWorkerCellExport("export { loadSnapshot } from './different.mjs';", "loadSnapshot"),
).toBeUndefined();
expect(() =>
resolveWorkerCellExport("export { loadSnapshot as a, loadSnapshot as b };", "loadSnapshot"),
).toThrow("Ambiguous");
});
});

View file

@ -0,0 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertWorkerCellPackageIdentity,
readWorkerCellPackageIdentity,
} from "../../scripts/e2e/lib/upgrade-survivor/worker-cell-package.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function packageFixture() {
const root = tempDirs.make("worker-package-identity-");
fs.mkdirSync(path.join(root, "dist"));
fs.writeFileSync(path.join(root, "package.json"), '{"name":"openclaw","version":"2026.9.4"}');
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n");
fs.writeFileSync(
path.join(root, "dist/build-info.json"),
JSON.stringify({ version: "2026.9.4", commit: "a".repeat(40) }),
);
fs.writeFileSync(path.join(root, "dist/entry.mjs"), "export const answer = 1;\n");
fs.symlinkSync("entry.mjs", path.join(root, "dist/alias.mjs"));
return root;
}
describe("worker survivor installed payload identity", () => {
it.each(["changed", "extra", "missing", "symlink"])(
"rejects a %s dist entry even when package version and build commit agree",
(mutation) => {
const root = packageFixture();
const expected = readWorkerCellPackageIdentity(root);
if (mutation === "changed") {
fs.writeFileSync(path.join(root, "dist/entry.mjs"), "export const answer = 2;\n");
} else if (mutation === "extra") {
fs.writeFileSync(path.join(root, "dist/stale.mjs"), "export {};\n");
} else if (mutation === "missing") {
fs.unlinkSync(path.join(root, "dist/entry.mjs"));
} else {
fs.unlinkSync(path.join(root, "dist/alias.mjs"));
fs.symlinkSync("build-info.json", path.join(root, "dist/alias.mjs"));
}
expect(() =>
assertWorkerCellPackageIdentity(readWorkerCellPackageIdentity(root), expected),
).toThrow("Installed application payload differs");
},
);
it("compares application bytes while npm reifies its installed dependency tree", () => {
const root = packageFixture();
const expected = readWorkerCellPackageIdentity(root);
fs.mkdirSync(path.join(root, "node_modules"));
fs.writeFileSync(path.join(root, "node_modules/.package-lock.json"), "{}");
expect(() =>
assertWorkerCellPackageIdentity(readWorkerCellPackageIdentity(root), expected),
).not.toThrow();
});
});