diff --git a/docs/cli/update/how-updates-run.md b/docs/cli/update/how-updates-run.md index 5451f75fc427..8c98dad01a7e 100644 --- a/docs/cli/update/how-updates-run.md +++ b/docs/cli/update/how-updates-run.md @@ -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. diff --git a/src/gateway/server-startup-bootstrap.ts b/src/gateway/server-startup-bootstrap.ts index 30d6a1a688a2..8485563eda0c 100644 --- a/src/gateway/server-startup-bootstrap.ts +++ b/src/gateway/server-startup-bootstrap.ts @@ -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, diff --git a/src/gateway/server-startup-minimal-boot.test.ts b/src/gateway/server-startup-minimal-boot.test.ts index c1745bd056ab..0d62ee2a13a9 100644 --- a/src/gateway/server-startup-minimal-boot.test.ts +++ b/src/gateway/server-startup-minimal-boot.test.ts @@ -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(); } diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index bf21e673162b..247bdbea1456 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -24,7 +24,6 @@ const applyPluginAutoEnable = vi.hoisted(() => autoEnabledReasons: {} as Record, })), ); -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: { diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 8d8773483a94..5cc1703ca859 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -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. diff --git a/src/gateway/server-startup-update-canary.test.ts b/src/gateway/server-startup-update-canary.test.ts index 1a4c8a614064..88edf1f193e0 100644 --- a/src/gateway/server-startup-update-canary.test.ts +++ b/src/gateway/server-startup-update-canary.test.ts @@ -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(() =>