feat(version): upgrade openclaw version 4.11 (#845)

This commit is contained in:
Haze
2026-04-13 19:11:28 +08:00
committed by GitHub
Unverified
parent 5482acd43d
commit 03c40985e1
9 changed files with 595 additions and 759 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "clawx", "name": "clawx",
"version": "0.3.8", "version": "0.3.9-beta.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@discordjs/opus", "@discordjs/opus",
@@ -94,7 +94,7 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@soimy/dingtalk": "^3.5.3", "@soimy/dingtalk": "^3.5.3",
"@tencent-weixin/openclaw-weixin": "^2.1.7", "@tencent-weixin/openclaw-weixin": "^2.1.8",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
@@ -119,7 +119,7 @@
"i18next": "^25.8.11", "i18next": "^25.8.11",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"openclaw": "2026.4.9", "openclaw": "2026.4.11",
"png2icons": "^2.0.1", "png2icons": "^2.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"react": "^19.2.4", "react": "^19.2.4",
@@ -143,4 +143,4 @@
"zx": "^8.8.5" "zx": "^8.8.5"
}, },
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
} }

1134
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -109,15 +109,26 @@ function buildChatEventDedupeKey(eventState: string, event: Record<string, unkno
return null; return null;
} }
function getFinalMessageIdDedupeKey(eventState: string, event: Record<string, unknown>): string | null {
if (eventState !== 'final') return null;
const msg = (event.message && typeof event.message === 'object')
? event.message as Record<string, unknown>
: null;
if (msg?.id != null) return `final-msgid|${String(msg.id)}`;
return null;
}
function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>): boolean { function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>): boolean {
const key = buildChatEventDedupeKey(eventState, event); const key = buildChatEventDedupeKey(eventState, event);
if (!key) return false; const msgKey = getFinalMessageIdDedupeKey(eventState, event);
if (!key && !msgKey) return false;
const now = Date.now(); const now = Date.now();
pruneChatEventDedupe(now); pruneChatEventDedupe(now);
if (_chatEventDedupe.has(key)) { if ((key && _chatEventDedupe.has(key)) || (msgKey && _chatEventDedupe.has(msgKey))) {
return true; return true;
} }
_chatEventDedupe.set(key, now); if (key) _chatEventDedupe.set(key, now);
if (msgKey) _chatEventDedupe.set(msgKey, now);
return false; return false;
} }
@@ -1118,38 +1129,50 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
// Background: fetch first user message for every non-main session to populate labels upfront. // Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything. // Retries on "gateway startup" errors since the gateway may still be initializing.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main')); const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) { if (sessionsToLabel.length > 0) {
void Promise.all( const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
sessionsToLabel.map(async (session) => { void (async () => {
try { let pending = sessionsToLabel;
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>( for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
'chat.history', const failed: typeof pending = [];
{ sessionKey: session.key, limit: 1000 }, await Promise.all(
); pending.map(async (session) => {
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : []; try {
const firstUser = msgs.find((m) => m.role === 'user'); const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
const lastMsg = msgs[msgs.length - 1]; 'chat.history',
set((s) => { { sessionKey: session.key, limit: 1000 },
const next: Partial<typeof s> = {}; );
if (firstUser) { const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
const labelText = getMessageText(firstUser.content).trim(); const firstUser = msgs.find((m) => m.role === 'user');
if (labelText) { const lastMsg = msgs[msgs.length - 1];
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText; set((s) => {
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated }; const next: Partial<typeof s> = {};
if (firstUser) {
const labelText = getMessageText(firstUser.content).trim();
if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
}
}
if (lastMsg?.timestamp) {
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
}
return next;
});
} catch (err) {
if (classifyHistoryStartupRetryError(err) === 'gateway_startup') {
failed.push(session);
} }
} }
if (lastMsg?.timestamp) { }),
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) }; );
} if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
return next; await sleep(LABEL_RETRY_DELAYS[attempt]!);
}); pending = failed;
} catch { }
// ignore per-session errors })();
}
}),
);
} }
} }
} catch (err) { } catch (err) {

View File

@@ -253,10 +253,12 @@ export function createHistoryActions(
return; return;
} }
if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) { const errorKind = classifyHistoryStartupRetryError(lastError);
if (isCurrentSession() && isInitialForegroundLoad && errorKind) {
console.warn('[chat.history] startup retry exhausted', { console.warn('[chat.history] startup retry exhausted', {
sessionKey: currentSessionKey, sessionKey: currentSessionKey,
gatewayState: useGatewayStore.getState().status.state, gatewayState: useGatewayStore.getState().status.state,
errorKind,
error: String(lastError), error: String(lastError),
}); });
} }
@@ -267,6 +269,11 @@ export function createHistoryActions(
if (applied && isInitialForegroundLoad) { if (applied && isInitialForegroundLoad) {
foregroundHistoryLoadSeen.add(currentSessionKey); foregroundHistoryLoadSeen.add(currentSessionKey);
} }
} else if (errorKind === 'gateway_startup') {
// Suppress error UI for gateway startup -- the history will load
// once the gateway finishes initializing (via sidebar refresh or
// the next session switch).
set({ loading: false });
} else { } else {
applyLoadFailure( applyLoadFailure(
result?.error result?.error

View File

@@ -1,8 +1,8 @@
import type { GatewayStatus } from '@/types/gateway'; import type { GatewayStatus } from '@/types/gateway';
export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000; export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000;
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [600] as const; export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [800, 2_000, 4_000, 8_000] as const;
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 15_000; export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 30_000;
export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS = export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS =
CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS; CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS;
export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000; export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000;
@@ -11,11 +11,20 @@ export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS =
+ CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0) + CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0)
+ 2_000; + 2_000;
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable'; export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable' | 'gateway_startup';
export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null { export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null {
const message = String(error).toLowerCase(); const message = String(error).toLowerCase();
if (
message.includes('unavailable during gateway startup')
|| message.includes('unavailable during startup')
|| message.includes('not yet ready')
|| message.includes('service not initialized')
) {
return 'gateway_startup';
}
if ( if (
message.includes('rpc timeout: chat.history') message.includes('rpc timeout: chat.history')
|| message.includes('gateway rpc timeout: chat.history') || message.includes('gateway rpc timeout: chat.history')
@@ -47,6 +56,11 @@ export function shouldRetryStartupHistoryLoad(
): boolean { ): boolean {
if (!gatewayStatus || !errorKind) return false; if (!gatewayStatus || !errorKind) return false;
// The gateway explicitly told us it's still initializing -- always retry
if (errorKind === 'gateway_startup') {
return true;
}
if (gatewayStatus.state === 'starting') { if (gatewayStatus.state === 'starting') {
return true; return true;
} }

View File

@@ -1,5 +1,6 @@
import { invokeIpc } from '@/lib/api-client'; import { invokeIpc } from '@/lib/api-client';
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'; import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types'; import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -111,38 +112,54 @@ export function createSessionActions(
} }
// Background: fetch first user message for every non-main session to populate labels upfront. // Background: fetch first user message for every non-main session to populate labels upfront.
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything. // Retries on "gateway startup" errors since the gateway may still be initializing.
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main')); const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
if (sessionsToLabel.length > 0) { if (sessionsToLabel.length > 0) {
void Promise.all( const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
sessionsToLabel.map(async (session) => { void (async () => {
try { let pending = sessionsToLabel;
const r = await invokeIpc( for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
'gateway:rpc', const failed: typeof pending = [];
'chat.history', await Promise.all(
{ sessionKey: session.key, limit: 1000 }, pending.map(async (session) => {
) as { success: boolean; result?: Record<string, unknown> }; try {
if (!r.success || !r.result) return; const r = await invokeIpc(
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : []; 'gateway:rpc',
const firstUser = msgs.find((m) => m.role === 'user'); 'chat.history',
const lastMsg = msgs[msgs.length - 1]; { sessionKey: session.key, limit: 1000 },
set((s) => { ) as { success: boolean; result?: Record<string, unknown>; error?: string };
const next: Partial<typeof s> = {}; if (!r.success) {
if (firstUser) { if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
const labelText = getMessageText(firstUser.content).trim(); failed.push(session);
if (labelText) { }
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText; return;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
} }
} if (!r.result) return;
if (lastMsg?.timestamp) { const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) }; const firstUser = msgs.find((m) => m.role === 'user');
} const lastMsg = msgs[msgs.length - 1];
return next; set((s) => {
}); const next: Partial<typeof s> = {};
} catch { /* ignore per-session errors */ } if (firstUser) {
}), const labelText = getMessageText(firstUser.content).trim();
); if (labelText) {
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}` : labelText;
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
}
}
if (lastMsg?.timestamp) {
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
}
return next;
});
} catch { /* ignore per-session errors */ }
}),
);
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
await sleep(LABEL_RETRY_DELAYS[attempt]!);
pending = failed;
}
})();
} }
} }
} catch (err) { } catch (err) {

View File

@@ -67,15 +67,28 @@ function buildGatewayEventDedupeKey(event: Record<string, unknown>): string | nu
return null; return null;
} }
function getMessageIdDedupeKey(event: Record<string, unknown>): string | null {
const state = event.state != null ? String(event.state) : '';
if (state !== 'final') return null;
const message = event.message;
if (message && typeof message === 'object') {
const msgId = (message as Record<string, unknown>).id;
if (msgId != null) return `final-msgid|${String(msgId)}`;
}
return null;
}
function shouldProcessGatewayEvent(event: Record<string, unknown>): boolean { function shouldProcessGatewayEvent(event: Record<string, unknown>): boolean {
const key = buildGatewayEventDedupeKey(event); const key = buildGatewayEventDedupeKey(event);
if (!key) return true; const msgKey = getMessageIdDedupeKey(event);
if (!key && !msgKey) return true;
const now = Date.now(); const now = Date.now();
pruneGatewayEventDedupe(now); pruneGatewayEventDedupe(now);
if (gatewayEventDedupe.has(key)) { if ((key && gatewayEventDedupe.has(key)) || (msgKey && gatewayEventDedupe.has(msgKey))) {
return false; return false;
} }
gatewayEventDedupe.set(key, now); if (key) gatewayEventDedupe.set(key, now);
if (msgKey) gatewayEventDedupe.set(msgKey, now);
return true; return true;
} }

View File

@@ -272,7 +272,7 @@ describe('chat history actions', () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
await loadPromise; await loadPromise;
expect(invokeIpcMock).toHaveBeenCalledTimes(2); expect(invokeIpcMock).toHaveBeenCalledTimes(5);
expect(h.read().messages).toEqual([]); expect(h.read().messages).toEqual([]);
expect(h.read().error).toBe('RPC timeout: chat.history'); expect(h.read().error).toBe('RPC timeout: chat.history');
expect(warnSpy).toHaveBeenCalledWith( expect(warnSpy).toHaveBeenCalledWith(

View File

@@ -90,7 +90,7 @@ describe('useChatStore startup history retry', () => {
{ sessionKey: 'agent:main:main', limit: 200 }, { sessionKey: 'agent:main:main', limit: 200 },
undefined, undefined,
); );
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 72_600); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
setTimeoutSpy.mockRestore(); setTimeoutSpy.mockRestore();
}); });