fix: wait for acp backend before startup reconcile

This commit is contained in:
Peter Steinberger
2026-04-26 02:21:07 +01:00
parent 00d2fbfda4
commit c43ce254e1
3 changed files with 99 additions and 15 deletions

View File

@@ -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.

View File

@@ -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<void>

View File

@@ -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> = T | Promise<T>;
@@ -61,6 +64,33 @@ async function hasGatewayStartupInternalHookListeners(): Promise<boolean> {
return hasInternalHookListeners("gateway", "startup");
}
async function waitForAcpRuntimeBackendReady(params: {
backendId?: string;
timeoutMs?: number;
pollMs?: number;
}): Promise<boolean> {
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 () => {