mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
fix: wait for acp backend before startup reconcile
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user