improve(ci): run first-hop post-convergence proof once (#163631)

* perf(ci): deduplicate post-convergence first-hop proof

* test(ci): cover canonical first-hop lane admission
This commit is contained in:
Dallin Romney 2026-10-02 10:12:15 -07:00 • committed by GitHub
parent dd5ff2c382
commit 935563c38d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 254 additions and 118 deletions

View file

@ -23,6 +23,7 @@ UNSUPPORTED_ADMISSION_PACKAGE=/tmp/openclaw-update-first-hop-unsupported-admissi
ARTIFACT_DIR="${OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR:-/tmp/openclaw-update-first-hop-artifacts}"
EXPECTED_MISSING_CHUNK="${OPENCLAW_UPDATE_FIRST_HOP_EXPECTED_MISSING_CHUNK-}"
ADMISSION_PROTOCOL="${OPENCLAW_UPDATE_FIRST_HOP_ADMISSION_PROTOCOL-}"
SCENARIO="${OPENCLAW_UPDATE_FIRST_HOP_SCENARIO:-all}"
BASE_PATH="$PATH"
ACCOUNT_HOME="$HOME"
mock_pid=""
@ -37,7 +38,11 @@ export npm_config_audit=false
export npm_config_fund=false
export npm_config_loglevel=error
for package_path in "$SOURCE_PACKAGE" "$CANDIDATE_PACKAGE" "$ORIGINAL_CANDIDATE_PACKAGE" "$NEGATIVE_PACKAGE" "$FUTURE_PACKAGE" "$ARTIFACT_DIR/source.json"; do
required_packages=("$CANDIDATE_PACKAGE" "$FUTURE_PACKAGE")
if [ "$SCENARIO" != "missing-load-path" ]; then
required_packages+=("$SOURCE_PACKAGE" "$ORIGINAL_CANDIDATE_PACKAGE" "$NEGATIVE_PACKAGE" "$ARTIFACT_DIR/source.json")
fi
for package_path in "${required_packages[@]}"; do
if [ ! -f "$package_path" ]; then
echo "missing package input: $package_path" >&2
exit 2
@ -362,7 +367,9 @@ run_positive_hops() {
run_missing_path_admission() {
local started=$SECONDS
local lane=admission-missing-load-path
reset_lane positive
if [ "$SCENARIO" != "missing-load-path" ]; then
reset_lane positive
fi
setup_lane "$lane" 18793 "$CANDIDATE_PACKAGE" 1
run_update "$lane-update" "$FUTURE_PACKAGE"
local assert_started=$SECONDS
@ -399,42 +406,54 @@ export OPENAI_API_KEY="sk-openclaw-first-hop"
export MOCK_REQUEST_LOG="$ARTIFACT_DIR/openai-requests.jsonl"
mock_pid="$(openclaw_e2e_start_mock_openai 44212 "$ARTIFACT_DIR/mock-openai.log")"
openclaw_e2e_wait_mock_openai 44212
if [ -n "$EXPECTED_MISSING_CHUNK" ]; then
run_negative_control
else
echo "No deterministic missing-chunk restart control for $source_version; positive hops remain required."
if [ "$SCENARIO" != "missing-load-path" ]; then
if [ -n "$EXPECTED_MISSING_CHUNK" ]; then
run_negative_control
else
echo "No deterministic missing-chunk restart control for $source_version; positive hops remain required."
fi
run_positive_hops
fi
run_positive_hops
if [ "$ADMISSION_PROTOCOL" = "1" ]; then
if [ "$SCENARIO" != "source" ] && [ "$ADMISSION_PROTOCOL" = "1" ]; then
run_missing_path_admission
elif [ "$SCENARIO" = "missing-load-path" ]; then
echo "the missing-load-path scenario requires candidate admission protocol 1" >&2
exit 2
fi
node -e '
const fs = require("node:fs"), path = require("node:path");
const root = process.argv[1];
const [root, scenario] = process.argv.slice(1);
const read = name => JSON.parse(fs.readFileSync(path.join(root, name), "utf8"));
const source = read("source.json");
const secondHopFixture = read("second-hop-fixture.json");
const retainedLegacyCompatibilityChunks = secondHopFixture.retainedLegacyCompatibilityChunks ?? [];
const [sourcePid, candidatePid, futurePid] = fs.readFileSync(path.join(root, "positive-service-pids.txt"), "utf8").trim().split("\n").map(Number);
fs.writeFileSync(path.join(root, "summary.json"), `${JSON.stringify({
source,
negativeControl: source.expectedMissingChunk
? { status: "passed", exit: 1, missingChunk: source.expectedMissingChunk }
: source.negativeControl,
firstHop: { exit: 0, method: "in-process-self-update", selfUpdatePassed: true, serviceIntent: "active", residueCount: 0, build: read("positive-first-build-info.json"), beforePid: sourcePid, afterPid: candidatePid },
secondHop: { exit: 0, method: "in-process-self-update", legacyCompatibilityChunksPresent: retainedLegacyCompatibilityChunks.length > 0, retainedLegacyCompatibilityChunks, removedCompatibilityChunks: secondHopFixture.removedCompatibilityChunks ?? [], serviceIntent: "active", residueCount: 0, build: read("positive-second-build-info.json"), beforePid: candidatePid, afterPid: futurePid },
admission: process.env.OPENCLAW_UPDATE_FIRST_HOP_ADMISSION_PROTOCOL === "1" ? {
supportedTarget: read("positive-second-admission.json"),
unsupportedTarget: read("positive-unsupported-admission-admission.json"),
missingLoadPath: {
...read("admission-missing-load-path-update-admission.json"),
postDoctor: read("admission-missing-load-path/missing-load-path/post-doctor.json"),
pendingLifecycleEntry: read("admission-missing-load-path-entry-probe.json"),
sameHopPolicyOverrideDemonstrated: false,
},
} : { status: "not-supported-by-candidate" },
}, null, 2)}\n`);
' "$ARTIFACT_DIR"
const summary = {};
if (scenario !== "missing-load-path") {
const source = read("source.json");
const secondHopFixture = read("second-hop-fixture.json");
const retainedLegacyCompatibilityChunks = secondHopFixture.retainedLegacyCompatibilityChunks ?? [];
const [sourcePid, candidatePid, futurePid] = fs.readFileSync(path.join(root, "positive-service-pids.txt"), "utf8").trim().split("\n").map(Number);
Object.assign(summary, {
source,
negativeControl: source.expectedMissingChunk
? { status: "passed", exit: 1, missingChunk: source.expectedMissingChunk }
: source.negativeControl,
firstHop: { exit: 0, method: "in-process-self-update", selfUpdatePassed: true, serviceIntent: "active", residueCount: 0, build: read("positive-first-build-info.json"), beforePid: sourcePid, afterPid: candidatePid },
secondHop: { exit: 0, method: "in-process-self-update", legacyCompatibilityChunksPresent: retainedLegacyCompatibilityChunks.length > 0, retainedLegacyCompatibilityChunks, removedCompatibilityChunks: secondHopFixture.removedCompatibilityChunks ?? [], serviceIntent: "active", residueCount: 0, build: read("positive-second-build-info.json"), beforePid: candidatePid, afterPid: futurePid },
admission: process.env.OPENCLAW_UPDATE_FIRST_HOP_ADMISSION_PROTOCOL === "1" ? {
supportedTarget: read("positive-second-admission.json"),
unsupportedTarget: read("positive-unsupported-admission-admission.json"),
} : { status: "not-supported-by-candidate" },
});
}
if (scenario !== "source" && process.env.OPENCLAW_UPDATE_FIRST_HOP_ADMISSION_PROTOCOL === "1") {
summary.admission ??= {};
summary.admission.missingLoadPath = {
...read("admission-missing-load-path-update-admission.json"),
postDoctor: read("admission-missing-load-path/missing-load-path/post-doctor.json"),
pendingLifecycleEntry: read("admission-missing-load-path-entry-probe.json"),
sameHopPolicyOverrideDemonstrated: false,
};
}
fs.writeFileSync(path.join(root, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
' "$ARTIFACT_DIR" "$SCENARIO"
echo "Packaged updater first-hop compatibility E2E passed."

View file

@ -25,6 +25,7 @@ SKIP_BUILD="${OPENCLAW_UPDATE_FIRST_HOP_E2E_SKIP_BUILD:-0}"
DOCKER_RUN_TIMEOUT="${OPENCLAW_UPDATE_FIRST_HOP_DOCKER_RUN_TIMEOUT:-3200s}"
# Space- or comma-separated recorded release versions; empty runs every recorded source.
SOURCE_VERSION_FILTER="${OPENCLAW_UPDATE_FIRST_HOP_SOURCE_VERSIONS:-}"
SCENARIO="${OPENCLAW_UPDATE_FIRST_HOP_SCENARIO:-all}"
ARTIFACT_DIR="${OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/update-first-hop-compat${SOURCE_VERSION_FILTER:+-${SOURCE_VERSION_FILTER//[ ,]/-}}}"
SOURCE_PACKAGE="${OPENCLAW_UPDATE_FIRST_HOP_SOURCE_PACKAGE_TGZ:-}"
FIXTURE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-update-first-hop.XXXXXX")"
@ -51,6 +52,17 @@ if [ -n "$SOURCE_PACKAGE" ] && [ -n "$SOURCE_VERSION_FILTER" ]; then
echo "an explicit source tarball cannot be combined with OPENCLAW_UPDATE_FIRST_HOP_SOURCE_VERSIONS" >&2
exit 2
fi
case "$SCENARIO" in
all | source | missing-load-path) ;;
*)
echo "invalid first-hop scenario: $SCENARIO" >&2
exit 2
;;
esac
if [ "$SCENARIO" = "missing-load-path" ] && { [ -n "$SOURCE_PACKAGE" ] || [ -n "$SOURCE_VERSION_FILTER" ]; }; then
echo "the missing-load-path scenario does not accept a historical source" >&2
exit 2
fi
PACKAGE_TGZ="$(
docker_e2e_prepare_package_tgz \
@ -65,12 +77,12 @@ node "$FIXTURE_HELPER" first-hop-tarball "$PACKAGE_TGZ" "$FIRST_HOP_TGZ" 0 \
node "$FIXTURE_HELPER" future-tarball "$FIRST_HOP_TGZ" "$FIXTURE_ROOT/future.tgz" 1 \
>"$ARTIFACT_DIR/second-hop-fixture.json"
ADMISSION_PROTOCOL="$(tar -xOf "$PACKAGE_TGZ" package/package.json | node -pe 'JSON.parse(require("node:fs").readFileSync(0, "utf8")).openclaw?.updateAdmissionProtocol ?? ""')"
if [ "$ADMISSION_PROTOCOL" = "1" ]; then
if [ "$ADMISSION_PROTOCOL" = "1" ] && [ "$SCENARIO" != "missing-load-path" ]; then
node "$FIXTURE_HELPER" unsupported-admission-tarball "$FIXTURE_ROOT/future.tgz" \
"$FIXTURE_ROOT/unsupported-admission.tgz" 2 >"$ARTIFACT_DIR/unsupported-admission-fixture.json"
fi
docker_e2e_package_mount_args "$FIRST_HOP_TGZ" /tmp/openclaw-update-first-hop-candidate.tgz
if [ "$ADMISSION_PROTOCOL" = "1" ]; then
if [ "$ADMISSION_PROTOCOL" = "1" ] && [ "$SCENARIO" != "missing-load-path" ]; then
DOCKER_E2E_PACKAGE_ARGS+=(-v "$FIXTURE_ROOT/unsupported-admission.tgz:/tmp/openclaw-update-first-hop-unsupported-admission.tgz:ro")
fi
@ -86,7 +98,7 @@ docker_e2e_build_or_reuse \
"$SKIP_BUILD"
SOURCE_VERSIONS=("")
if [ -z "$SOURCE_PACKAGE" ]; then
if [ "$SCENARIO" != "missing-load-path" ] && [ -z "$SOURCE_PACKAGE" ]; then
SOURCE_VERSIONS=()
node "$FIXTURE_HELPER" sources "$FIXTURE_ROOT/packages/original/package" \
"$SOURCE_VERSION_FILTER" >"$FIXTURE_ROOT/source-versions.txt"
@ -97,41 +109,45 @@ fi
for version in "${SOURCE_VERSIONS[@]}"; do
lane_artifact_dir="$ARTIFACT_DIR"
source_package="$SOURCE_PACKAGE"
if [ -n "$version" ]; then
lane_artifact_dir="$ARTIFACT_DIR/$version"
mkdir -p "$lane_artifact_dir"
cp "$ARTIFACT_DIR/second-hop-fixture.json" "$lane_artifact_dir/second-hop-fixture.json"
npm pack "openclaw@$version" --ignore-scripts --json --min-release-age=0 \
--pack-destination "$FIXTURE_ROOT/source" >"$lane_artifact_dir/source-pack.json"
source_package="$FIXTURE_ROOT/source/$(
node "$FIXTURE_HELPER" pack-filename "$lane_artifact_dir/source-pack.json"
)"
source_package="${SOURCE_PACKAGE:-$FIRST_HOP_TGZ}"
expected_missing_chunk=""
negative_tgz="$FIRST_HOP_TGZ"
if [ "$SCENARIO" != "missing-load-path" ]; then
if [ -n "$version" ]; then
lane_artifact_dir="$ARTIFACT_DIR/$version"
mkdir -p "$lane_artifact_dir"
cp "$ARTIFACT_DIR/second-hop-fixture.json" "$lane_artifact_dir/second-hop-fixture.json"
npm pack "openclaw@$version" --ignore-scripts --json --min-release-age=0 \
--pack-destination "$FIXTURE_ROOT/source" >"$lane_artifact_dir/source-pack.json"
source_package="$FIXTURE_ROOT/source/$(
node "$FIXTURE_HELPER" pack-filename "$lane_artifact_dir/source-pack.json"
)"
fi
node "$FIXTURE_HELPER" source "$FIXTURE_ROOT/packages/original/package" \
"$source_package" "$version" >"$lane_artifact_dir/source.json"
expected_missing_chunk="$(node -e '
const source = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
process.stdout.write(source.expectedMissingChunk ?? "");
' "$lane_artifact_dir/source.json")"
negative_tgz="$FIXTURE_ROOT/negative-${version:-explicit}.tgz"
node "$FIXTURE_HELPER" negative-tarball "$FIRST_HOP_TGZ" "$negative_tgz" \
"$expected_missing_chunk" >"$lane_artifact_dir/negative-fixture.json"
{
printf 'source=%s\n' "$source_package"
printf 'original_candidate=%s\n' "$PACKAGE_TGZ"
printf 'candidate=%s\n' "$FIRST_HOP_TGZ"
printf 'expected_missing_chunk=%s\n' "$expected_missing_chunk"
shasum -a 256 "$source_package" "$PACKAGE_TGZ" "$FIRST_HOP_TGZ" "$negative_tgz" "$FIXTURE_ROOT/future.tgz"
if [ "$ADMISSION_PROTOCOL" = "1" ]; then
shasum -a 256 "$FIXTURE_ROOT/unsupported-admission.tgz"
fi
printf '\nsource_build_info=' && tar -xOf "$source_package" package/dist/build-info.json
printf '\noriginal_candidate_build_info=' && tar -xOf "$PACKAGE_TGZ" package/dist/build-info.json
printf '\ncandidate_build_info=' && tar -xOf "$FIRST_HOP_TGZ" package/dist/build-info.json
printf '\nfuture_build_info=' && tar -xOf "$FIXTURE_ROOT/future.tgz" package/dist/build-info.json
} >"$lane_artifact_dir/inputs.txt"
fi
chmod a+rwx "$lane_artifact_dir"
node "$FIXTURE_HELPER" source "$FIXTURE_ROOT/packages/original/package" \
"$source_package" "$version" >"$lane_artifact_dir/source.json"
expected_missing_chunk="$(node -e '
const source = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
process.stdout.write(source.expectedMissingChunk ?? "");
' "$lane_artifact_dir/source.json")"
negative_tgz="$FIXTURE_ROOT/negative-${version:-explicit}.tgz"
node "$FIXTURE_HELPER" negative-tarball "$FIRST_HOP_TGZ" "$negative_tgz" \
"$expected_missing_chunk" >"$lane_artifact_dir/negative-fixture.json"
{
printf 'source=%s\n' "$source_package"
printf 'original_candidate=%s\n' "$PACKAGE_TGZ"
printf 'candidate=%s\n' "$FIRST_HOP_TGZ"
printf 'expected_missing_chunk=%s\n' "$expected_missing_chunk"
shasum -a 256 "$source_package" "$PACKAGE_TGZ" "$FIRST_HOP_TGZ" "$negative_tgz" "$FIXTURE_ROOT/future.tgz"
if [ "$ADMISSION_PROTOCOL" = "1" ]; then
shasum -a 256 "$FIXTURE_ROOT/unsupported-admission.tgz"
fi
printf '\nsource_build_info=' && tar -xOf "$source_package" package/dist/build-info.json
printf '\noriginal_candidate_build_info=' && tar -xOf "$PACKAGE_TGZ" package/dist/build-info.json
printf '\ncandidate_build_info=' && tar -xOf "$FIRST_HOP_TGZ" package/dist/build-info.json
printf '\nfuture_build_info=' && tar -xOf "$FIXTURE_ROOT/future.tgz" package/dist/build-info.json
} >"$lane_artifact_dir/inputs.txt"
echo "Running packaged updater first-hop compatibility Docker E2E (${version:-explicit source})..."
docker_e2e_run_with_harness \
@ -139,6 +155,7 @@ for version in "${SOURCE_VERSIONS[@]}"; do
-e OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR=/tmp/openclaw-update-first-hop-artifacts \
-e OPENCLAW_UPDATE_FIRST_HOP_EXPECTED_MISSING_CHUNK="$expected_missing_chunk" \
-e OPENCLAW_UPDATE_FIRST_HOP_ADMISSION_PROTOCOL="$ADMISSION_PROTOCOL" \
-e OPENCLAW_UPDATE_FIRST_HOP_SCENARIO="$SCENARIO" \
-v "$lane_artifact_dir:/tmp/openclaw-update-first-hop-artifacts" \
-v "$(docker_e2e_abs_path "$source_package"):/tmp/openclaw-update-first-hop-source.tgz:ro" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
@ -150,7 +167,7 @@ for version in "${SOURCE_VERSIONS[@]}"; do
bash scripts/e2e/lib/upgrade-survivor/update-first-hop-compat.sh
done
if [ -z "$SOURCE_PACKAGE" ]; then
if [ "$SCENARIO" != "missing-load-path" ] && [ -z "$SOURCE_PACKAGE" ]; then
node -e '
const fs = require("node:fs"), path = require("node:path");
const [root, ...versions] = process.argv.slice(1);

View file

@ -25,8 +25,9 @@ import officialExternalProviderCatalog from "./official-external-provider-catalo
import { isRecord } from "./record-shared.mjs";
import {
UPDATE_FIRST_HOP_COMPAT_LANE,
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
isUpdateFirstHopCompatLane,
listRecordedFirstHopSourceVersions,
listUpdateFirstHopCompatLaneNames,
updateFirstHopCompatLaneName,
} from "./update-first-hop-lanes.mjs";
import {
@ -100,10 +101,7 @@ export function parseLaneSelection(raw: string | undefined): string[] {
}
const laneAliases = new Map([
["install-e2e", ["install-e2e-openai", "install-e2e-anthropic"]],
[
UPDATE_FIRST_HOP_COMPAT_LANE,
listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName),
],
[UPDATE_FIRST_HOP_COMPAT_LANE, listUpdateFirstHopCompatLaneNames()],
[
"bundled-plugin-install-uninstall",
Array.from(
@ -428,21 +426,33 @@ function supportsUpdateFirstHopCompatForTarget(
frozenTarget?: InertTargetContract,
): boolean {
if (!targetRoot && !frozenTarget) {
// Untargeted planning runs this checkout's matching lane and package; this lane
// entered the catalog with candidate admission protocol 1.
return true;
}
if (laneName === UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE) {
// Release targets are full pinned checkouts. A missing manifest is an incomplete
// target contract, so never infer candidate admission from sibling metadata.
const manifest = readTargetMetadata(targetRoot, "package.json", frozenTarget);
return (
manifest !== null &&
(JSON.parse(manifest) as { openclaw?: { updateAdmissionProtocol?: number } }).openclaw
?.updateAdmissionProtocol === 1
);
}
// A target that records its own inventory only proves the hops it lists.
const inventory = readTargetMetadata(
targetRoot,
"scripts/lib/update-compat-inventory.json",
frozenTarget,
);
if (
inventory !== null &&
!(JSON.parse(inventory).releases as { version: string }[]).some(
(release) => updateFirstHopCompatLaneName(release.version) === laneName,
)
) {
return false;
if (inventory !== null) {
const releases = (JSON.parse(inventory).releases as { version: string }[]).map((release) =>
updateFirstHopCompatLaneName(release.version),
);
if (!releases.includes(laneName)) {
return false;
}
}
const source = readTargetMetadata(targetRoot, "scripts/runtime-postbuild.mts", frozenTarget);
if (source === null) {

View file

@ -3,6 +3,7 @@
// here. Planning and execution live in separate modules.
import { fileURLToPath } from "node:url";
import {
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
listRecordedFirstHopSourceVersions,
updateFirstHopCompatLaneName,
} from "./update-first-hop-lanes.mjs";
@ -75,22 +76,30 @@ const updateMigrationCommand = upgradeSurvivorScriptCommand(
const dreamingCronDoctorCommand = upgradeSurvivorScriptCommand(
"OPENCLAW_UPGRADE_SURVIVOR_PUBLISHED_BASELINE=1 OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=openclaw@2026.9.6 OPENCLAW_UPGRADE_SURVIVOR_CANDIDATE=current OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=dreaming-cron-doctor OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE=manual OPENCLAW_UPGRADE_SURVIVOR_ROOT_MANAGED_VPS=0 OPENCLAW_UPGRADE_SURVIVOR_LIVE_MODELS= OPENCLAW_UPGRADE_SURVIVOR_LIVE_OPENAI=0",
);
// One lane per recorded source release so the hops can run concurrently.
const updateFirstHopCompatLanes = listRecordedFirstHopSourceVersions().map((version) =>
npmLane(
updateFirstHopCompatLaneName(version),
`OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 OPENCLAW_UPDATE_FIRST_HOP_SOURCE_VERSIONS=${version} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-first-hop-compat`,
{
resources: ["service"],
stateScenario: "upgrade-survivor",
// Run 36506342273 (hosted 4-vCPU): projected 2125s x ~1.5 => 3200s inner;
// add 300s for host-side fixtures, package preparation, and cleanup.
timeoutMs: 3500 * 1000,
// Limit npm/disk contention to two hops at npm limit 5; a weight-3 survivor can overlap one.
weight: 2,
},
const updateFirstHopCompatLaneOptions = {
resources: ["service"],
stateScenario: "upgrade-survivor",
// Run 36506342273 (hosted 4-vCPU): projected 2125s x ~1.5 => 3200s inner;
// add 300s for host-side fixtures, package preparation, and cleanup.
timeoutMs: 3500 * 1000,
// Limit npm/disk contention to two hops at npm limit 5; a weight-3 survivor can overlap one.
weight: 2,
} satisfies LaneOptions;
// Keep source-derived hops separate while running the post-convergence edge case once.
const updateFirstHopCompatLanes = [
...listRecordedFirstHopSourceVersions().map((version) =>
npmLane(
updateFirstHopCompatLaneName(version),
`OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 OPENCLAW_UPDATE_FIRST_HOP_SCENARIO=source OPENCLAW_UPDATE_FIRST_HOP_SOURCE_VERSIONS=${version} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-first-hop-compat`,
updateFirstHopCompatLaneOptions,
),
),
);
npmLane(
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
"OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 OPENCLAW_UPDATE_FIRST_HOP_SCENARIO=missing-load-path OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-first-hop-compat",
updateFirstHopCompatLaneOptions,
),
];
const CODEX_HARNESS_API_KEY_ENV = "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key";
const npmOnboardLaneOptions = {
prepublishPluginPackages: ["@openclaw/codex"],

View file

@ -3,6 +3,7 @@
import inventory from "./update-compat-inventory.json" with { type: "json" };
export const UPDATE_FIRST_HOP_COMPAT_LANE = "update-first-hop-compat";
export const UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE = `${UPDATE_FIRST_HOP_COMPAT_LANE}-missing-load-path`;
export function listRecordedFirstHopSourceVersions() {
return inventory.releases.map((release) => release.version);
@ -12,20 +13,25 @@ export function updateFirstHopCompatLaneName(version) {
return `${UPDATE_FIRST_HOP_COMPAT_LANE}-${version}`;
}
export function listUpdateFirstHopCompatLaneNames() {
return [
...listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName),
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
];
}
export function isUpdateFirstHopCompatLane(name) {
return (
name === UPDATE_FIRST_HOP_COMPAT_LANE || name.startsWith(`${UPDATE_FIRST_HOP_COMPAT_LANE}-`)
);
}
/** Expands the family token into one lane per recorded source version, without duplicates. */
/** Expands the family token into every source lineage plus the fresh candidate edge case. */
export function expandUpdateFirstHopCompatLanes(names) {
return [
...new Set(
names.flatMap((name) =>
name === UPDATE_FIRST_HOP_COMPAT_LANE
? listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName)
: [name],
name === UPDATE_FIRST_HOP_COMPAT_LANE ? listUpdateFirstHopCompatLaneNames() : [name],
),
),
];

View file

@ -18,6 +18,7 @@ import {
} from "../../scripts/lib/docker-e2e-plan.mts";
import { createFrozenTargetSource } from "../../scripts/lib/frozen-target-source.mjs";
import {
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
listRecordedFirstHopSourceVersions,
updateFirstHopCompatLaneName,
} from "../../scripts/lib/update-first-hop-lanes.mjs";
@ -60,7 +61,8 @@ function copyCurrentScenarioMetadata(targetRoot: string) {
}
const firstHopSourceVersions = listRecordedFirstHopSourceVersions();
const firstHopLaneNames = firstHopSourceVersions.map(updateFirstHopCompatLaneName);
const sourceFirstHopLaneNames = firstHopSourceVersions.map(updateFirstHopCompatLaneName);
const firstHopLaneNames = [...sourceFirstHopLaneNames, UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE];
function planFor(
overrides: Partial<Parameters<typeof resolveDockerE2ePlan>[0]> = {},
@ -330,8 +332,8 @@ describe("scripts/lib/docker-e2e-plan", () => {
selectedLaneNames: parseLaneSelection("update-first-hop-compat"),
upgradeSurvivorTargetRoot: targetRoot,
});
expect(plan.lanes.map((lane) => lane.name)).toEqual(firstHopLaneNames);
expect(plan.omittedUnsupportedLanes).toEqual([]);
expect(plan.lanes.map((lane) => lane.name)).toEqual(sourceFirstHopLaneNames);
expect(plan.omittedUnsupportedLanes).toEqual([UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE]);
});
it("omits only the first-hop sources a target's own inventory does not record", () => {
@ -352,7 +354,52 @@ describe("scripts/lib/docker-e2e-plan", () => {
});
expect(plan.lanes.map((lane) => lane.name)).toEqual([updateFirstHopCompatLaneName(oldest)]);
expect(plan.lanes[0]?.timeoutMs).toBe(3_500_000);
expect(plan.omittedUnsupportedLanes).toEqual(newer.map(updateFirstHopCompatLaneName));
expect(plan.omittedUnsupportedLanes).toEqual([
...newer.map(updateFirstHopCompatLaneName),
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
]);
});
it("keeps source hops and gates post-convergence proof on candidate admission", () => {
for (const version of firstHopSourceVersions) {
expect(findLaneByName(updateFirstHopCompatLaneName(version))?.command).toBe(
`OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 OPENCLAW_UPDATE_FIRST_HOP_SCENARIO=source OPENCLAW_UPDATE_FIRST_HOP_SOURCE_VERSIONS=${version} OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-first-hop-compat`,
);
}
const targetRoot = tempDirs.make("openclaw-first-hop-admission-target-");
writeFileSync(
join(targetRoot, "package.json"),
JSON.stringify({ openclaw: { updateAdmissionProtocol: 1 } }),
);
const plan = planFor({
selectedLaneNames: [UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE],
upgradeSurvivorTargetRoot: targetRoot,
});
expect(plan.lanes.map(summarizeLane)).toEqual([
{
command:
"OPENCLAW_QA_ALLOW_UPDATE_FIRST_HOP=1 OPENCLAW_UPDATE_FIRST_HOP_SCENARIO=missing-load-path OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-first-hop-compat",
imageKind: "bare",
live: false,
name: UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
resources: ["docker", "npm", "service"],
stateScenario: "upgrade-survivor",
timeoutMs: 3_500_000,
weight: 2,
},
]);
expect(plan.omittedUnsupportedLanes).toEqual([]);
writeFileSync(
join(targetRoot, "package.json"),
JSON.stringify({ openclaw: { updateAdmissionProtocol: 2 } }),
);
const unsupported = planFor({
selectedLaneNames: [UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE],
upgradeSurvivorTargetRoot: targetRoot,
});
expect(unsupported.lanes).toEqual([]);
expect(unsupported.omittedUnsupportedLanes).toEqual([UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE]);
});
it.each([

View file

@ -14,7 +14,10 @@ import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { expandUpdateFirstHopCompatLanes } from "../../scripts/lib/update-first-hop-lanes.mjs";
import {
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
expandUpdateFirstHopCompatLanes,
} from "../../scripts/lib/update-first-hop-lanes.mjs";
import { copyTreeCloseOnExec } from "../helpers/close-on-exec-copy.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
@ -527,7 +530,13 @@ describe("frozen admission upgrade Docker aliases", () => {
const result = f.run({ docker: { lanes: requestedLanes } });
expect(result.status, result.stderr).toBe(0);
const record = JSON.parse(result.stdout);
expect(record.docker).toEqual({ lanes: requestedLanes, omitted: [], status: "ADMITTED" });
const omitted =
lane === "update-first-hop-compat" ? [UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE] : [];
expect(record.docker).toEqual({
lanes: requestedLanes.filter((requested) => !omitted.includes(requested)),
omitted,
status: "ADMITTED",
});
expect(record.selection.consumers).toEqual(lane === "plugins-offline" ? ["plugins"] : []);
expect(record.contracts.map((contract: { consumer: string }) => contract.consumer)).toEqual(
record.selection.consumers,

View file

@ -4,6 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parseLaneSelection, resolveDockerE2ePlan } from "../../scripts/lib/docker-e2e-plan.mts";
import {
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
listRecordedFirstHopSourceVersions,
updateFirstHopCompatLaneName,
} from "../../scripts/lib/update-first-hop-lanes.mjs";
@ -214,23 +215,24 @@ describe("scripts/plan-targeted-docker-lane-groups", () => {
).toEqual(expandedPlan("published-upgrade-survivor", baselines, scenarios).scheduledLanes);
});
it("runs each recorded first-hop source as its own job", () => {
it("runs each recorded first-hop source and the fresh candidate edge as separate jobs", () => {
const firstHopLanes = listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName);
const expandedLanes = [...firstHopLanes, UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE];
expect(firstHopLanes.length).toBeGreaterThan(1);
expect(
planTargetedDockerLaneGroups({ lanes: "upgrade-survivor update-first-hop-compat" }),
).toEqual([
{ docker_lanes: "upgrade-survivor", label: "upgrade-survivor" },
...firstHopLanes.map((lane) => ({ docker_lanes: lane, label: lane })),
...expandedLanes.map((lane) => ({ docker_lanes: lane, label: lane })),
]);
expect(parseLaneSelection("update-first-hop-compat")).toEqual(firstHopLanes);
expect(parseLaneSelection("update-first-hop-compat")).toEqual(expandedLanes);
// A family token beside one of its members must not schedule that hop twice.
const mixed = `${firstHopLanes[firstHopLanes.length - 1]} update-first-hop-compat`;
const mixed = `${UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE} update-first-hop-compat`;
expect(planTargetedDockerLaneGroups({ lanes: mixed }).map((group) => group.label)).toEqual([
firstHopLanes[firstHopLanes.length - 1],
...firstHopLanes.slice(0, -1),
UPDATE_FIRST_HOP_MISSING_LOAD_PATH_LANE,
...firstHopLanes,
]);
expect(parseLaneSelection(mixed)).toHaveLength(firstHopLanes.length);
expect(parseLaneSelection(mixed)).toHaveLength(expandedLanes.length);
});
it("keeps normal targeted lanes grouped by the configured group size", () => {

View file

@ -521,8 +521,8 @@ describe("first-hop package fixtures", () => {
},
);
it.skipIf(process.platform === "win32").each(["explicit", "recorded"])(
"carries the candidate registry into the first-hop Docker lane with %s sources",
it.skipIf(process.platform === "win32").each(["explicit", "recorded", "missing-load-path"])(
"routes the first-hop Docker lane with the %s scenario",
(sourceMode) => {
const root = fs.realpathSync(tempDirs.make("openclaw-first-hop-docker-"));
const bin = path.join(root, "bin");
@ -586,7 +586,8 @@ if (process.argv[2] === "run") {
const args = process.argv.slice(3);
fs.appendFileSync(process.env.DOCKER_ARGS_FILE, JSON.stringify(args) + "\\n");
const artifact = args.find(arg => arg.endsWith(":/tmp/openclaw-update-first-hop-artifacts")).split(":")[0];
const source = JSON.parse(fs.readFileSync(path.join(artifact, "source.json"), "utf8"));
const sourceFile = path.join(artifact, "source.json");
const source = fs.existsSync(sourceFile) ? JSON.parse(fs.readFileSync(sourceFile, "utf8")) : undefined;
const inspect = (name) => {
const mount = args.find(arg => arg.endsWith(":/tmp/openclaw-update-first-hop-" + name + ".tgz:ro"));
if (!mount) return undefined;
@ -626,6 +627,8 @@ process.stdout.write(JSON.stringify(version === "2026.9.2" ? { openclaw: packed
OPENCLAW_UPDATE_FIRST_HOP_DOCKER_RUN_TIMEOUT: "",
OPENCLAW_UPDATE_FIRST_HOP_E2E_SKIP_BUILD: "1",
OPENCLAW_UPDATE_FIRST_HOP_SOURCE_PACKAGE_TGZ: sourceMode === "explicit" ? tarball : "",
OPENCLAW_UPDATE_FIRST_HOP_SCENARIO:
sourceMode === "missing-load-path" ? "missing-load-path" : "all",
OPENCLAW_UPDATE_FIRST_HOP_EXPECTED_MISSING_CHUNK: "shared-Y6bNiw2w.js",
OPENCLAW_UPDATE_FIRST_HOP_CANDIDATE_PACKAGE_TGZ: tarball,
OPENCLAW_UPDATE_FIRST_HOP_ARTIFACT_DIR: path.join(root, "artifacts"),
@ -641,7 +644,7 @@ process.stdout.write(JSON.stringify(version === "2026.9.2" ? { openclaw: packed
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(invocations).toHaveLength(sourceMode === "explicit" ? 1 : 3);
expect(invocations).toHaveLength(sourceMode === "recorded" ? 3 : 1);
const firstFixture = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/first-hop-fixture.json"), "utf8"),
);
@ -661,7 +664,12 @@ process.stdout.write(JSON.stringify(version === "2026.9.2" ? { openclaw: packed
const recorded = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/summary.json"), "utf8"),
);
const packages = sourceMode === "recorded" ? recorded.sources : [recorded];
const packages =
sourceMode === "missing-load-path"
? []
: sourceMode === "recorded"
? recorded.sources
: [recorded];
for (const artifact of packages) {
const fixtureDirectory =
sourceMode === "recorded"
@ -706,9 +714,18 @@ process.stdout.write(JSON.stringify(version === "2026.9.2" ? { openclaw: packed
expect(args).toContain(`${registry}:/tmp/openclaw-prepublish-plugin-registry:ro`);
expect(args).toContain(`${tarball}:/tmp/openclaw-update-first-hop-original.tgz:ro`);
expect(args).toContain("OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION=2026.8.1");
expect(args).toContain(
`OPENCLAW_UPDATE_FIRST_HOP_SCENARIO=${
sourceMode === "missing-load-path" ? "missing-load-path" : "all"
}`,
);
expect(args).toContain("bash");
expect(args).toContain("scripts/e2e/lib/upgrade-survivor/update-first-hop-compat.sh");
}
if (sourceMode === "missing-load-path") {
expect(fs.existsSync(path.join(root, "artifacts/source.json"))).toBe(false);
expect(fs.existsSync(path.join(root, "artifacts/negative-fixture.json"))).toBe(false);
}
if (sourceMode === "recorded") {
const summary = JSON.parse(
fs.readFileSync(path.join(root, "artifacts/summary.json"), "utf8"),