fix(update): defer subagent recovery until activation (#162998)

This commit is contained in:
Peter Steinberger 2026-10-01 21:36:30 -05:00 • committed by GitHub
parent 26a3530288
commit 5cce0c99b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 44 deletions

View file

@ -199,8 +199,8 @@ classification. The live plugin files and host links stay unchanged. Channels,
cron, automatic updates, and other side services are suppressed in this canary.
The copied databases undergo the same schema checks and migrations without
reviving the removed Tasks registry.
The canary also defers session catalog hydration, worker recovery, and startup
maintenance until activation, recording a warning. Required configuration,
The canary also defers session catalog hydration, subagent and worker recovery,
and startup maintenance until activation, recording a warning. Required configuration,
database ownership, schema, and migration checks still run before readiness;
plugin runtime loading remains part of validation. The serving Gateway prepares
its session catalogs and maintenance normally after activation.

View file

@ -490,8 +490,13 @@ export async function prepareGatewayServerBootstrap(input: {
);
}
publishSystemEventStoreConfig(cfgAtStart);
const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", () =>
prepareGatewayPluginBootstrap({
const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", async () => {
if (!opts.updateCanary) {
const { initSubagentRegistry } =
await import("../agents/subagents/registry/subagent-registry.js");
await initSubagentRegistry();
}
return prepareGatewayPluginBootstrap({
cfgAtStart,
activationSourceConfig: startupActivationSourceConfig,
pluginMetadataSnapshot: startupConfigLoad.pluginMetadataSnapshot,
@ -499,8 +504,8 @@ export async function prepareGatewayServerBootstrap(input: {
minimalTestGateway,
ambientEnvTriggers,
log,
}),
);
});
});
const {
gatewayPluginConfigAtStart,
defaultWorkspaceDir,

View file

@ -5,6 +5,11 @@
// ui-e2e suite that boots a minimal test gateway.
import fs from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
awaitGateBeforeSettlement,
createDeferred,
withinTest,
} from "../../test/helpers/promise.js";
import { resetConfigRuntimeState } from "../config/runtime-snapshot.js";
import { readLoggingConfig } from "../logging/config.js";
import { resetLogger } from "../logging/logger.js";
@ -31,7 +36,7 @@ afterEach(() => {
});
describe("gateway minimal boot smoke", () => {
it("suppresses ambient channel triggers when the server option is omitted", async () => {
it("suppresses ambient channel triggers and awaits subagent recovery", async ({ signal }) => {
const port = await getFreePort();
const state = await createOpenClawTestState({
label: "gateway-bootstrap-ambient-default",
@ -56,9 +61,29 @@ describe("gateway minimal boot smoke", () => {
state.applyEnv();
try {
const { prepareGatewayServerBootstrap } = await import("./server-startup-bootstrap.js");
const [{ prepareGatewayServerBootstrap }, subagents, pluginContext] = await Promise.all([
import("./server-startup-bootstrap.js"),
import("../agents/subagents/registry/subagent-registry.js"),
import("../plugins/runtime/load-context.js"),
]);
const enteredRecovery = createDeferred();
const releaseRecovery = createDeferred();
let recoveryComplete = false;
const init = vi.spyOn(subagents, "initSubagentRegistry").mockImplementation(() => {
enteredRecovery.resolve();
return releaseRecovery.promise.then(() => {
recoveryComplete = true;
});
});
const publishPluginContext = pluginContext.setPluginRuntimeLoadContext;
const publish = vi
.spyOn(pluginContext, "setPluginRuntimeLoadContext")
.mockImplementation((...args) => {
expect(recoveryComplete).toBe(true);
return publishPluginContext(...args);
});
const log = createSubsystemLogger("gateway/bootstrap-test");
const bootstrap = await prepareGatewayServerBootstrap({
const pendingBootstrap = prepareGatewayServerBootstrap({
port,
opts: {
auth: { mode: "token", token },
@ -72,13 +97,31 @@ describe("gateway minimal boot smoke", () => {
await import("./server-worker-environment-startup.js"),
formatRuntimeGatewayAuthTokenWarning: () => "unused",
});
expect(bootstrap.ambientEnvTriggers).toBe("suppress");
vi.stubEnv(
"OPENCLAW_CONFIG_PATH",
`/tmp/openclaw-bootstrap-missing-${process.pid}-${Date.now()}.json`,
);
expect(readLoggingConfig()).toMatchObject({ level: "debug" });
try {
await withinTest(
awaitGateBeforeSettlement(
enteredRecovery.promise,
pendingBootstrap,
"Gateway bootstrap did not prepare subagent recovery",
),
signal,
);
expect(publish).not.toHaveBeenCalled();
releaseRecovery.resolve();
const bootstrap = await withinTest(pendingBootstrap, signal);
expect(publish).toHaveBeenCalled();
expect(bootstrap.ambientEnvTriggers).toBe("suppress");
vi.stubEnv(
"OPENCLAW_CONFIG_PATH",
`/tmp/openclaw-bootstrap-missing-${process.pid}-${Date.now()}.json`,
);
expect(readLoggingConfig()).toMatchObject({ level: "debug" });
} finally {
releaseRecovery.resolve();
await pendingBootstrap.catch(() => {});
init.mockRestore();
publish.mockRestore();
}
} finally {
await state.cleanup();
}

View file

@ -24,7 +24,6 @@ const applyPluginAutoEnable = vi.hoisted(() =>
autoEnabledReasons: {} as Record<string, string[]>,
})),
);
const initSubagentRegistry = vi.hoisted(() => vi.fn());
const getActivePluginRegistry = vi.hoisted(() => vi.fn<() => PluginRegistry | undefined>());
const setActivePluginRegistry = vi.hoisted(() => vi.fn());
const resolveProviderPolicySurfaceForOwner = vi.hoisted(() =>
@ -141,10 +140,6 @@ vi.mock("../agents/workspace-state-dirs.js", () => ({
assertConfiguredWorkspaceStateReady: () => {},
}));
vi.mock("../agents/subagents/registry/subagent-registry.js", () => ({
initSubagentRegistry: () => initSubagentRegistry(),
}));
vi.mock("../channels/plugins/lifecycle-startup.js", () => ({
runChannelPluginStartupMaintenance: (params: unknown) =>
runChannelPluginStartupMaintenance(params),
@ -316,7 +311,6 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
getActivePluginRegistry.mockReset();
setActivePluginRegistry.mockClear();
applyPluginAutoEnable.mockClear();
initSubagentRegistry.mockClear();
prepareGatewayPluginLoad.mockClear();
listAmbientOnlyConfiguredChannelIds.mockClear().mockReturnValue([]);
loadPluginLookUpTable.mockClear().mockReturnValue({
@ -341,15 +335,6 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
expect(listLegacyPairingStoreFiles).not.toHaveBeenCalled();
});
it("hydrates the subagent registry before plugin bootstrap", async () => {
await prepareBootstrapWithRuntimeConfig({});
expect(initSubagentRegistry).toHaveBeenCalledOnce();
expect(initSubagentRegistry.mock.invocationCallOrder[0]).toBeLessThan(
loadPluginLookUpTable.mock.invocationCallOrder[0]!,
);
});
it("derives startup activation from source config instead of runtime plugin defaults", async () => {
const sourceConfig = {
channels: {

View file

@ -1,5 +1,4 @@
import { tryResolveConfiguredAgentWorkspaceDir } from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagents/registry/subagent-registry.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js";
import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
import { validateConfiguredBindings } from "../channels/plugins/configured-binding-registry.js";
@ -136,7 +135,6 @@ export async function prepareGatewayPluginBootstrap(params: {
ambientEnvTriggers?: AmbientEnvTriggerPolicy;
}) {
const activationSourceConfig = params.activationSourceConfig ?? params.cfgAtStart;
await initSubagentRegistry();
// Activation uses the pre-runtime source so auto-enable policy cannot be skewed by
// defaults injected while loading runtime config; runtime-only plugin config still merges in.

View file

@ -64,17 +64,27 @@ beforeAll(async () => {
},
});
const [cliShim, githubCleanup, plugins, workers, projection, lifetime, discovery, skills] =
await Promise.all([
import("../infra/openclaw-cli-shim.js"),
import("../agents/github-tool-profile-cleanup.js"),
import("./server-startup-plugins.js"),
import("./server-worker-environment-startup.js"),
import("./session-row-projection.js"),
import("./server-lifetime-sidecars.js"),
import("./server-discovery-runtime.js"),
import("../skills/runtime/remote.js"),
]);
const [
cliShim,
githubCleanup,
plugins,
workers,
projection,
lifetime,
discovery,
skills,
subagents,
] = await Promise.all([
import("../infra/openclaw-cli-shim.js"),
import("../agents/github-tool-profile-cleanup.js"),
import("./server-startup-plugins.js"),
import("./server-worker-environment-startup.js"),
import("./session-row-projection.js"),
import("./server-lifetime-sidecars.js"),
import("./server-discovery-runtime.js"),
import("../skills/runtime/remote.js"),
import("../agents/subagents/registry/subagent-registry.js"),
]);
vi.spyOn(cliShim, "prepareGatewayAgentCliShim").mockImplementation(() =>
rejectDeferredWork("agent CLI shim"),
);
@ -99,6 +109,12 @@ beforeAll(async () => {
vi.spyOn(skills, "primeRemoteSkillsCache").mockImplementation(() =>
rejectDeferredWork("remote skills warmup"),
);
vi.spyOn(subagents, "initSubagentRegistry").mockImplementation(() =>
rejectDeferredWork("subagent recovery hydration"),
);
vi.spyOn(subagents, "activateSubagentRegistry").mockImplementation(() =>
rejectDeferredWork("subagent recovery activation"),
);
const pluginLoad = vi.spyOn(plugins, "loadGatewayStartupPluginRuntime");
await withAgentDatabaseStartupAdmission(async (admission) => {
vi.spyOn(admission, "activate").mockImplementation(() =>