Merge pull request #509 from netease-youdao/fix/client-side-timeout-watchdog

fix: add client-side timeout watchdog for stuck running state
This commit is contained in:
btc69m979y-dotcom
2026-03-19 17:15:35 +08:00
committed by GitHub
3 changed files with 67 additions and 0 deletions
+6
View File
@@ -23,6 +23,9 @@ const translations: Record<LanguageType, Record<string, string>> = {
// Session titles (created by ChannelSessionSync)
cronSessionPrefix: '定时',
// Timeout hint
taskTimedOut: '[任务超时] 任务因超过最大允许时长而被自动停止。你可以继续对话以从中断处继续。',
},
en: {
// Tray menu
@@ -33,6 +36,9 @@ const translations: Record<LanguageType, Record<string, string>> = {
// 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.',
},
};
@@ -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<typeof setTimeout>;
};
type BufferedChatEvent = {
@@ -554,6 +558,14 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
private pendingMessageUpdateTimer: Map<string, ReturnType<typeof setTimeout>> = 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) {
+7
View File
@@ -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,
},