fix(update): rebuild stale Git runtimes when source is already current (#156996)

* fix(update): rebuild stale git runtimes through managed handoff

* fix(update): decide Git no-ops from build provenance

* fix(update): rebuild Git runtimes without provenance
This commit is contained in:
Peter Steinberger 2026-09-24 08:38:14 -07:00 • committed by GitHub
parent 06815a460f
commit bae12326a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 421 additions and 54 deletions

View file

@ -33,10 +33,31 @@ replacement. Choose an empty `OPENCLAW_GIT_DIR` and retry.
If the resolved registry package version equals the installed version without changing If the resolved registry package version equals the installed version without changing
the selected channel or installation method, or the Git target SHA equals the selected channel or installation method, or the Git target SHA equals
`HEAD`, plugin convergence still runs; if plugins and runtime artifacts remain unchanged, the run finishes `skipped` with reason `already-current`. Runtime maintenance can therefore succeed without changing the Git revision. A same-version `HEAD` and the recorded build commit matches it, plugin convergence still runs; if plugins and runtime artifacts remain unchanged, the run finishes `skipped` with reason `already-current`. Runtime maintenance can therefore succeed without changing the Git revision. A same-version
explicit `--channel` or installation-method change finishes successfully. explicit `--channel` or installation-method change finishes successfully.
Changed plugins restart a running managed Gateway unless `--no-restart` is set; retained exact pins produce the same advisories as a core update without requiring a restart. Changed plugins restart a running managed Gateway unless `--no-restart` is set; retained exact pins produce the same advisories as a core update without requiring a restart.
If a Git checkout advanced without rebuilding or its runtime has no recorded
build commit, the matching source revision still needs an update. OpenClaw builds
and validates a separate candidate, then stops the managed Gateway before
replacing the runtime and restarting it. The new build records its commit, so
the next update can finish as already current.
`--no-restart` cannot replace runtime files used by a running Gateway in the same
installation; the update leaves those files intact and reports the process and
the stop/retry action.
Source commands launched with `pnpm openclaw` also refuse an automatic rebuild
while that installation's Gateway is running. Use the installed `openclaw update`
or `node openclaw.mjs update` from the checkout to reach the updater's managed
handoff without the source wrapper rebuilding first. Manual `pnpm build` remains
an operator action: stop the Gateway before rebuilding its installation.
This decision runs in the installed updater. An older updater that returns
`already-current` with `runtime-verification-failed` cannot obtain the fix from a
candidate it never builds. Stop the Gateway through its actual service manager,
rebuild the checkout, and start the Gateway through that manager before retrying.
Do not rebuild its installation while the old Gateway is still serving.
Linux updates also refresh outdated OpenClaw-managed systemd policy when the core Linux updates also refresh outdated OpenClaw-managed systemd policy when the core
is already current or `--no-restart` is set. This policy-only refresh confirms is already current or `--no-restart` is set. This policy-only refresh confirms
`daemon-reload` without stopping the Gateway and preserves operator drop-ins. `daemon-reload` without stopping the Gateway and preserves operator drop-ins.

View file

@ -12,7 +12,10 @@ import path from "node:path";
import process from "node:process"; import process from "node:process";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { getCommandArgsWithRootOptions } from "../src/infra/cli-root-options.ts"; import { getCommandArgsWithRootOptions } from "../src/infra/cli-root-options.ts";
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts"; import {
distArtifactEntryArgs,
withDistArtifactOwnership,
} from "./lib/dist-artifact-ownership.mts";
import { import {
BUILD_STAMP_FILE, BUILD_STAMP_FILE,
RUNTIME_POSTBUILD_STAMP_FILE, RUNTIME_POSTBUILD_STAMP_FILE,
@ -42,6 +45,7 @@ import {
runNodeWatchedPaths, runNodeWatchedPaths,
} from "./run-node-watch-paths.mts"; } from "./run-node-watch-paths.mts";
import { listCoreRuntimePostBuildOutputs, runRuntimePostBuild } from "./runtime-postbuild.mts"; import { listCoreRuntimePostBuildOutputs, runRuntimePostBuild } from "./runtime-postbuild.mts";
import { listTsdownOutputRoots } from "./tsdown-build.mts";
type RunNodeInjectedChild = { type RunNodeInjectedChild = {
kill?: (signal?: NodeJS.Signals) => boolean | void; kill?: (signal?: NodeJS.Signals) => boolean | void;
@ -136,7 +140,6 @@ function asRunNodeChild(value: unknown): RunNodeChild {
export { runNodeWatchedPaths }; export { runNodeWatchedPaths };
const runtimeBuildArgs = ["--import", "tsx", "scripts/build-all.mts", "qaRuntime"];
const RUN_NODE_DEFAULT_SHUTDOWN_GRACE_MS = 5_000; const RUN_NODE_DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
const RUN_NODE_MAX_SHUTDOWN_GRACE_MS = 5 * 60_000; const RUN_NODE_MAX_SHUTDOWN_GRACE_MS = 5 * 60_000;
const RUN_NODE_SHUTDOWN_GRACE_MESSAGE_TYPE = "openclaw:shutdown-grace"; const RUN_NODE_SHUTDOWN_GRACE_MESSAGE_TYPE = "openclaw:shutdown-grace";
@ -1348,6 +1351,32 @@ const withRunNodeBuildLock = async <T,>(deps: RunNodeDeps, callback: () => Promi
} }
}; };
const withRunNodeRuntimePublication = async <T,>(deps: RunNodeDeps, publish: () => Promise<T>) => {
const [{ withGatewayRuntimeArtifactPublication }, { parseCliProfileArgs, applyCliProfileEnv }] =
await Promise.all([
import("../src/cli/update-cli/update-command-service-publication.ts"),
import("../src/cli/profile.ts"),
]);
const selected = parseCliProfileArgs([deps.execPath, "openclaw.mjs", ...deps.args]);
if (!selected.ok) {
throw new Error(selected.error);
}
const env = { ...deps.env };
if (selected.profile) {
applyCliProfileEnv({ profile: selected.profile, env });
}
return await withGatewayRuntimeArtifactPublication(
{
root: deps.cwd,
env,
timeoutMs: 60_000,
outputPaths: listTsdownOutputRoots(),
assertCurrent() {},
},
publish,
);
};
const syncRuntimeArtifacts = async (deps: RunNodeDeps) => { const syncRuntimeArtifacts = async (deps: RunNodeDeps) => {
try { try {
await deps.runRuntimePostBuild({ cwd: deps.cwd, env: deps.env }); await deps.runRuntimePostBuild({ cwd: deps.cwd, env: deps.env });
@ -1376,11 +1405,13 @@ const syncRuntimeArtifactsAndStamp = async (deps: RunNodeDeps) =>
if (!resolveRuntimePostBuildRequirement(deps).shouldSync) { if (!resolveRuntimePostBuildRequirement(deps).shouldSync) {
return true; return true;
} }
const synced = await syncRuntimeArtifacts(deps); return await withRunNodeRuntimePublication(deps, async () => {
if (synced) { const synced = await syncRuntimeArtifacts(deps);
writeRuntimePostBuildStamp(deps); if (synced) {
} writeRuntimePostBuildStamp(deps);
return synced; }
return synced;
});
}); });
const shouldSkipWatchRuntimeSync = (deps: RunNodeDeps, requirement: RuntimePostBuildRequirement) => const shouldSkipWatchRuntimeSync = (deps: RunNodeDeps, requirement: RuntimePostBuildRequirement) =>
@ -1581,22 +1612,30 @@ export async function runNodeMain(params: RunNodeMainParams = {}): Promise<RunNo
`Building TypeScript (dist is stale: ${lockedBuildRequirement.reason} - ${formatBuildReason(lockedBuildRequirement.reason)}).`, `Building TypeScript (dist is stale: ${lockedBuildRequirement.reason} - ${formatBuildReason(lockedBuildRequirement.reason)}).`,
deps, deps,
); );
return await withRunNodeProgress(deps, "Building local CLI artifacts", async () => { return await withDistArtifactOwnership(deps.cwd, () =>
const build = asRunNodeChild( withRunNodeRuntimePublication(deps, () =>
deps.spawn(deps.execPath, runtimeBuildArgs, { withRunNodeProgress(deps, "Building local CLI artifacts", async () => {
cwd: deps.cwd, const build = asRunNodeChild(
detached: shouldUseRunNodeChildProcessGroup(deps), deps.spawn(
env: { deps.execPath,
...deps.env, distArtifactEntryArgs(path.join(deps.cwd, "scripts/build-all.mts"), ["qaRuntime"]),
[RUN_NODE_SKIP_DTS_BUILD_ENV]: deps.env[RUN_NODE_SKIP_DTS_BUILD_ENV] ?? "1", {
}, cwd: deps.cwd,
stdio: ["inherit", "pipe", "pipe"], detached: shouldUseRunNodeChildProcessGroup(deps),
env: {
...deps.env,
[RUN_NODE_SKIP_DTS_BUILD_ENV]: deps.env[RUN_NODE_SKIP_DTS_BUILD_ENV] ?? "1",
},
stdio: ["inherit", "pipe", "pipe"],
},
),
);
pipeSpawnedOutput(build, deps, { stdoutTarget: "stderr" });
const result = await waitForSpawnedProcess(build, deps);
return getInterruptedSpawnOutcome(result, deps.platform) ?? result.exitCode ?? 1;
}), }),
); ),
pipeSpawnedOutput(build, deps, { stdoutTarget: "stderr" }); );
const result = await waitForSpawnedProcess(build, deps);
return getInterruptedSpawnOutcome(result, deps.platform) ?? result.exitCode ?? 1;
});
}); });
if (buildExitCode !== 0) { if (buildExitCode !== 0) {
return await closeRunNodeOutputTee(deps, buildExitCode); return await closeRunNodeOutputTee(deps, buildExitCode);

View file

@ -23,6 +23,8 @@ export function createUpdateStateProfileInitializer(
if (!fs.existsSync(databasePath) && fs.existsSync(preparedStateDatabase)) { if (!fs.existsSync(databasePath) && fs.existsSync(preparedStateDatabase)) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 }); fs.mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
fs.copyFileSync(preparedStateDatabase, databasePath); fs.copyFileSync(preparedStateDatabase, databasePath);
fixtureStateDatabases.add(databasePath);
return;
} }
const database = openOpenClawStateDatabase({ env }); const database = openOpenClawStateDatabase({ env });
fixtureStateDatabases.add(database.path); fixtureStateDatabases.add(database.path);

