diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c6adf328e..c2e2f7521ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,9 @@ Docs: https://docs.openclaw.ai - Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras. - Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman. - Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan. +- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob. +- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob. +- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT. ### Breaking diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 523f7c54c36..8388438f37d 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -92,6 +92,7 @@ import { resolveDiscordPresenceUpdate } from "./presence.js"; import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { resolveDiscordRestFetch } from "./rest-fetch.js"; +import { formatDiscordStartupStatusMessage } from "./startup-status.js"; import type { DiscordMonitorStatusSink } from "./status.js"; import { createNoopThreadBindingManager, @@ -972,7 +973,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const botIdentity = botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? ""); - runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`); + runtime.log?.( + formatDiscordStartupStatusMessage({ + gatewayReady: lifecycleGateway?.isConnected === true, + botIdentity: botIdentity || undefined, + }), + ); if (lifecycleGateway?.isConnected) { opts.setStatus?.(createConnectedChannelStatusPatch()); } diff --git a/extensions/discord/src/monitor/startup-status.test.ts b/extensions/discord/src/monitor/startup-status.test.ts new file mode 100644 index 00000000000..47cc84202d6 --- /dev/null +++ b/extensions/discord/src/monitor/startup-status.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatDiscordStartupStatusMessage } from "./startup-status.js"; + +describe("formatDiscordStartupStatusMessage", () => { + it("reports logged-in status only after the gateway is ready", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: true, + botIdentity: "bot-1 (Molty)", + }), + ).toBe("logged in to discord as bot-1 (Molty)"); + }); + + it("reports client initialization while gateway readiness is still pending", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: false, + botIdentity: "bot-1 (Molty)", + }), + ).toBe("discord client initialized as bot-1 (Molty); awaiting gateway readiness"); + }); + + it("handles missing identity without awkward punctuation", () => { + expect( + formatDiscordStartupStatusMessage({ + gatewayReady: false, + }), + ).toBe("discord client initialized; awaiting gateway readiness"); + }); +}); diff --git a/extensions/discord/src/monitor/startup-status.ts b/extensions/discord/src/monitor/startup-status.ts new file mode 100644 index 00000000000..94f311912b8 --- /dev/null +++ b/extensions/discord/src/monitor/startup-status.ts @@ -0,0 +1,10 @@ +export function formatDiscordStartupStatusMessage(params: { + gatewayReady: boolean; + botIdentity?: string; +}): string { + const identitySuffix = params.botIdentity ? ` as ${params.botIdentity}` : ""; + if (params.gatewayReady) { + return `logged in to discord${identitySuffix}`; + } + return `discord client initialized${identitySuffix}; awaiting gateway readiness`; +} diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts index 842374155b3..fefbe083347 100644 --- a/extensions/feishu/src/thread-bindings.ts +++ b/extensions/feishu/src/thread-bindings.ts @@ -51,16 +51,18 @@ type FeishuThreadBindingsState = { }; const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState"); -const state = resolveGlobalSingleton( - FEISHU_THREAD_BINDINGS_STATE_KEY, - () => ({ - managersByAccountId: new Map(), - bindingsByAccountConversation: new Map(), - }), -); +let state: FeishuThreadBindingsState | undefined; -const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId; -const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation; +function getState(): FeishuThreadBindingsState { + state ??= resolveGlobalSingleton( + FEISHU_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + }), + ); + return state; +} function resolveBindingKey(params: { accountId: string; conversationId: string }): string { return `${params.accountId}:${params.conversationId}`; @@ -119,7 +121,7 @@ export function createFeishuThreadBindingManager(params: { cfg: OpenClawConfig; }): FeishuThreadBindingManager { const accountId = normalizeAccountId(params.accountId); - const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existing = getState().managersByAccountId.get(accountId); if (existing) { return existing; } @@ -138,9 +140,11 @@ export function createFeishuThreadBindingManager(params: { const manager: FeishuThreadBindingManager = { accountId, getByConversationId: (conversationId) => - BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })), + getState().bindingsByAccountConversation.get( + resolveBindingKey({ accountId, conversationId }), + ), listBySessionKey: (targetSessionKey) => - [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + [...getState().bindingsByAccountConversation.values()].filter( (record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey, ), bindConversation: ({ @@ -184,7 +188,7 @@ export function createFeishuThreadBindingManager(params: { boundAt: now, lastActivityAt: now, }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set( + getState().bindingsByAccountConversation.set( resolveBindingKey({ accountId, conversationId: normalizedConversationId }), record, ); @@ -192,30 +196,30 @@ export function createFeishuThreadBindingManager(params: { }, touchConversation: (conversationId, at = Date.now()) => { const key = resolveBindingKey({ accountId, conversationId }); - const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existingRecord = getState().bindingsByAccountConversation.get(key); if (!existingRecord) { return null; } const updated = { ...existingRecord, lastActivityAt: at }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated); + getState().bindingsByAccountConversation.set(key, updated); return updated; }, unbindConversation: (conversationId) => { const key = resolveBindingKey({ accountId, conversationId }); - const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existingRecord = getState().bindingsByAccountConversation.get(key); if (!existingRecord) { return null; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getState().bindingsByAccountConversation.delete(key); return existingRecord; }, unbindBySessionKey: (targetSessionKey) => { const removed: FeishuThreadBindingRecord[] = []; - for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) { + for (const record of [...getState().bindingsByAccountConversation.values()]) { if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) { continue; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete( + getState().bindingsByAccountConversation.delete( resolveBindingKey({ accountId, conversationId: record.conversationId }), ); removed.push(record); @@ -223,12 +227,12 @@ export function createFeishuThreadBindingManager(params: { return removed; }, stop: () => { - for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) { + for (const key of [...getState().bindingsByAccountConversation.keys()]) { if (key.startsWith(`${accountId}:`)) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getState().bindingsByAccountConversation.delete(key); } } - MANAGERS_BY_ACCOUNT_ID.delete(accountId); + getState().managersByAccountId.delete(accountId); unregisterSessionBindingAdapter({ channel: "feishu", accountId }); }, }; @@ -290,22 +294,22 @@ export function createFeishuThreadBindingManager(params: { }, }); - MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); + getState().managersByAccountId.set(accountId, manager); return manager; } export function getFeishuThreadBindingManager( accountId?: string, ): FeishuThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; + return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null; } export const __testing = { resetFeishuThreadBindingsForTests() { - for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + for (const manager of getState().managersByAccountId.values()) { manager.stop(); } - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); + getState().managersByAccountId.clear(); + getState().bindingsByAccountConversation.clear(); }, }; diff --git a/extensions/slack/src/sent-thread-cache.ts b/extensions/slack/src/sent-thread-cache.ts index f155571a1b4..332a7d65496 100644 --- a/extensions/slack/src/sent-thread-cache.ts +++ b/extensions/slack/src/sent-thread-cache.ts @@ -15,7 +15,12 @@ const MAX_ENTRIES = 5000; */ const SLACK_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.slackThreadParticipation"); -const threadParticipation = resolveGlobalMap(SLACK_THREAD_PARTICIPATION_KEY); +let threadParticipation: Map | undefined; + +function getThreadParticipation(): Map { + threadParticipation ??= resolveGlobalMap(SLACK_THREAD_PARTICIPATION_KEY); + return threadParticipation; +} function makeKey(accountId: string, channelId: string, threadTs: string): string { return `${accountId}:${channelId}:${threadTs}`; @@ -23,17 +28,17 @@ function makeKey(accountId: string, channelId: string, threadTs: string): string function evictExpired(): void { const now = Date.now(); - for (const [key, timestamp] of threadParticipation) { + for (const [key, timestamp] of getThreadParticipation()) { if (now - timestamp > TTL_MS) { - threadParticipation.delete(key); + getThreadParticipation().delete(key); } } } function evictOldest(): void { - const oldest = threadParticipation.keys().next().value; + const oldest = getThreadParticipation().keys().next().value; if (oldest) { - threadParticipation.delete(oldest); + getThreadParticipation().delete(oldest); } } @@ -45,6 +50,7 @@ export function recordSlackThreadParticipation( if (!accountId || !channelId || !threadTs) { return; } + const threadParticipation = getThreadParticipation(); if (threadParticipation.size >= MAX_ENTRIES) { evictExpired(); } @@ -63,6 +69,7 @@ export function hasSlackThreadParticipation( return false; } const key = makeKey(accountId, channelId, threadTs); + const threadParticipation = getThreadParticipation(); const timestamp = threadParticipation.get(key); if (timestamp == null) { return false; @@ -75,5 +82,5 @@ export function hasSlackThreadParticipation( } export function clearSlackThreadParticipationCache(): void { - threadParticipation.clear(); + getThreadParticipation().clear(); } diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index ae943f169d3..7b10e52312a 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -28,11 +28,17 @@ type TelegramSendMessageDraft = ( */ const TELEGRAM_DRAFT_STREAM_STATE_KEY = Symbol.for("openclaw.telegramDraftStreamState"); -const draftStreamState = resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ - nextDraftId: 0, -})); +let draftStreamState: { nextDraftId: number } | undefined; + +function getDraftStreamState(): { nextDraftId: number } { + draftStreamState ??= resolveGlobalSingleton(TELEGRAM_DRAFT_STREAM_STATE_KEY, () => ({ + nextDraftId: 0, + })); + return draftStreamState; +} function allocateTelegramDraftId(): number { + const draftStreamState = getDraftStreamState(); draftStreamState.nextDraftId = draftStreamState.nextDraftId >= TELEGRAM_DRAFT_ID_MAX ? 1 : draftStreamState.nextDraftId + 1; return draftStreamState.nextDraftId; @@ -454,6 +460,6 @@ export function createTelegramDraftStream(params: { export const __testing = { resetTelegramDraftStreamForTests() { - draftStreamState.nextDraftId = 0; + getDraftStreamState().nextDraftId = 0; }, }; diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 4d14f179b2f..591e4c35a84 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -103,17 +103,34 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); const AUTO_LINKED_ANCHOR_PATTERN = /]*>\1<\/a>/gi; -const FILE_REFERENCE_PATTERN = new RegExp( - `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`, - "gi", -); -const ORPHANED_TLD_PATTERN = new RegExp( - `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`, - "g", -); const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi; +let fileReferencePattern: RegExp | undefined; +let orphanedTldPattern: RegExp | undefined; + +function getFileReferencePattern(): RegExp { + if (fileReferencePattern) { + return fileReferencePattern; + } + const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); + fileReferencePattern = new RegExp( + `(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`, + "gi", + ); + return fileReferencePattern; +} + +function getOrphanedTldPattern(): RegExp { + if (orphanedTldPattern) { + return orphanedTldPattern; + } + const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|"); + orphanedTldPattern = new RegExp( + `([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`, + "g", + ); + return orphanedTldPattern; +} function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string { if (filename.startsWith("//")) { @@ -134,8 +151,8 @@ function wrapSegmentFileRefs( if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) { return text; } - const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef); - return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) => + const wrappedStandalone = text.replace(getFileReferencePattern(), wrapStandaloneFileRef); + return wrappedStandalone.replace(getOrphanedTldPattern(), (match, prefix: string, tld: string) => prefix === ">" ? match : `${prefix}${escapeHtml(tld)}`, ); } diff --git a/extensions/telegram/src/sent-message-cache.ts b/extensions/telegram/src/sent-message-cache.ts index bb48bce3c0f..f10f56b68f7 100644 --- a/extensions/telegram/src/sent-message-cache.ts +++ b/extensions/telegram/src/sent-message-cache.ts @@ -17,7 +17,12 @@ type CacheEntry = { */ const TELEGRAM_SENT_MESSAGES_KEY = Symbol.for("openclaw.telegramSentMessages"); -const sentMessages = resolveGlobalMap(TELEGRAM_SENT_MESSAGES_KEY); +let sentMessages: Map | undefined; + +function getSentMessages(): Map { + sentMessages ??= resolveGlobalMap(TELEGRAM_SENT_MESSAGES_KEY); + return sentMessages; +} function getChatKey(chatId: number | string): string { return String(chatId); @@ -37,6 +42,7 @@ function cleanupExpired(entry: CacheEntry): void { */ export function recordSentMessage(chatId: number | string, messageId: number): void { const key = getChatKey(chatId); + const sentMessages = getSentMessages(); let entry = sentMessages.get(key); if (!entry) { entry = { timestamps: new Map() }; @@ -54,7 +60,7 @@ export function recordSentMessage(chatId: number | string, messageId: number): v */ export function wasSentByBot(chatId: number | string, messageId: number): boolean { const key = getChatKey(chatId); - const entry = sentMessages.get(key); + const entry = getSentMessages().get(key); if (!entry) { return false; } @@ -67,5 +73,5 @@ export function wasSentByBot(chatId: number | string, messageId: number): boolea * Clear all cached entries (for testing). */ export function clearSentMessageCache(): void { - sentMessages.clear(); + getSentMessages().clear(); } diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index be734804efb..d4d1c3fbab4 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -77,17 +77,19 @@ type TelegramThreadBindingsState = { */ const TELEGRAM_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.telegramThreadBindingsState"); -const threadBindingsState = resolveGlobalSingleton( - TELEGRAM_THREAD_BINDINGS_STATE_KEY, - () => ({ - managersByAccountId: new Map(), - bindingsByAccountConversation: new Map(), - persistQueueByAccountId: new Map>(), - }), -); -const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId; -const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation; -const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId; +let threadBindingsState: TelegramThreadBindingsState | undefined; + +function getThreadBindingsState(): TelegramThreadBindingsState { + threadBindingsState ??= resolveGlobalSingleton( + TELEGRAM_THREAD_BINDINGS_STATE_KEY, + () => ({ + managersByAccountId: new Map(), + bindingsByAccountConversation: new Map(), + persistQueueByAccountId: new Map>(), + }), + ); + return threadBindingsState; +} function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { @@ -168,7 +170,7 @@ function fromSessionBindingInput(params: { }): TelegramThreadBindingRecord { const now = Date.now(); const metadata = params.input.metadata ?? {}; - const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get( + const existing = getThreadBindingsState().bindingsByAccountConversation.get( resolveBindingKey({ accountId: params.accountId, conversationId: params.input.conversationId, @@ -310,7 +312,7 @@ async function persistBindingsToDisk(params: { version: STORE_VERSION, bindings: params.bindings ?? - [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + [...getThreadBindingsState().bindingsByAccountConversation.values()].filter( (entry) => entry.accountId === params.accountId, ), }; @@ -322,7 +324,7 @@ async function persistBindingsToDisk(params: { } function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] { - return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + return [...getThreadBindingsState().bindingsByAccountConversation.values()].filter( (entry) => entry.accountId === accountId, ); } @@ -335,16 +337,17 @@ function enqueuePersistBindings(params: { if (!params.persist) { return Promise.resolve(); } - const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve(); + const previous = + getThreadBindingsState().persistQueueByAccountId.get(params.accountId) ?? Promise.resolve(); const next = previous .catch(() => undefined) .then(async () => { await persistBindingsToDisk(params); }); - PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next); + getThreadBindingsState().persistQueueByAccountId.set(params.accountId, next); void next.finally(() => { - if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) { - PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId); + if (getThreadBindingsState().persistQueueByAccountId.get(params.accountId) === next) { + getThreadBindingsState().persistQueueByAccountId.delete(params.accountId); } }); return next; @@ -412,7 +415,7 @@ export function createTelegramThreadBindingManager( } = {}, ): TelegramThreadBindingManager { const accountId = normalizeAccountId(params.accountId); - const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existing = getThreadBindingsState().managersByAccountId.get(accountId); if (existing) { return existing; } @@ -430,7 +433,7 @@ export function createTelegramThreadBindingManager( accountId, conversationId: entry.conversationId, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, { + getThreadBindingsState().bindingsByAccountConversation.set(key, { ...entry, accountId, }); @@ -448,7 +451,7 @@ export function createTelegramThreadBindingManager( if (!conversationId) { return undefined; } - return BINDINGS_BY_ACCOUNT_CONVERSATION.get( + return getThreadBindingsState().bindingsByAccountConversation.get( resolveBindingKey({ accountId, conversationId, @@ -471,7 +474,7 @@ export function createTelegramThreadBindingManager( return null; } const key = resolveBindingKey({ accountId, conversationId }); - const existing = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); + const existing = getThreadBindingsState().bindingsByAccountConversation.get(key); if (!existing) { return null; } @@ -479,7 +482,7 @@ export function createTelegramThreadBindingManager( ...existing, lastActivityAt: normalizeTimestampMs(at ?? Date.now()), }; - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord); + getThreadBindingsState().bindingsByAccountConversation.set(key, nextRecord); persistBindingsSafely({ accountId, persist: manager.shouldPersistMutations(), @@ -494,11 +497,11 @@ export function createTelegramThreadBindingManager( return null; } const key = resolveBindingKey({ accountId, conversationId }); - const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + const removed = getThreadBindingsState().bindingsByAccountConversation.get(key) ?? null; if (!removed) { return null; } - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getThreadBindingsState().bindingsByAccountConversation.delete(key); persistBindingsSafely({ accountId, persist: manager.shouldPersistMutations(), @@ -521,7 +524,7 @@ export function createTelegramThreadBindingManager( accountId, conversationId: entry.conversationId, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + getThreadBindingsState().bindingsByAccountConversation.delete(key); removed.push(entry); } if (removed.length > 0) { @@ -540,9 +543,9 @@ export function createTelegramThreadBindingManager( sweepTimer = null; } unregisterSessionBindingAdapter({ channel: "telegram", accountId }); - const existingManager = MANAGERS_BY_ACCOUNT_ID.get(accountId); + const existingManager = getThreadBindingsState().managersByAccountId.get(accountId); if (existingManager === manager) { - MANAGERS_BY_ACCOUNT_ID.delete(accountId); + getThreadBindingsState().managersByAccountId.delete(accountId); } }, }; @@ -574,7 +577,7 @@ export function createTelegramThreadBindingManager( metadata: input.metadata, }, }); - BINDINGS_BY_ACCOUNT_CONVERSATION.set( + getThreadBindingsState().bindingsByAccountConversation.set( resolveBindingKey({ accountId, conversationId }), record, ); @@ -714,14 +717,14 @@ export function createTelegramThreadBindingManager( sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); + getThreadBindingsState().managersByAccountId.set(accountId, manager); return manager; } export function getTelegramThreadBindingManager( accountId?: string, ): TelegramThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; + return getThreadBindingsState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null; } function updateTelegramBindingsBySessionKey(params: { @@ -741,7 +744,7 @@ function updateTelegramBindingsBySessionKey(params: { conversationId: entry.conversationId, }); const next = params.update(entry, now); - BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, next); + getThreadBindingsState().bindingsByAccountConversation.set(key, next); updated.push(next); } if (updated.length > 0) { @@ -799,12 +802,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: { export const __testing = { async resetTelegramThreadBindingsForTests() { - for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + for (const manager of getThreadBindingsState().managersByAccountId.values()) { manager.stop(); } - await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values()); - PERSIST_QUEUE_BY_ACCOUNT_ID.clear(); - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); + await Promise.allSettled(getThreadBindingsState().persistQueueByAccountId.values()); + getThreadBindingsState().persistQueueByAccountId.clear(); + getThreadBindingsState().managersByAccountId.clear(); + getThreadBindingsState().bindingsByAccountConversation.clear(); }, }; diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 35fc741db58..47460c5efa7 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -125,6 +125,27 @@ describe("formatAssistantErrorText", () => { const msg = makeAssistantError("request ended without sending any chunks"); expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); }); + + it("returns a connection-refused message for ECONNREFUSED failures", () => { + const msg = makeAssistantError("connect ECONNREFUSED 127.0.0.1:443 during upstream call"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: connection refused by the provider endpoint.", + ); + }); + + it("returns a DNS-specific message for provider lookup failures", () => { + const msg = makeAssistantError("dial tcp: lookup api.example.com: no such host (ENOTFOUND)"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: DNS lookup for the provider endpoint failed.", + ); + }); + + it("returns an interrupted-connection message for socket hang ups", () => { + const msg = makeAssistantError("socket hang up"); + expect(formatAssistantErrorText(msg)).toBe( + "LLM request failed: network connection was interrupted.", + ); + }); }); describe("formatRawAssistantErrorForUi", () => { diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index 2808d320cc5..82fe67c47f4 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -88,6 +88,14 @@ describe("sanitizeUserFacingText", () => { ); }); + it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => { + expect( + sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", { + errorContext: true, + }), + ).toBe("LLM request failed: connection refused by the provider endpoint."); + }); + it.each([ { input: "Hello there!\n\nHello there!", diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 7719ecb41a0..bb3d6b78206 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -65,6 +65,57 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined { return undefined; } +function formatTransportErrorCopy(raw: string): string | undefined { + if (!raw) { + return undefined; + } + const lower = raw.toLowerCase(); + + if ( + /\beconnrefused\b/i.test(raw) || + lower.includes("connection refused") || + lower.includes("actively refused") + ) { + return "LLM request failed: connection refused by the provider endpoint."; + } + + if ( + /\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) || + lower.includes("socket hang up") || + lower.includes("connection reset") || + lower.includes("connection aborted") + ) { + return "LLM request failed: network connection was interrupted."; + } + + if ( + /\benotfound\b|\beai_again\b/i.test(raw) || + lower.includes("getaddrinfo") || + lower.includes("no such host") || + lower.includes("dns") + ) { + return "LLM request failed: DNS lookup for the provider endpoint failed."; + } + + if ( + /\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) || + lower.includes("network is unreachable") || + lower.includes("host is unreachable") + ) { + return "LLM request failed: the provider endpoint is unreachable from this host."; + } + + if ( + lower.includes("fetch failed") || + lower.includes("connection error") || + lower.includes("network request failed") + ) { + return "LLM request failed: network connection error."; + } + + return undefined; +} + function isReasoningConstraintErrorMessage(raw: string): boolean { if (!raw) { return false; @@ -566,6 +617,11 @@ export function formatAssistantErrorText( return transientCopy; } + const transportCopy = formatTransportErrorCopy(raw); + if (transportCopy) { + return transportCopy; + } + if (isTimeoutErrorMessage(raw)) { return "LLM request timed out."; } @@ -626,6 +682,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo if (prefixedCopy) { return prefixedCopy; } + const transportCopy = formatTransportErrorCopy(trimmed); + if (transportCopy) { + return transportCopy; + } if (isTimeoutErrorMessage(trimmed)) { return "LLM request timed out."; } diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 911b124113a..9ffd7a53a72 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -58,14 +58,16 @@ describe("handleAgentEnd", () => { expect(warn.mock.calls[0]?.[1]).toMatchObject({ event: "embedded_run_agent_end", runId: "run-1", - error: "connection refused", + error: "LLM request failed: connection refused by the provider endpoint.", rawErrorPreview: "connection refused", + consoleMessage: + "embedded run agent end: runId=run-1 isError=true model=unknown provider=unknown error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused", }); expect(onAgentEvent).toHaveBeenCalledWith({ stream: "lifecycle", data: { phase: "error", - error: "connection refused", + error: "LLM request failed: connection refused by the provider endpoint.", }, }); }); @@ -92,7 +94,7 @@ describe("handleAgentEnd", () => { failoverReason: "overloaded", providerErrorType: "overloaded_error", consoleMessage: - "embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment.", + 'embedded run agent end: runId=run-1 isError=true model=claude-test provider=anthropic error=The AI service is temporarily overloaded. Please try again in a moment. rawError={"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}', }); }); @@ -112,7 +114,7 @@ describe("handleAgentEnd", () => { const meta = warn.mock.calls[0]?.[1]; expect(meta).toMatchObject({ consoleMessage: - "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=connection refused", + "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused", }); expect(meta?.consoleMessage).not.toContain("\n"); expect(meta?.consoleMessage).not.toContain("\r"); diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 973de1ebefc..7edc299460c 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -50,6 +50,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-"; const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown"; const safeProvider = sanitizeForConsole(lastAssistant.provider) ?? "unknown"; + const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview); + const rawErrorConsoleSuffix = safeRawErrorPreview ? ` rawError=${safeRawErrorPreview}` : ""; ctx.log.warn("embedded run agent end", { event: "embedded_run_agent_end", tags: ["error_handling", "lifecycle", "agent_end", "assistant_error"], @@ -60,7 +62,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { model: lastAssistant.model, provider: lastAssistant.provider, ...observedError, - consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}`, + consoleMessage: `embedded run agent end: runId=${safeRunId} isError=true model=${safeModel} provider=${safeProvider} error=${safeErrorText}${rawErrorConsoleSuffix}`, }); emitAgentEvent({ runId: ctx.params.runId, diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 46212816410..3762afc6d8a 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -567,6 +567,47 @@ describe("gateway-status command", () => { expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); }); + it("passes the full caller timeout through to local loopback probes", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + auth: { mode: "token", token: "ltok" }, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + timeoutMs: 15_000, + }), + ); + }); + + it("keeps inactive local loopback probes on the short timeout in remote mode", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "remote", + auth: { mode: "token", token: "ltok" }, + remote: {}, + }, + } as never); + + await runGatewayStatus(runtime, { timeout: "15000", json: true }); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + timeoutMs: 800, + }), + ); + }); + it("skips invalid ssh-auto discovery targets", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index ecdeeaa9570..c338d7fe55b 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -176,7 +176,7 @@ export async function gatewayStatusCommand( token: authResolution.token, password: authResolution.password, }; - const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); + const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target); const probe = await probeGateway({ url: target.url, auth, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index e0c1ecee763..525b99db98c 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -6,6 +6,7 @@ import { isScopeLimitedProbeFailure, renderProbeSummaryLine, resolveAuthForTarget, + resolveProbeBudgetMs, } from "./helpers.js"; describe("extractConfigSummary", () => { @@ -273,3 +274,21 @@ describe("probe reachability classification", () => { expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); }); }); + +describe("resolveProbeBudgetMs", () => { + it("lets active local loopback probes use the full caller budget", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000); + expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000); + }); + + it("keeps inactive local loopback probes on the short cap", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800); + expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500); + }); + + it("keeps non-local probe caps unchanged", () => { + expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500); + expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 5f1a5e2f5ee..aec1a6a794d 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -116,14 +116,21 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew return targets; } -export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { - if (kind === "localLoopback") { - return Math.min(800, overallMs); +export function resolveProbeBudgetMs( + overallMs: number, + target: Pick, +): number { + switch (target.kind) { + case "localLoopback": + // Active loopback probes should honor the caller budget because local shells/containers + // can legitimately take longer to connect. Inactive loopback probes stay bounded so + // remote-mode status checks do not stall on an expected local miss. + return target.active ? overallMs : Math.min(800, overallMs); + case "sshTunnel": + return Math.min(2_000, overallMs); + default: + return Math.min(1_500, overallMs); } - if (kind === "sshTunnel") { - return Math.min(2000, overallMs); - } - return Math.min(1500, overallMs); } export function sanitizeSshTarget(value: unknown): string | null { diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 4a2374e17cb..01c69be5199 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -40,9 +40,15 @@ vi.mock("./client.js", () => ({ GatewayClient: MockGatewayClient, })); -const { probeGateway } = await import("./probe.js"); +const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js"); describe("probeGateway", () => { + it("clamps probe timeout to timer-safe bounds", () => { + expect(clampProbeTimeoutMs(1)).toBe(250); + expect(clampProbeTimeoutMs(2_000)).toBe(2_000); + expect(clampProbeTimeoutMs(3_000_000_000)).toBe(2_147_483_647); + }); + it("connects with operator.read scope", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index bbd36639b78..b285c395c3d 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -29,6 +29,13 @@ export type GatewayProbeResult = { configSnapshot: unknown; }; +export const MIN_PROBE_TIMEOUT_MS = 250; +export const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export function clampProbeTimeoutMs(timeoutMs: number): number { + return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs)); +} + export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; @@ -144,21 +151,18 @@ export async function probeGateway(opts: { }, }); - const timer = setTimeout( - () => { - settle({ - ok: false, - connectLatencyMs, - error: connectError ? `connect failed: ${connectError}` : "timeout", - close, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - }, - Math.max(250, opts.timeoutMs), - ); + const timer = setTimeout(() => { + settle({ + ok: false, + connectLatencyMs, + error: connectError ? `connect failed: ${connectError}` : "timeout", + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + }, clampProbeTimeoutMs(opts.timeoutMs)); client.start(); }); diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 0894fe0d5b5..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -31,14 +31,6 @@ "resolvedPath": "extensions/imessage/runtime-api.js", "reason": "imports extension-owned file from src/plugins" }, - { - "file": "src/plugins/runtime/runtime-matrix.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/matrix/runtime-api.js", - "resolvedPath": "extensions/matrix/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", "line": 10,