From bf03acef3932ad19e76ad7bb64a6edc5fe487d97 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 23:29:01 -0700 Subject: [PATCH] fix(gateway): release channel startup gate before handoff (#126555) --- .../server-startup-post-attach.test.ts | 44 ++++++++++++++++++- src/gateway/server-startup-post-attach.ts | 34 +++++++------- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index f166c69d20bf..732ef50188e5 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -1842,6 +1842,45 @@ describe("startGatewayPostAttachRuntime", () => { ); }); + it("releases startup account starts before awaiting channel handoff", async () => { + const events: string[] = []; + let releaseAccountStarts!: () => void; + const accountStartsReady = new Promise((resolve) => { + releaseAccountStarts = resolve; + }); + const startChannels = vi.fn(async () => { + events.push("channels-start"); + await accountStartsReady; + events.push("channels-end"); + }); + const onChannelsStarted = vi.fn(() => { + events.push("channels-released"); + releaseAccountStarts(); + }); + + const sidecars = startGatewaySidecars({ + cfg: { hooks: { internal: { enabled: false } } } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels, + onChannelsStarted, + log: { warn: vi.fn() }, + logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + logChannels: { info: vi.fn(), error: vi.fn() }, + }); + + await waitForGatewayTestState(() => { + expect(onChannelsStarted).toHaveBeenCalledOnce(); + }); + expect(events.slice(0, 2)).toEqual(["channels-start", "channels-released"]); + await sidecars; + + expect(events).toEqual(["channels-start", "channels-released", "channels-end"]); + expect(startChannels).toHaveBeenCalledOnce(); + expect(onChannelsStarted).toHaveBeenCalledOnce(); + }); + it("starts and reports plugin services after channel startup completes", async () => { await withEnvAsync( { OPENCLAW_SKIP_CHANNELS: undefined, OPENCLAW_SKIP_PROVIDERS: undefined }, @@ -1898,8 +1937,8 @@ describe("startGatewayPostAttachRuntime", () => { }); expect(events).toEqual([ "channels-start", - "channels-end", "channels-started", + "channels-end", "plugin-services", ]); expect(onPluginServices).toHaveBeenCalledTimes(1); @@ -2112,6 +2151,7 @@ describe("startGatewayPostAttachRuntime", () => { const trace = createStartupTraceRecorder(); const logChannels = { info: vi.fn(), error: vi.fn() }; const prewarmPrimaryModel = vi.fn(async () => {}); + const onChannelsStarted = vi.fn(); await withEnvAsync( { OPENCLAW_SKIP_CHANNELS: "1", OPENCLAW_SKIP_PROVIDERS: undefined }, @@ -2134,6 +2174,7 @@ describe("startGatewayPostAttachRuntime", () => { logChannels, startupTrace: trace.startupTrace, prewarmPrimaryModel, + onChannelsStarted, }); }, ); @@ -2149,6 +2190,7 @@ describe("startGatewayPostAttachRuntime", () => { expect(logChannels.info).toHaveBeenCalledWith( "skipping channel start (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)", ); + expect(onChannelsStarted).toHaveBeenCalledOnce(); }); it("records prepared runtime build grouping in the startup trace", async () => { diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d281d7c9c03a..cace17fe8195 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -675,26 +675,24 @@ export async function startGatewaySidecars(params: { await measureStartup(params.startupTrace, "sidecars.chat-metadata", async () => { await params.refreshChatMetadata?.(); }); + const shouldStartChannels = params.shouldStartChannels?.() !== false; await measureStartup(params.startupTrace, "sidecars.channels", async () => { - if (skipChannels) { - await measureStartup(params.startupTrace, "sidecars.channel-skip", () => - params.logChannels.info( - "skipping channel start (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)", - ), - ); - } else if (params.shouldStartChannels?.() !== false) { - try { - await measureStartup(params.startupTrace, "sidecars.channel-start", () => - params.startChannels(), - ); - } catch (err) { - params.logChannels.error(`channel startup failed: ${String(err)}`); - } - } + const channelStart = skipChannels + ? measureStartup(params.startupTrace, "sidecars.channel-skip", () => + params.logChannels.info( + "skipping channel start (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)", + ), + ) + : shouldStartChannels + ? measureStartup(params.startupTrace, "sidecars.channel-start", params.startChannels).catch( + (err: unknown) => params.logChannels.error(`channel startup failed: ${String(err)}`), + ) + : Promise.resolve(); + // Account tasks can depend on this generation gate, so release it after + // their handoff is prepared but before waiting for that handoff to settle. + const accountStartGateRelease = shouldStartChannels ? params.onChannelsStarted?.() : undefined; + await Promise.all([accountStartGateRelease, channelStart]); }); - if (params.shouldStartChannels?.() !== false) { - await params.onChannelsStarted?.(); - } const shouldStartPluginServices = params.shouldStartPluginServices?.() !== false; let pluginServicesOwner: PluginServicesHandle | null = null;