View file

@ -2140,7 +2140,6 @@ describe("update-cli", () => {
); );
it.each([ it.each([
{ kind: "git", restart: false, capability: "sealed" },
{ kind: "git", restart: true, capability: "sealed" }, { kind: "git", restart: true, capability: "sealed" },
{ kind: "package", restart: false, capability: "sealed" }, { kind: "package", restart: false, capability: "sealed" },
{ kind: "package", restart: true, capability: "sealed" }, { kind: "package", restart: true, capability: "sealed" },
@ -12478,21 +12477,21 @@ describe("update-cli", () => {
}, },
}, },
{ {
name: "skips service env refresh when --no-restart is set", name: "refuses a running Git installation with --no-restart",
run: async () => { run: async () => {
mockGitUpdateAfterMutation(); mockGitUpdateAfterMutation();
serviceLoaded.mockResolvedValue(true); serviceLoaded.mockResolvedValue(true);
mockOwnedGitService(); mockOwnedGitService();
await updateCommand({ restart: false }); await expect(updateCommand({ restart: false })).rejects.toEqual(new ExitError(1));
}, },
assert: () => { assert: () => {
expectNoSideEffects(runDaemonInstall, runDaemonRestart); expectNoSideEffects(runDaemonInstall, runDaemonRestart, serviceStop);
expect(freshRestartCalls()).toHaveLength(0); expect(freshRestartCalls()).toHaveLength(0);
expect( expect(
gatewayCommandCall(path.join(process.cwd(), "dist", "index.js"), "install"), gatewayCommandCall(path.join(process.cwd(), "dist", "index.js"), "install"),
).toBeUndefined(); ).toBeUndefined();
expect(getLogOutput()).toContain("Gateway: restart skipped (--no-restart)."); expect(getErrorOutput()).toContain("still running");
}, },
}, },
{ {

View file

@ -25,6 +25,76 @@ describe("mutable update execution", () => {
registerExecutionTimeoutTests(); registerExecutionTimeoutTests();
registerNativeAdmissionTests({ executionParams, mocks, successfulUpdate }); registerNativeAdmissionTests({ executionParams, mocks, successfulUpdate });
it.each(["same", "alias", "disjoint"] as const)(
"preserves a serving Git runtime when activation did not stop it: %s",
async (destination) => {
await withTestDir({ prefix: "git-live-runtime-custody-" }, async (dir) => {
const servingRoot = path.join(dir, "serving");
const targetRoot = destination === "same" ? servingRoot : path.join(dir, "target");
await fs.mkdir(path.join(servingRoot, "dist"), { recursive: true });
if (destination === "alias") {
await fs.symlink(
servingRoot,
targetRoot,
process.platform === "win32" ? "junction" : "dir",
);
} else if (destination === "disjoint") {
await fs.mkdir(path.join(targetRoot, "dist"), { recursive: true });
}
const artifact = path.join(servingRoot, "dist", "prepare.runtime.js");
await fs.writeFile(artifact, "retained serving runtime");
const target = { schemaVersions: { state: 15, agent: 19 } };
mocks.maybeStopService.mockImplementation(async () => ({
...inspectOrStopService("inspect"),
servicePid: 23456,
serviceUpdateVerdict: {
kind: "owned",
root: servingRoot,
fingerprint: "serving-generation",
refreshDefinition: false,
},
}));
vi.spyOn(readiness, "verifyPreviousGatewayForUpdate").mockResolvedValue(true);
mocks.runGitUpdate.mockImplementation(
async (
options: Parameters<typeof import("./update-command-git.js").updateGitInstall>[0],
) => {
await options.inspectGitTarget?.(target);
await options.beforeGitMutation?.(target);
await fs.writeFile(
path.join(targetRoot, "dist", "prepare.runtime.js"),
"candidate runtime",
);
return { ...successfulUpdate, mode: "git" };
},
);
const execution = await executeMutableUpdate({
...executionParams("git"),
root: targetRoot,
shouldRestart: false,
opts: { json: true, restart: false },
});
expect(await fs.readFile(artifact, "utf8")).toBe("retained serving runtime");
expect(mocks.serviceStopped).toBe(false);
if (destination === "disjoint") {
expect(execution?.result.status).toBe("ok");
expect(
await fs.readFile(path.join(targetRoot, "dist", "prepare.runtime.js"), "utf8"),
).toBe("candidate runtime");
} else {
expect(execution).toMatchObject({
mutationStarted: false,
result: { status: "error", reason: "runtime-artifact-publication" },
});
expect(execution?.result.steps).toEqual(
expect.arrayContaining([
expect.objectContaining({ stderrTail: expect.stringContaining("23456") }),
]),
);
}
});
},
);
it("retains the live update run when stopped-service context capture fails", async () => { it("retains the live update run when stopped-service context capture fails", async () => {
await withTestDir({ prefix: "partial-stop-recovery-owner-" }, async (dir) => { await withTestDir({ prefix: "partial-stop-recovery-owner-" }, async (dir) => {
const control = path.join(dir, "leases"); const control = path.join(dir, "leases");

View file

@ -15,6 +15,7 @@ import {
canResolveRegistryVersionForPackageTarget, canResolveRegistryVersionForPackageTarget,
verifyPackageUpdateRecovery, verifyPackageUpdateRecovery,
} from "../../infra/update-global.js"; } from "../../infra/update-global.js";
import { updateInstallRootsMatch } from "../../infra/update-install-root.js";
import { recordUpdateRunPhase } from "../../infra/update-run-ledger.js"; import { recordUpdateRunPhase } from "../../infra/update-run-ledger.js";
import { isFailedUpdateStep } from "../../infra/update-run-step.js"; import { isFailedUpdateStep } from "../../infra/update-run-step.js";
import { readCurrentGitUpdateRecovery } from "../../infra/update-runner-git-recovery.js"; import { readCurrentGitUpdateRecovery } from "../../infra/update-runner-git-recovery.js";
@ -539,6 +540,20 @@ export async function executeMutableUpdate(
await stopManagedServiceBeforeMutableUpdate(roots); await stopManagedServiceBeforeMutableUpdate(roots);
await recheckSchemas(admittedTargetSchemaVersions); await recheckSchemas(admittedTargetSchemaVersions);
assertExecutionCurrent(); assertExecutionCurrent();
const serving = preManagedServiceStop;
const servingVerdict = serving?.serviceUpdateVerdict;
if (
params.updateInstallKind === "git" &&
serving?.running &&
!serving.stopped &&
servingVerdict?.kind === "owned" &&
roots.some((root) => updateInstallRootsMatch(root, servingVerdict.root))
) {
throw new UpdatePreMutationError(
"runtime-artifact-publication",
`Cannot replace Git runtime artifacts in ${servingVerdict.root}: its Gateway${serving.servicePid === undefined ? "" : ` (PID ${serving.servicePid})`} is still running and this update did not stop it. Stop that Gateway through its service manager, then rerun \`${formatCliCommand("openclaw update", serving.serviceEnv)}\` without \`--no-restart\`. The serving runtime was left unchanged.`,
);
}
// Both install paths enter mutation only after the post-stop schema/authority fence. // Both install paths enter mutation only after the post-stop schema/authority fence.
preManagedServiceStop?.windowsTaskAutoStartRecovery?.beginMutation(); preManagedServiceStop?.windowsTaskAutoStartRecovery?.beginMutation();
mutationStarted = true; mutationStarted = true;

View file

@ -19,9 +19,13 @@ async function git(root: string, ...args: string[]): Promise<string> {
return result.stdout.trim(); return result.stdout.trim();
} }
it.each([true, false])( it.each([
"checks snapshot space after the Git no-op decision (current=%s)", { current: true, recorded: true, noOp: true },
async (current) => { { current: false, recorded: true, noOp: false },
{ current: true, recorded: false, noOp: false },
])(
"checks snapshot space after the Git no-op decision (current=$current, recorded=$recorded)",
async ({ current, recorded, noOp }) => {
await withTestDir({ prefix: "git-update-snapshot-" }, async (base) => { await withTestDir({ prefix: "git-update-snapshot-" }, async (base) => {
const root = path.join(base, "checkout"); const root = path.join(base, "checkout");
const stateDir = path.join(base, "state"); const stateDir = path.join(base, "state");
@ -46,6 +50,13 @@ it.each([true, false])(
await git(root, "add", "."); await git(root, "add", ".");
await git(root, "commit", "-m", "fixture"); await git(root, "commit", "-m", "fixture");
const before = await git(root, "rev-parse", "HEAD"); const before = await git(root, "rev-parse", "HEAD");
if (recorded) {
await fs.mkdir(path.join(root, "dist"));
await fs.writeFile(
path.join(root, "dist", "build-info.json"),
JSON.stringify({ commit: before }),
);
}
let target = before; let target = before;
if (!current) { if (!current) {
await fs.writeFile(path.join(root, "candidate.txt"), "candidate\n"); await fs.writeFile(path.join(root, "candidate.txt"), "candidate\n");
@ -105,13 +116,13 @@ it.each([true, false])(
: headCommandOptions?.timeoutMs, : headCommandOptions?.timeoutMs,
).toBe(20 * 60_000); ).toBe(20 * 60_000);
expect(result).toMatchObject( expect(result).toMatchObject(
current noOp
? { status: "skipped", reason: "already-current" } ? { status: "skipped", reason: "already-current" }
: { status: "error", reason: "snapshot-capacity-insufficient" }, : { status: "error", reason: "snapshot-capacity-insufficient" },
); );
expect(capacity.mock.calls.length === 0).toBe(current); expect(capacity.mock.calls.length === 0).toBe(noOp);
expect(getSnapshotSource).toHaveBeenCalledTimes(current ? 0 : 1); expect(getSnapshotSource).toHaveBeenCalledTimes(noOp ? 0 : 1);
if (!current) { if (!noOp) {
expect(result.steps).toContainEqual( expect(result.steps).toContainEqual(
expect.objectContaining({ expect.objectContaining({
name: "snapshot-space-preflight", name: "snapshot-space-preflight",

View file

@ -1,6 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { runNodeMain } from "../../../scripts/run-node.mts";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js"; import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js";
import type { GatewayService } from "../../daemon/service.js"; import type { GatewayService } from "../../daemon/service.js";
@ -97,6 +98,67 @@ async function withRuntimePublicationFixture(
}); });
} }
it.each([undefined, "stale-profile"])(
"preserves the serving installation when a source command requests an automatic rebuild (profile=%s)",
(profile) =>
withRuntimePublicationFixture(async ({ root, env, service }) => {
vi.mocked(service.readRuntime).mockResolvedValue({
status: "running",
pid: 23456,
systemd: { managerUid: 2001 },
});
const entry = path.join(root, "dist", "entry.js");
const before = await fs.readFile(entry, "utf8");
const spawn = vi.fn(() => {
throw new Error("Automatic build started under the serving Gateway");
});
await expect(
runNodeMain({
cwd: root,
args: [...(profile ? ["--profile", profile] : []), "doctor"],
env: { ...env, OPENCLAW_RUNNER_LOG: "0" },
spawn,
}),
).rejects.toThrow(/affected Gateway.*running/);
expect(spawn).not.toHaveBeenCalled();
expect(
vi
.mocked(service.readRuntime)
.mock.calls.some(([observedEnv]) => observedEnv?.OPENCLAW_PROFILE === profile),
).toBe(true);
expect(await fs.readFile(entry, "utf8")).toBe(before);
}),
);
it("holds Gateway startup custody until the automatic source build exits", () =>
withRuntimePublicationFixture(async ({ root, env, coordinatorPath }) => {
let builds = 0;
const spawn = (_command: string, args: string[]) => {
if (args.some((arg) => arg.endsWith("scripts/build-all.mts"))) {
builds += 1;
const competingStartup = tryAcquireExclusiveSqliteCoordinator(coordinatorPath);
competingStartup?.release();
expect(competingStartup).toBeNull();
}
return {
on(event: string, listener: (code: number, signal: null) => void) {
if (event === "exit") {
queueMicrotask(() => listener(0, null));
}
},
};
};
expect(
await runNodeMain({
cwd: root,
args: ["doctor"],
env: { ...env, OPENCLAW_RUNNER_LOG: "0" },
spawn,
}),
).toBe(0);
expect(builds).toBe(1);
}));
it.each([true, false])( it.each([true, false])(
"parks the foreground Gateway before source runtime publication only when artifacts change: %s", "parks the foreground Gateway before source runtime publication only when artifacts change: %s",
(changed) => (changed) =>

View file

@ -50,6 +50,7 @@ export async function withGatewayRuntimeArtifactPublication<T>(
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
timeoutMs: number; timeoutMs: number;
assertCurrent: () => void; assertCurrent: () => void;
outputPaths?: readonly string[];
}, },
publish: (assertPublicationCurrent: () => Promise<void>) => Promise<T>, publish: (assertPublicationCurrent: () => Promise<void>) => Promise<T>,
): Promise<T> { ): Promise<T> {
@ -104,21 +105,24 @@ export async function withGatewayRuntimeArtifactPublication<T>(
const same = (a: PathIdentity, b: PathIdentity) => const same = (a: PathIdentity, b: PathIdentity) =>
a.real === b.real || a.real === b.real ||
Boolean(a.stat && b.stat && a.stat.dev === b.stat.dev && a.stat.ino === b.stat.ino); Boolean(a.stat && b.stat && a.stat.dev === b.stat.dev && a.stat.ino === b.stat.ino);
const outputPaths = [ const outputPaths = params.outputPaths ?? [
"dist-runtime", "dist-runtime",
path.join("dist", "extensions", "node_modules", "openclaw"), path.join("dist", "extensions", "node_modules", "openclaw"),
]; ];
const parentPaths = new Set([""]);
for (const output of outputPaths) {
for (let parent = path.dirname(output); parent !== "."; parent = path.dirname(parent)) {
if (!outputPaths.some((replaced) => isPathInside(replaced, parent))) {
parentPaths.add(parent);
}
}
}
const readInspection = async () => { const readInspection = async () => {
assertCurrent(); assertCurrent();
// Parents are stable across publication; output roots themselves are renamed. // Parents are stable across publication; output roots themselves are renamed.
// Record missing descendants too, so creating them cannot redirect a later effect. // Record missing descendants too, so creating them cannot redirect a later effect.
const parents = await Promise.all( const parents = await Promise.all(
[ [...parentPaths].map((relative) =>
"",
"dist",
path.join("dist", "extensions"),
path.join("dist", "extensions", "node_modules"),
].map((relative) =>
relative ? outputIdentity(path.join(params.root, relative)) : identity(params.root), relative ? outputIdentity(path.join(params.root, relative)) : identity(params.root),
), ),
); );

View file

@ -44,7 +44,7 @@ export async function collectStaleRuntimeBuildFindings(
message: `Running build came from commit ${builtCommit.slice(0, 7)}, but the checkout is at ${checkoutCommit.slice(0, 7)}; the loaded runtime is older than its source.`, message: `Running build came from commit ${builtCommit.slice(0, 7)}, but the checkout is at ${checkoutCommit.slice(0, 7)}; the loaded runtime is older than its source.`,
path: root, path: root,
fixHint: fixHint:
"Rebuild with `pnpm build` so the running runtime matches the checkout, then restart the Gateway.", "Run `openclaw update` to rebuild and restart through the update lifecycle. For a manual build, stop the Gateway before running `pnpm build`, then start it again.",
}, },
]; ];
} }

View file

@ -256,6 +256,57 @@ describe("Git candidate activation", () => {
}, },
); );
it.each([
{ channel: "dev", recorded: true },
{ channel: "stable", recorded: true },
{ channel: "dev", recorded: false },
{ channel: "stable", recorded: false },
] as const)(
"rebuilds a source-current $channel checkout before activating its stale runtime (recorded=$recorded)",
async ({ channel, recorded }) => {
const builtSha = beforeSha;
const target = await advanceRemote();
await git(remote, "tag", "v2026.9.1");
await git(root, "pull", "--ff-only");
beforeSha = target;
const buildInfoPath = path.join(root, "dist", "build-info.json");
if (!recorded) {
await fs.writeFile(buildInfoPath, JSON.stringify({ buildId: builtSha }));
}
await expectRuntime(root, builtSha);
const result = await update({
channel,
beforeGitMutation: async () => {
expect(stopped).toBe(false);
await expectRuntime(root, builtSha);
stopped = true;
events.push("stop");
},
});
expect(result.status, JSON.stringify(result)).toBe("ok");
expect(result.before).toMatchObject({ sha: target, buildId: builtSha });
expect(result.after).toMatchObject({ sha: target, buildId: target });
expect(events).toEqual(["build", "validate", "stop", "migrate"]);
await expectRuntime(root, target);
expect(JSON.parse(await fs.readFile(buildInfoPath, "utf8"))).toMatchObject({
commit: target,
});
stopped = false;
events.length = 0;
expect(await update({ channel })).toMatchObject({
status: "skipped",
reason: "already-current",
before: { sha: target, buildId: target },
});
expect(stopped).toBe(false);
expect(events).toEqual([]);
await expectNoRuntimeStagingPaths();
},
);
it("keeps build and exposure source selection in the admitted candidate", async () => { it("keeps build and exposure source selection in the admitted candidate", async () => {
vi.stubEnv("OPENCLAW_DEV_SOURCE_ROOT", root); vi.stubEnv("OPENCLAW_DEV_SOURCE_ROOT", root);
await advanceRemote(); await advanceRemote();

View file

@ -4,6 +4,7 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import { resolveControlUiAssetHealth } from "./control-ui-assets.js"; import { resolveControlUiAssetHealth } from "./control-ui-assets.js";
import { hasErrnoCode } from "./errno.js"; import { hasErrnoCode } from "./errno.js";
import { gitCommitPrefixesMatch } from "./git-commit.js";
import { DEV_BRANCH, resolveDevUpstreamRefs } from "./update-channels.js"; import { DEV_BRANCH, resolveDevUpstreamRefs } from "./update-channels.js";
import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js"; import { resolveDevUpdateTargetRevision, type DevUpdateTarget } from "./update-dev-target.js";
import { import {
@ -527,6 +528,7 @@ export async function runGitCandidatePreflight(params: {
refreshedRemotes: readonly string[]; refreshedRemotes: readonly string[];
targetRevision?: string; targetRevision?: string;
beforeSha?: string | null; beforeSha?: string | null;
beforeBuiltCommit: string | null;
beforeGitStaging?: UpdateRunnerOptions["beforeGitStaging"]; beforeGitStaging?: UpdateRunnerOptions["beforeGitStaging"];
validateCandidate: UpdateRunnerOptions["validateCandidate"]; validateCandidate: UpdateRunnerOptions["validateCandidate"];
prepareGitExposure?: UpdateRunnerOptions["prepareGitExposure"]; prepareGitExposure?: UpdateRunnerOptions["prepareGitExposure"];
@ -599,8 +601,12 @@ export async function runGitCandidatePreflight(params: {
localDevBranchExists = upstream.localDevBranchExists; localDevBranchExists = upstream.localDevBranchExists;
} }
// A resolved no-op must not enter validation, stop the service, or rewrite its runtime. // A matching source revision cannot prove an unrecorded runtime is current.
if (!params.prepareGitExposure && preflightBaseSha === params.beforeSha) { const canSkipActivation =
!params.prepareGitExposure &&
params.beforeBuiltCommit !== null &&
gitCommitPrefixesMatch(params.beforeBuiltCommit, params.beforeSha ?? "");
if (canSkipActivation && preflightBaseSha === params.beforeSha) {
return { status: "skipped", reason: "already-current" }; return { status: "skipped", reason: "already-current" };
} }
if (params.beforeGitStaging) { if (params.beforeGitStaging) {
@ -651,7 +657,7 @@ export async function runGitCandidatePreflight(params: {
}; };
} }
for (const sha of candidates) { for (const sha of candidates) {
if (!params.prepareGitExposure && sha === params.beforeSha) { if (canSkipActivation && sha === params.beforeSha) {
return { status: "skipped", reason: "already-current" }; return { status: "skipped", reason: "already-current" };
} }
if (sha !== preflightBaseSha) { if (sha !== preflightBaseSha) {

View file

@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@ -56,6 +56,7 @@ describe("Git updater release tag refresh", () => {
path.join(seed, "package.json"), path.join(seed, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.9.1" }), JSON.stringify({ name: "openclaw", version: "2026.9.1" }),
); );
writeFileSync(path.join(seed, ".gitignore"), "dist/\n");
git(seed, "add", "."); git(seed, "add", ".");
git(seed, "commit", "-m", "original release"); git(seed, "commit", "-m", "original release");
const oldTag = git(seed, "rev-parse", "HEAD"); const oldTag = git(seed, "rev-parse", "HEAD");
@ -65,6 +66,17 @@ describe("Git updater release tag refresh", () => {
git(seed, "push", "origin", "main", "--tags"); git(seed, "push", "origin", "main", "--tags");
git(directory, "clone", "--origin", releaseRemote, upstream, root); git(directory, "clone", "--origin", releaseRemote, upstream, root);
git(root, "checkout", "--detach"); git(root, "checkout", "--detach");
const dist = path.join(root, "dist");
mkdirSync(path.join(dist, "control-ui"), { recursive: true });
writeFileSync(path.join(dist, "entry.js"), "export {};\n");
writeFileSync(
path.join(dist, "build-info.json"),
JSON.stringify({ commit: release, buildId: release }),
);
for (const stamp of [".buildstamp", ".runtime-postbuildstamp"]) {
writeFileSync(path.join(dist, stamp), JSON.stringify({ head: release }));
}
writeFileSync(path.join(dist, "control-ui", "index.html"), "ready");
git(root, "tag", "local-only", oldTag); git(root, "tag", "local-only", oldTag);
if (forkRemote) { if (forkRemote) {
const fork = path.join(directory, "fork.git"); const fork = path.join(directory, "fork.git");

View file

@ -4,7 +4,11 @@ import { readPackageVersion } from "./package-json.js";
import { DEV_BRANCH, type UpdateChannel } from "./update-channels.js"; import { DEV_BRANCH, type UpdateChannel } from "./update-channels.js";
import { getUpdateDoctorConfigFailureReason } from "./update-doctor-config.js"; import { getUpdateDoctorConfigFailureReason } from "./update-doctor-config.js";
import { createUpdateErrorFact } from "./update-failure-facts.js"; import { createUpdateErrorFact } from "./update-failure-facts.js";
import { readBuiltGatewayBuildId, verifyGitUpdateRecovery } from "./update-git-runtime.js"; import {
readBuiltGatewayBuildId,
readBuiltRuntimeCommit,
verifyGitUpdateRecovery,
} from "./update-git-runtime.js";
import { UpdateRequesterRevokedError } from "./update-requester-authority.js"; import { UpdateRequesterRevokedError } from "./update-requester-authority.js";
import { isFailedUpdateStep } from "./update-run-step.js"; import { isFailedUpdateStep } from "./update-run-step.js";
import { runStep } from "./update-runner-command.js"; import { runStep } from "./update-runner-command.js";
@ -65,9 +69,10 @@ export async function updateGitCheckout(params: {
timeoutMs, timeoutMs,
}); });
const beforeSha = beforeShaResult.stdout.trim() || null; const beforeSha = beforeShaResult.stdout.trim() || null;
const [beforeVersion, beforeBuildId] = await Promise.all([ const [beforeVersion, beforeBuildId, beforeBuiltCommit] = await Promise.all([
readPackageVersion(gitRoot), readPackageVersion(gitRoot),
readBuiltGatewayBuildId(gitRoot), readBuiltGatewayBuildId(gitRoot),
readBuiltRuntimeCommit(gitRoot),
]); ]);
const before = { const before = {
sha: beforeSha, sha: beforeSha,
@ -406,6 +411,7 @@ export async function updateGitCheckout(params: {
devTarget, devTarget,
refreshedRemotes: fetched.refreshedRemotes, refreshedRemotes: fetched.refreshedRemotes,
beforeSha, beforeSha,
beforeBuiltCommit,
beforeGitStaging: opts.beforeGitStaging, beforeGitStaging: opts.beforeGitStaging,
needsCheckoutMain, needsCheckoutMain,
timeoutMs, timeoutMs,

View file

@ -38,6 +38,13 @@ import {
import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js"; import { resolveTestNodeExecPath } from "../../src/test-utils/node-process.js";
import { toolingProbeRuntimeEntrypoints } from "./tooling-probe-runtime.test-support.mts"; import { toolingProbeRuntimeEntrypoints } from "./tooling-probe-runtime.test-support.mts";
vi.mock("../../src/cli/update-cli/update-command-service-publication.js", () => ({
withGatewayRuntimeArtifactPublication: async (
_params: unknown,
publish: () => Promise<unknown>,
) => publish(),
}));
const testNodeExecPath = resolveTestNodeExecPath(); const testNodeExecPath = resolveTestNodeExecPath();
const buildAllUrl = resolveRuntimeWorkerUrl(toolingProbeRuntimeEntrypoints.buildAll); const buildAllUrl = resolveRuntimeWorkerUrl(toolingProbeRuntimeEntrypoints.buildAll);
const buildArtifactCacheUrl = resolveRuntimeWorkerUrl( const buildArtifactCacheUrl = resolveRuntimeWorkerUrl(
@ -884,7 +891,13 @@ describe("resolveBuildAllSteps", () => {
}), }),
).toBe(0); ).toBe(0);
expect(spawn.mock.calls.map(([, args]) => args)).toEqual([ expect(spawn.mock.calls.map(([, args]) => args)).toEqual([
["--import", "tsx", "scripts/build-all.mts", "qaRuntime"], [
"--import",
expect.stringMatching(/\/scripts\/tsx\.mjs$/),
expect.stringMatching(/[\\/]scripts[\\/]lib[\\/]dist-artifact-ownership\.mts$/),
expect.stringMatching(/\/scripts\/build-all\.mts$/),
"qaRuntime",
],
["openclaw.mjs", "status"], ["openclaw.mjs", "status"],
]); ]);
const env = spawn.mock.calls[0]![2].env!; const env = spawn.mock.calls[0]![2].env!;

View file

@ -527,6 +527,8 @@ describe.skipIf(process.platform === "win32")("dist artifact ownership", () => {
import fs from 'node:fs'; import fs from 'node:fs';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { registerSourceRunnerServiceFixture } = await import(${JSON.stringify(path.join(sourceRoot, "test/scripts/fixtures/source-runner-service.mjs"))});
registerSourceRunnerServiceFixture(${JSON.stringify(sourceRoot)});
const { runNodeMain } = await import(${JSON.stringify(path.join(sourceRoot, "scripts/run-node.mts"))}); const { runNodeMain } = await import(${JSON.stringify(path.join(sourceRoot, "scripts/run-node.mts"))});
process.exitCode = await runNodeMain({ process.exitCode = await runNodeMain({
cwd: process.cwd(), args: ['artifact-fixture'], cwd: process.cwd(), args: ['artifact-fixture'],

View file

@ -3,6 +3,7 @@ import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module"; import { syncBuiltinESMExports } from "node:module";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { registerSourceRunnerServiceFixture } from "./source-runner-service.mjs";
const root = process.env.OPENCLAW_TEST_NATIVE_RUNNER_ROOT; const root = process.env.OPENCLAW_TEST_NATIVE_RUNNER_ROOT;
const sourceRoot = process.env.OPENCLAW_TEST_NATIVE_RUNNER_SOURCE; const sourceRoot = process.env.OPENCLAW_TEST_NATIVE_RUNNER_SOURCE;
@ -10,6 +11,7 @@ const mode = process.env.OPENCLAW_TEST_NATIVE_RUNNER_MODE;
if (!root || !sourceRoot || (mode !== "runner" && mode !== "watch")) { if (!root || !sourceRoot || (mode !== "runner" && mode !== "watch")) {
throw new Error("Native runner signal fixture is missing its private scope"); throw new Error("Native runner signal fixture is missing its private scope");
} }
registerSourceRunnerServiceFixture(sourceRoot);
const fixture = fileURLToPath(import.meta.url); const fixture = fileURLToPath(import.meta.url);
const release = path.join(root, "release"); const release = path.join(root, "release");
const terminate = path.join(root, "terminate"); const terminate = path.join(root, "terminate");

View file

@ -0,0 +1,31 @@
import { registerHooks } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
// Compiler and process fixtures own no Gateway; its custody has separate boundary tests.
export function registerSourceRunnerServiceFixture(sourceRoot) {
const rootUrl = pathToFileURL(sourceRoot + path.sep).href;
const modules = new Map([
[
"src/cli/update-cli/update-command-service-publication",
"export async function withGatewayRuntimeArtifactPublication(_params, publish) { return publish(async () => {}); }",
],
[
"src/cli/profile",
"export function parseCliProfileArgs(argv) { return { ok: true, profile: null, argv }; } export function applyCliProfileEnv() { throw new Error('This fixture has no profile'); }",
],
]);
registerHooks({
load(url, context, nextLoad) {
// The real wrappers load both source and prepared legacy-finalizer modules.
const source = url.startsWith(rootUrl)
? [...modules].find(
([name]) => url.endsWith(`/${name}.ts`) || url.endsWith(`/${name}.js`),
)?.[1]
: undefined;
return source === undefined
? nextLoad(url, context)
: { format: "module", source, shortCircuit: true };
},
});
}

View file

@ -239,7 +239,14 @@ it.runIf(process.platform !== "win32").each(["runner", "watch"] as const)(
); );
await runQaGatewayFixture( await runQaGatewayFixture(
async () => { async () => {
const worker = await waitForPidFile(path.join(root, "worker.pid"), 5_000); const worker = await Promise.race([
waitForPidFile(path.join(root, "worker.pid"), 5_000),
command.then((result) => {
throw new Error(
`Native ${mode} exited before its worker started: ${formatShimResult(result)}`,
);
}),
]);
expect(isProcessAlive(worker)).toBe(true); expect(isProcessAlive(worker)).toBe(true);
writeFileSync(path.join(root, "terminate"), "terminate"); writeFileSync(path.join(root, "terminate"), "terminate");
const result = await command; const result = await command;

View file

@ -10,7 +10,7 @@ import {
bundledPluginFile, bundledPluginFile,
bundledPluginRoot, bundledPluginRoot,
} from "openclaw/plugin-sdk/test-fixtures"; } from "openclaw/plugin-sdk/test-fixtures";
import { expect, it as baseIt } from "vitest"; import { expect, it as baseIt, vi } from "vitest";
import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mts"; import { copyBundledPluginMetadata } from "../../scripts/copy-bundled-plugin-metadata.mts";
import { import {
BUILD_STAMP_FILE, BUILD_STAMP_FILE,
@ -26,6 +26,13 @@ import {
} from "../../scripts/lib/update-compat-chunks.mts"; } from "../../scripts/lib/update-compat-chunks.mts";
import { runNodeMain } from "../../scripts/run-node.mts"; import { runNodeMain } from "../../scripts/run-node.mts";
import { withTestDir } from "../../src/test-helpers/temp-dir.js"; import { withTestDir } from "../../src/test-helpers/temp-dir.js";
// These launcher fixtures have no service. Publication custody is covered at its owner.
vi.mock("../../src/cli/update-cli/update-command-service-publication.js", () => ({
withGatewayRuntimeArtifactPublication: async (
_params: unknown,
publish: () => Promise<unknown>,
) => publish(),
}));
import { import {
previousReleaseInventory, previousReleaseInventory,
writeUpdateCompatibilityBuildFixture, writeUpdateCompatibilityBuildFixture,
@ -210,7 +217,14 @@ export async function writeRuntimePostBuildScaffold(tmp: string): Promise<void>
} }
export function expectedBuildSpawn() { export function expectedBuildSpawn() {
return [process.execPath, "--import", "tsx", "scripts/build-all.mts", "qaRuntime"]; return [
process.execPath,
"--import",
expect.stringMatching(/\/scripts\/tsx\.mjs$/),
expect.stringMatching(/[\\/]scripts[\\/]lib[\\/]dist-artifact-ownership\.mts$/),
expect.stringMatching(/\/scripts\/build-all\.mts$/),
"qaRuntime",
];
} }
export function statusCommandSpawn() { export function statusCommandSpawn() {
@ -234,7 +248,7 @@ export function resolvePath(tmp: string, relativePath: string) {
} }
export function isTsxScriptArgs(args: string[], scriptPath: string): boolean { export function isTsxScriptArgs(args: string[], scriptPath: string): boolean {
return args[0] === "--import" && args[1] === "tsx" && args[2] === scriptPath; return args[0] === "--import" && args.some((arg) => arg.endsWith(scriptPath));
} }
export async function expectPathMissing(targetPath: string): Promise<void> { export async function expectPathMissing(targetPath: string): Promise<void> {