fix(update): refuse runtime repair while shared outputs are in use (#161239)

Reuse managed-service publication checks at each runtime publication boundary,
comparing the publisher's exact physical outputs. Live disjoint runtimes stay
available; observed shared consumers must be stopped before replacement.

Document that stopped sibling profiles retain their own state and may need the
updated CLI's existing Doctor migration before resuming. No schema version or
automatic sibling migration is introduced.

Validation: 67 focused tests pass; five overlap regressions take 202 ms.
Fresh managed review is scoped-clean through P2. Isolated Linux native proof of
the composed selector/runtime candidate covers disjoint, overlapping live,
and stopped-sibling publication with preserved conversation witnesses.
This commit is contained in:
Peter Steinberger 2026-09-29 16:09:44 -07:00 • committed by GitHub
parent 3e29414ba1
commit ecb13ad18d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 123 additions and 14 deletions

View file

@ -45,6 +45,13 @@ The new build records its commit, so the next update can finish as already curre
installation; the update leaves those files intact and reports the process and
the stop/retry action.
Stopping another Gateway that shares this installation protects its running
code; it does not migrate that Gateway's separate state. If its older state
requires migration, run the updated CLI's `doctor --fix` under that service's own
account, profile, and environment before resuming it. Doctor may restore the
service itself, so inspect its status before starting it again through its
service manager.
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
@ -962,7 +969,7 @@ the sentinel.
<Step title="Sync plugins">
Against the installed target, syncs plugins to the active channel before restarting the managed service. Dev uses bundled plugins; stable and beta use npm or ClawHub while preserving recorded source choices. A changed plugin snapshot runs fresh Doctor migrations; unchanged plugins do not run another full Doctor pass. The updater then revalidates the service owner, starts the Gateway, and verifies the final snapshot.
Source targets that support runtime completion also check their generated plugin runtime overlay and SDK aliases before loading plugin configuration. This completes artifacts omitted by an older updater on the first update to such a target; `update repair` performs the same check before Doctor. Exact artifacts remain untouched, including while a Gateway is running. Replacing missing or stale artifacts requires proof that the affected runtime is offline. A Gateway serving a physically separate runtime does not block completion. If service ownership or offline status cannot be verified, completion fails with recovery guidance instead of reporting a successful update. Older targets retain their existing generation behavior. Clean-source and staged-build validation still apply.
Source targets that support runtime completion also check their generated plugin runtime overlay and SDK aliases before loading plugin configuration. This completes artifacts omitted by an older updater on the first update to such a target; `update repair` performs the same check before Doctor. Exact artifacts remain untouched, including while a Gateway is running. Replacing missing or stale artifacts requires proof that the selected Gateway's affected runtime is offline. Before publication and each subsequent write, completion also checks discovered managed Gateways and refuses any observed live sibling using overlapping output paths. Stop that sibling through its own service manager or Startup process before retrying. A Gateway serving a physically separate runtime does not block completion. If the selected service's ownership or offline status cannot be verified, completion fails with recovery guidance instead of reporting a successful update. Older targets retain their existing generation behavior. Clean-source and staged-build validation still apply.
</Step>
</Steps>

View file

@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { runNodeMain } from "../../../scripts/run-node.mts";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import * as serviceFiles from "../../daemon/inspect-files.js";
import { ServiceInspectionError } from "../../daemon/service-inspection-error.js";
import { withGatewayServiceOperationLock } from "../../daemon/service-operation-lock.js";
import type { GatewayService } from "../../daemon/service.js";
@ -463,6 +464,85 @@ it.each([
}),
);
it.each([
{ label: "already live", late: false, output: "dist-runtime", shared: "dist-runtime" },
{
label: "discovered before the effect",
late: true,
output: "dist-runtime",
shared: "dist-runtime",
},
{ label: "physically disjoint", late: false, output: "dist-runtime", shared: undefined },
{
label: "custom output shared",
late: false,
output: "custom-runtime",
shared: "custom-runtime",
},
{
label: "custom output disjoint",
late: false,
output: "custom-runtime",
shared: "dist-runtime",
},
])("fences runtime-only publication against a sibling that is $label", ({ late, output, shared }) =>
withRuntimePublicationFixture(async ({ home, root, env, service }) => {
const sibling = path.join(home, "sibling");
await fs.mkdir(path.join(sibling, "dist"), { recursive: true });
await fs.writeFile(path.join(sibling, "package.json"), JSON.stringify({ name: "openclaw" }));
await fs.writeFile(path.join(sibling, "dist", "entry.js"), "export {};\n");
await fs.mkdir(path.join(root, output), { recursive: true });
if (shared) {
await fs.symlink(path.join(root, shared), path.join(sibling, shared), "junction");
}
const directory = path.join(home, ".config", "systemd", "user");
await fs.mkdir(directory, { recursive: true });
const scan = serviceFiles.scanSystemdDir;
vi.spyOn(serviceFiles, "scanSystemdDir").mockImplementation((params) =>
params.dir === directory ? scan(params) : Promise.resolve([]),
);
const unit = "openclaw-gateway-sibling.service";
const discoverSibling = () =>
fs.writeFile(
path.join(directory, unit),
`[Service]\nEnvironment="OPENCLAW_PROFILE=sibling" "OPENCLAW_SERVICE_MARKER=openclaw" "OPENCLAW_SERVICE_KIND=gateway"\nExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(sibling, "dist", "entry.js"))} gateway\n`,
);
if (!late) {
await discoverSibling();
}
vi.mocked(service.readCommand).mockImplementation(async (serviceEnv) => ({
programArguments: [
process.execPath,
path.join(serviceEnv?.OPENCLAW_SYSTEMD_UNIT === unit ? sibling : root, "dist", "entry.js"),
"gateway",
],
}));
vi.mocked(service.readRuntime).mockImplementation(async (serviceEnv) => ({
status: serviceEnv?.OPENCLAW_SYSTEMD_UNIT === unit ? "running" : "stopped",
systemd: { managerUid: 2001 },
}));
const artifact = path.join(root, output, "published.txt");
await fs.writeFile(artifact, "original");
const publication = withGatewayRuntimeArtifactPublication(
{ root, env, timeoutMs: 200, assertCurrent() {}, outputPaths: [output] },
async (assertPublicationCurrent) => {
if (late) {
await discoverSibling();
}
await assertPublicationCurrent();
await fs.writeFile(artifact, "candidate");
},
);
const overlaps = shared === output;
if (overlaps) {
await expect(publication).rejects.toMatchObject({ reason: "runtime-artifact-publication" });
} else {
await expect(publication).resolves.toBeUndefined();
}
expect(await fs.readFile(artifact, "utf8")).toBe(overlaps ? "original" : "candidate");
}),
);
it.each(["inspection", "publication"])(
"retains the caller's publication lease across %s awaits",
(when) =>

View file

@ -32,6 +32,7 @@ import {
observedSystemdManagerUid,
resolveUpdatedGatewayRestartPort,
} from "./update-command-service-plan.js";
import { assertManagedGatewayArtifactPublication } from "./update-command-service-revalidation.js";
export async function isManagedGatewayServiceOffline(state: GatewayServiceState): Promise<boolean> {
// Loaded LaunchAgents can respawn even while disabled. Windows needs the live
@ -155,8 +156,6 @@ export async function withGatewayRuntimeArtifactPublication<T>(
timeoutMs: params.timeoutMs,
});
assertCurrent();
const layout = await summarizeGatewayServiceLayout(state.command);
assertCurrent();
const database = await outputIdentity(resolveOpenClawStateSqlitePath(state.env));
assertCurrent();
const serviceName =
@ -171,20 +170,22 @@ export async function withGatewayRuntimeArtifactPublication<T>(
profile: resolveGatewayProfileSuffix(state.env.OPENCLAW_PROFILE),
managerUid: observedSystemdManagerUid(state),
});
let serving: { root: PathIdentity; entrypoint: PathIdentity } | undefined;
let disjoint = false;
if (layout?.packageRootReal && layout.entrypointReal) {
const inspectServing = async (command: GatewayServiceState["command"]) => {
const layout = await summarizeGatewayServiceLayout(command);
assertCurrent();
if (!layout?.packageRootReal || !layout.entrypointReal) {
return undefined;
}
const [installed, entrypoint] = await Promise.all([
identity(layout.packageRootReal),
outputIdentity(layout.entrypointReal),
]);
assertCurrent();
serving = { root: installed, entrypoint };
const servingOutputs = await Promise.all(
outputPaths.map((output) => outputIdentity(path.join(installed.real, output))),
);
assertCurrent();
disjoint =
const disjoint =
!same(target, installed) &&
!destinations.some(
(destination) =>
@ -197,11 +198,17 @@ export async function withGatewayRuntimeArtifactPublication<T>(
isPathInside(output.real, destination.real),
),
);
} else if (
state.command ||
state.installed ||
state.loadState.status !== "not-loaded" ||
!state.runtime?.missingUnit
return { serving: { root: installed, entrypoint }, disjoint };
};
const inspected = await inspectServing(state.command);
const serving = inspected?.serving;
const disjoint = inspected?.disjoint ?? false;
if (
!inspected &&
(state.command ||
state.installed ||
state.loadState.status !== "not-loaded" ||
!state.runtime?.missingUnit)
) {
refuse();
}
@ -241,6 +248,18 @@ export async function withGatewayRuntimeArtifactPublication<T>(
refuse();
}
}
await assertManagedGatewayArtifactPublication({
roots: [params.root],
env: params.env,
timeoutMs: params.timeoutMs,
assertCurrent,
updateInstallKind: "git",
shouldRestart: false,
inspectOverlap: async (_root, command) => {
const consumer = await inspectServing(command);
return consumer ? !consumer.disjoint : null;
},
});
return { state, disjoint, parents, destinations, database, nativeIdentity, serving };
};
const inspect = async () => {

View file

@ -76,6 +76,7 @@ export async function assertManagedGatewayArtifactPublication(params: {
updateInstallKind: "git" | "package" | "unknown";
shouldRestart: boolean;
phase?: "before-stop" | "publication";
inspectOverlap?: typeof gatewayServiceCommandOverlapsPhysicalInstallation;
}): Promise<void> {
params.assertCurrent();
const serving = params.selected;
@ -202,7 +203,9 @@ export async function assertManagedGatewayArtifactPublication(params: {
continue;
}
for (const root of params.roots) {
const overlaps = await gatewayServiceCommandOverlapsPhysicalInstallation(root, state.command);
const overlaps = await (
params.inspectOverlap ?? gatewayServiceCommandOverlapsPhysicalInstallation
)(root, state.command);
params.assertCurrent();
if (overlaps !== true || (await selectedConsumer(binding, state))) {
continue;