From 8ed52c146337dd56823db98cea5d8ee0758cf0d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 02:36:38 +0100 Subject: [PATCH] fix: bound configured acp binding readiness --- CHANGELOG.md | 3 ++ src/channels/plugins/binding-routing.test.ts | 43 +++++++++++++++++++- src/channels/plugins/binding-routing.ts | 34 +++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d4609015e..8ff87e975a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,9 @@ Docs: https://docs.openclaw.ai - ACP: wait for the configured runtime backend to become healthy before startup identity reconciliation, avoiding transient acpx warnings during Gateway boot. Fixes #40566. +- Channels/ACP bindings: time out configured binding readiness checks instead of + letting Discord preflight hang forever when an ACP target never settles. Fixes + #68776. - Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks diff --git a/src/channels/plugins/binding-routing.test.ts b/src/channels/plugins/binding-routing.test.ts index b2bba2cb120..1428832ca57 100644 --- a/src/channels/plugins/binding-routing.test.ts +++ b/src/channels/plugins/binding-routing.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { __testing, registerSessionBindingAdapter, @@ -6,7 +6,14 @@ import { type SessionBindingRecord, } from "../../infra/outbound/session-binding-service.js"; import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; -import { resolveRuntimeConversationBindingRoute } from "./binding-routing.js"; +import { + ensureConfiguredBindingRouteReady, + resolveRuntimeConversationBindingRoute, +} from "./binding-routing.js"; +import { + registerStatefulBindingTargetDriver, + unregisterStatefulBindingTargetDriver, +} from "./stateful-target-drivers.js"; function createRoute(): ResolvedAgentRoute { return { @@ -112,3 +119,35 @@ describe("runtime conversation binding route", () => { expect(result.route).toBe(route); }); }); + +describe("ensureConfiguredBindingRouteReady", () => { + afterEach(() => { + vi.useRealTimers(); + unregisterStatefulBindingTargetDriver("slow"); + }); + + it("returns a bounded failure when target readiness never settles", async () => { + vi.useFakeTimers(); + registerStatefulBindingTargetDriver({ + id: "slow", + ensureReady: async () => await new Promise(() => {}), + ensureSession: async () => ({ + ok: false, + sessionKey: "agent:slow:binding", + error: "not used", + }), + }); + + const resultPromise = ensureConfiguredBindingRouteReady({ + cfg: {} as never, + bindingResolution: { statefulTarget: { driverId: "slow" } } as never, + }); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(resultPromise).resolves.toEqual({ + ok: false, + error: "Configured binding route ready check timed out", + }); + }); +}); diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts index cf06eb4668f..bcf93d95b9c 100644 --- a/src/channels/plugins/binding-routing.ts +++ b/src/channels/plugins/binding-routing.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { logVerbose } from "../../globals.js"; import { getSessionBindingService, type ConversationRef, @@ -11,6 +12,8 @@ import { resolveConfiguredBinding } from "./binding-registry.js"; import { ensureConfiguredBindingTargetReady } from "./binding-targets.js"; import type { ConfiguredBindingResolution } from "./binding-types.js"; +const CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS = 30_000; + export type ConfiguredBindingRouteResult = { bindingResolution: ConfiguredBindingResolution | null; route: ResolvedAgentRoute; @@ -152,5 +155,34 @@ export async function ensureConfiguredBindingRouteReady(params: { cfg: OpenClawConfig; bindingResolution: ConfiguredBindingResolution | null; }): Promise<{ ok: true } | { ok: false; error: string }> { - return await ensureConfiguredBindingTargetReady(params); + const readyPromise = ensureConfiguredBindingTargetReady(params); + let timer: ReturnType | undefined; + const timeoutToken = Symbol("configured-binding-route-ready-timeout"); + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutToken), CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS); + timer.unref?.(); + }); + + try { + const result = await Promise.race([readyPromise, timeoutPromise]); + if (result !== timeoutToken) { + return result; + } + logVerbose( + `configured binding route ready check timed out after ${ + CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS / 1_000 + }s`, + ); + readyPromise.then( + (lateResult) => + logVerbose( + `configured binding route ready check settled after timeout (ok=${lateResult.ok})`, + ), + (err) => + logVerbose(`configured binding route ready check rejected after timeout: ${String(err)}`), + ); + return { ok: false, error: "Configured binding route ready check timed out" }; + } finally { + clearTimeout(timer); + } }