mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
dd5ff2c382
commit
935563c38d
9 changed files with 254 additions and 118 deletions
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue