From c43ce254e1073f54e3c05ac76ac3fc4d61a71664 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:21:07 +0100 Subject: [PATCH] fix: wait for acp backend before startup reconcile --- CHANGELOG.md | 3 + .../server-startup-post-attach.test.ts | 48 ++++++++++++++ src/gateway/server-startup-post-attach.ts | 63 ++++++++++++++----- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af2d0724999..9753f8e49fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- ACP: wait for the configured runtime backend to become healthy before startup + identity reconciliation, avoiding transient acpx warnings during Gateway boot. + Fixes #40566. - CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index e0c271949dd..73deb453973 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -20,6 +20,7 @@ const hoisted = vi.hoisted(() => { const scheduleSubagentOrphanRecovery = vi.fn(); const shouldWakeFromRestartSentinel = vi.fn(() => false); const scheduleRestartSentinelWake = vi.fn(); + const getAcpRuntimeBackend = vi.fn<(id?: string) => unknown>(() => null); const reconcilePendingSessionIdentities = vi.fn(async () => ({ checked: 0, resolved: 0, @@ -41,6 +42,7 @@ const hoisted = vi.hoisted(() => { scheduleSubagentOrphanRecovery, shouldWakeFromRestartSentinel, scheduleRestartSentinelWake, + getAcpRuntimeBackend, reconcilePendingSessionIdentities, }; }); @@ -97,6 +99,10 @@ vi.mock("../acp/control-plane/manager.js", () => ({ })), })); +vi.mock("../acp/runtime/registry.js", () => ({ + getAcpRuntimeBackend: hoisted.getAcpRuntimeBackend, +})); + vi.mock("./server-restart-sentinel.js", () => ({ scheduleRestartSentinelWake: hoisted.scheduleRestartSentinelWake, shouldWakeFromRestartSentinel: hoisted.shouldWakeFromRestartSentinel, @@ -143,6 +149,8 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.scheduleSubagentOrphanRecovery.mockClear(); hoisted.shouldWakeFromRestartSentinel.mockReturnValue(false); hoisted.scheduleRestartSentinelWake.mockClear(); + hoisted.getAcpRuntimeBackend.mockReset(); + hoisted.getAcpRuntimeBackend.mockReturnValue(null); hoisted.reconcilePendingSessionIdentities.mockClear(); }); @@ -294,6 +302,46 @@ describe("startGatewayPostAttachRuntime", () => { } }); + it("waits for a healthy ACP runtime backend before startup identity reconcile", async () => { + let healthy = false; + hoisted.getAcpRuntimeBackend.mockImplementation((id?: string) => ({ + id: id ?? "acpx", + runtime: {}, + healthy: () => healthy, + })); + + await startGatewaySidecars({ + cfg: { + hooks: { internal: { enabled: false } }, + acp: { enabled: true, backend: "acpx" }, + } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels: vi.fn(async () => undefined), + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }); + + await vi.waitFor(() => { + expect(hoisted.getAcpRuntimeBackend).toHaveBeenCalledWith("acpx"); + }); + expect(hoisted.reconcilePendingSessionIdentities).not.toHaveBeenCalled(); + + healthy = true; + await vi.waitFor(() => { + expect(hoisted.reconcilePendingSessionIdentities).toHaveBeenCalledTimes(1); + }); + }); + it("passes typed gateway_start context with config, workspace dir, and a live cron getter", async () => { const runGatewayStart = vi.fn< (event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext) => Promise diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 81e2d67b92c..d20c464ac94 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -1,3 +1,4 @@ +import { setTimeout as sleep } from "node:timers/promises"; import type { CliDeps } from "../cli/deps.types.js"; import type { GatewayTailscaleMode } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -17,6 +18,8 @@ import { STARTUP_UNAVAILABLE_GATEWAY_METHODS } from "./server-startup-unavailabl import type { startGatewayTailscaleExposure } from "./server-tailscale.js"; const SESSION_LOCK_STALE_MS = 30 * 60 * 1000; +const ACP_BACKEND_READY_TIMEOUT_MS = 5_000; +const ACP_BACKEND_READY_POLL_MS = 50; type Awaitable = T | Promise; @@ -61,6 +64,33 @@ async function hasGatewayStartupInternalHookListeners(): Promise { return hasInternalHookListeners("gateway", "startup"); } +async function waitForAcpRuntimeBackendReady(params: { + backendId?: string; + timeoutMs?: number; + pollMs?: number; +}): Promise { + const { getAcpRuntimeBackend } = await import("../acp/runtime/registry.js"); + const timeoutMs = params.timeoutMs ?? ACP_BACKEND_READY_TIMEOUT_MS; + const pollMs = params.pollMs ?? ACP_BACKEND_READY_POLL_MS; + const deadline = Date.now() + timeoutMs; + + do { + const backend = getAcpRuntimeBackend(params.backendId); + if (backend) { + try { + if (!backend.healthy || backend.healthy()) { + return true; + } + } catch { + // Treat transient backend health probe errors like "not ready yet". + } + } + await sleep(pollMs, undefined, { ref: false }); + } while (Date.now() < deadline); + + return false; +} + async function prewarmConfiguredPrimaryModel(params: { cfg: OpenClawConfig; log: { warn: (msg: string) => void }; @@ -305,22 +335,25 @@ export async function startGatewaySidecars(params: { }); if (params.cfg.acp?.enabled) { - const [{ getAcpSessionManager }, { ACP_SESSION_IDENTITY_RENDERER_VERSION }] = await Promise.all( - [import("../acp/control-plane/manager.js"), import("../acp/runtime/session-identifiers.js")], - ); - void getAcpSessionManager() - .reconcilePendingSessionIdentities({ cfg: params.cfg }) - .then((result) => { - if (result.checked === 0) { - return; - } - params.log.warn( - `acp startup identity reconcile (renderer=${ACP_SESSION_IDENTITY_RENDERER_VERSION}): checked=${result.checked} resolved=${result.resolved} failed=${result.failed}`, - ); - }) - .catch((err) => { - params.log.warn(`acp startup identity reconcile failed: ${String(err)}`); + void (async () => { + await waitForAcpRuntimeBackendReady({ backendId: params.cfg.acp?.backend }); + const [{ getAcpSessionManager }, { ACP_SESSION_IDENTITY_RENDERER_VERSION }] = + await Promise.all([ + import("../acp/control-plane/manager.js"), + import("../acp/runtime/session-identifiers.js"), + ]); + const result = await getAcpSessionManager().reconcilePendingSessionIdentities({ + cfg: params.cfg, }); + if (result.checked === 0) { + return; + } + params.log.warn( + `acp startup identity reconcile (renderer=${ACP_SESSION_IDENTITY_RENDERER_VERSION}): checked=${result.checked} resolved=${result.resolved} failed=${result.failed}`, + ); + })().catch((err) => { + params.log.warn(`acp startup identity reconcile failed: ${String(err)}`); + }); } await measureStartup(params.startupTrace, "sidecars.memory", async () => {