diff --git a/src/main/i18n.ts b/src/main/i18n.ts index 082f538..3ef5e0f 100644 --- a/src/main/i18n.ts +++ b/src/main/i18n.ts @@ -23,6 +23,9 @@ const translations: Record> = { // Session titles (created by ChannelSessionSync) cronSessionPrefix: '定时', + + // Timeout hint + taskTimedOut: '[任务超时] 任务因超过最大允许时长而被自动停止。你可以继续对话以从中断处继续。', }, en: { // Tray menu @@ -33,6 +36,9 @@ const translations: Record> = { // Session titles cronSessionPrefix: 'Cron', + + // Timeout hint + taskTimedOut: '[Task timed out] The task was automatically stopped because it exceeded the maximum allowed duration. You can continue the conversation to pick up where it left off.', }, }; diff --git a/src/main/libs/agentEngine/openclawRuntimeAdapter.ts b/src/main/libs/agentEngine/openclawRuntimeAdapter.ts index b483331..db7495a 100644 --- a/src/main/libs/agentEngine/openclawRuntimeAdapter.ts +++ b/src/main/libs/agentEngine/openclawRuntimeAdapter.ts @@ -27,6 +27,8 @@ import { extractGatewayMessageText, } from '../openclawHistory'; import { buildOpenClawLocalTimeContextPrompt } from '../openclawLocalTimeContextPrompt'; +import { OPENCLAW_AGENT_TIMEOUT_SECONDS } from '../openclawConfigSync'; +import { t } from '../../i18n'; const OPENCLAW_GATEWAY_TOOL_EVENTS_CAP = 'tool-events'; const BRIDGE_MAX_MESSAGES = 20; @@ -116,6 +118,8 @@ type ActiveTurn = { bufferedChatPayloads: BufferedChatEvent[]; /** Agent events buffered while pendingUserSync is true. */ bufferedAgentPayloads: BufferedAgentEvent[]; + /** Client-side timeout watchdog timer (fallback for missing gateway abort events). */ + timeoutTimer?: ReturnType; }; type BufferedChatEvent = { @@ -554,6 +558,14 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim private pendingMessageUpdateTimer: Map> = new Map(); private static readonly MESSAGE_UPDATE_THROTTLE_MS = 100; + /** + * Server-side agent timeout in seconds (mirrors agents.defaults.timeoutSeconds in openclaw config). + * Used to set a client-side fallback timer that fires slightly after the server timeout, + * so LobsterAI can recover even when the gateway fails to deliver the abort event. + */ + agentTimeoutSeconds = OPENCLAW_AGENT_TIMEOUT_SECONDS; + private static readonly CLIENT_TIMEOUT_GRACE_MS = 30_000; + constructor(store: CoworkStore, engineManager: OpenClawEngineManager) { super(); this.store = store; @@ -987,6 +999,13 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim }); this.sessionIdByRunId.set(runId, sessionId); + // Start client-side timeout watchdog. + // OpenClaw gateway has a known issue where embedded run timeouts may not + // produce a WS abort/final event (the subscription is torn down before the + // lifecycle event fires). This timer fires slightly after the server-side + // timeout to recover the UI from a stuck "running" state. + this.startTurnTimeoutWatchdog(sessionId); + const client = this.requireGatewayClient(); try { const attachments = options.imageAttachments?.length @@ -2285,6 +2304,14 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim private handleChatAborted(sessionId: string, turn: ActiveTurn): void { this.store.updateSession(sessionId, { status: 'idle' }); if (!turn.stopRequested) { + // The run was aborted without user request — most likely a timeout. + // Add a visible hint so the user knows the task was interrupted. + const hintMessage = this.store.addMessage(sessionId, { + type: 'assistant', + content: t('taskTimedOut'), + metadata: { isTimeout: true }, + }); + this.emit('message', sessionId, hintMessage); this.emit('complete', sessionId, turn.runId); } const abortedSessionKey = turn.sessionKey; @@ -3162,6 +3189,11 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim private cleanupSessionTurn(sessionId: string): void { const turn = this.activeTurns.get(sessionId); if (turn) { + // Clear client-side timeout watchdog + if (turn.timeoutTimer) { + clearTimeout(turn.timeoutTimer); + turn.timeoutTimer = undefined; + } // Cancel any pending throttled messageUpdate timer for this turn if (turn.assistantMessageId) { this.clearPendingMessageUpdate(turn.assistantMessageId); @@ -3182,6 +3214,27 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim this.reCreatedChannelSessionIds.delete(sessionId); } + /** + * Start a client-side timeout watchdog for a turn. + * Fires after the server-side timeout + grace period, recovering the UI + * if the gateway fails to deliver the abort/final event. + */ + private startTurnTimeoutWatchdog(sessionId: string): void { + const turn = this.activeTurns.get(sessionId); + if (!turn) return; + const timeoutMs = this.agentTimeoutSeconds * 1000 + + OpenClawRuntimeAdapter.CLIENT_TIMEOUT_GRACE_MS; + turn.timeoutTimer = setTimeout(() => { + const currentTurn = this.activeTurns.get(sessionId); + if (!currentTurn || currentTurn.turnToken !== turn.turnToken) return; + console.warn( + `[OpenClawRuntime] Client-side timeout watchdog fired for session ${sessionId}, ` + + `runId=${currentTurn.runId} after ${timeoutMs}ms — gateway did not deliver abort event`, + ); + this.handleChatAborted(sessionId, currentTurn); + }, timeoutMs); + } + /** * Called when a session is deleted from the store. * Purges all in-memory references so that new channel messages @@ -3265,6 +3318,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim this.sessionIdByRunId.set(runId, sessionId); } this.store.updateSession(sessionId, { status: 'running' }); + this.startTurnTimeoutWatchdog(sessionId); // For channel sessions, prefetch user messages before streaming starts if (isChannel) { diff --git a/src/main/libs/openclawConfigSync.ts b/src/main/libs/openclawConfigSync.ts index 3802129..eb5c354 100644 --- a/src/main/libs/openclawConfigSync.ts +++ b/src/main/libs/openclawConfigSync.ts @@ -22,6 +22,12 @@ const mapExecutionModeToSandboxMode = (_mode: CoworkExecutionMode): 'off' | 'non return 'off'; }; +/** + * Default agent timeout in seconds written to openclaw config. + * Also used by the runtime adapter's client-side timeout watchdog. + */ +export const OPENCLAW_AGENT_TIMEOUT_SECONDS = 3600; + const mapApiTypeToOpenClawApi = (apiType: 'anthropic' | 'openai' | undefined): 'anthropic-messages' | 'openai-completions' => { return apiType === 'openai' ? 'openai-completions' : 'anthropic-messages'; }; @@ -538,6 +544,7 @@ export class OpenClawConfigSync { }, agents: { defaults: { + timeoutSeconds: OPENCLAW_AGENT_TIMEOUT_SECONDS, model: { primary: providerSelection.primaryModel, },