Skip to content

Commit 30bd8c0

Browse files
hazeonesu8su
andauthored
feat(gateway): enhance gateway readiness handling and batch sync configuration (#851)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
1 parent 758a8f8 commit 30bd8c0

File tree

14 files changed

+626
-69
lines changed

14 files changed

+626
-69
lines changed

electron/api/routes/channels.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
normalizeSlackMessagingTarget,
6565
normalizeWhatsAppMessagingTarget,
6666
} from '../../utils/openclaw-sdk';
67+
import { logger } from '../../utils/logger';
6768

6869
// listWhatsAppDirectory*FromConfig were removed from openclaw's public exports
6970
// in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery.
@@ -263,11 +264,11 @@ function scheduleGatewayChannelSaveRefresh(
263264
return;
264265
}
265266
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
266-
ctx.gatewayManager.debouncedRestart();
267+
ctx.gatewayManager.debouncedRestart(150);
267268
void reason;
268269
return;
269270
}
270-
ctx.gatewayManager.debouncedReload();
271+
ctx.gatewayManager.debouncedReload(150);
271272
void reason;
272273
}
273274

@@ -416,6 +417,28 @@ interface ChannelAccountsView {
416417
accounts: ChannelAccountView[];
417418
}
418419

420+
function buildGatewayStatusSnapshot(status: GatewayChannelStatusPayload | null): string {
421+
if (!status?.channelAccounts) return 'none';
422+
const entries = Object.entries(status.channelAccounts);
423+
if (entries.length === 0) return 'empty';
424+
return entries
425+
.slice(0, 12)
426+
.map(([channelType, accounts]) => {
427+
const channelStatus = pickChannelRuntimeStatus(accounts);
428+
const flags = accounts.slice(0, 4).map((account) => {
429+
const accountId = typeof account.accountId === 'string' ? account.accountId : 'default';
430+
const connected = account.connected === true ? '1' : '0';
431+
const running = account.running === true ? '1' : '0';
432+
const linked = account.linked === true ? '1' : '0';
433+
const probeOk = account.probe?.ok === true ? '1' : '0';
434+
const hasErr = typeof account.lastError === 'string' && account.lastError.trim().length > 0 ? '1' : '0';
435+
return `${accountId}[c${connected}r${running}l${linked}p${probeOk}e${hasErr}]`;
436+
}).join('|');
437+
return `${channelType}:${channelStatus}{${flags}}`;
438+
})
439+
.join(', ');
440+
}
441+
419442
function shouldIncludeRuntimeAccountId(
420443
accountId: string,
421444
configuredAccountIds: Set<string>,
@@ -458,7 +481,11 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
458481
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
459482
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
460483

461-
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
484+
async function buildChannelAccountsView(
485+
ctx: HostApiContext,
486+
options?: { probe?: boolean },
487+
): Promise<ChannelAccountsView[]> {
488+
const startedAt = Date.now();
462489
// Read config once and share across all sub-calls (was 5 readFile calls before).
463490
const openClawConfig = await readOpenClawConfig();
464491

@@ -470,11 +497,24 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
470497

471498
let gatewayStatus: GatewayChannelStatusPayload | null;
472499
try {
473-
// probe: false — use cached runtime state instead of active network probes
474-
// per channel. Real-time status updates arrive via channel.status events.
500+
// probe=false uses cached runtime state (lighter); probe=true forces
501+
// adapter-level connectivity checks for faster post-restart convergence.
502+
const probe = options?.probe === true;
475503
// 8s timeout — fail fast when Gateway is busy with AI tasks.
476-
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>('channels.status', { probe: false }, 8000);
504+
const rpcStartedAt = Date.now();
505+
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>(
506+
'channels.status',
507+
{ probe },
508+
probe ? 5000 : 8000,
509+
);
510+
logger.info(
511+
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}`
512+
);
477513
} catch {
514+
const probe = options?.probe === true;
515+
logger.warn(
516+
`[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms`
517+
);
478518
gatewayStatus = null;
479519
}
480520

@@ -553,7 +593,11 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
553593
});
554594
}
555595

556-
return channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
596+
const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
597+
logger.info(
598+
`[channels.accounts] response probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}`
599+
);
600+
return sorted;
557601
}
558602

559603
function buildChannelTargetLabel(baseLabel: string, value: string): string {
@@ -1147,7 +1191,9 @@ export async function handleChannelRoutes(
11471191

11481192
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
11491193
try {
1150-
const channels = await buildChannelAccountsView(ctx);
1194+
const probe = url.searchParams.get('probe') === '1';
1195+
logger.info(`[channels.accounts] request probe=${probe ? '1' : '0'}`);
1196+
const channels = await buildChannelAccountsView(ctx, { probe });
11511197
sendJson(res, 200, { success: true, channels });
11521198
} catch (error) {
11531199
sendJson(res, 500, { success: false, error: String(error) });

electron/gateway/config-sync.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-stor
2020
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
2121
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
2222
import { getUvMirrorEnv } from '../utils/uv-env';
23-
import { cleanupDanglingWeChatPluginState, listConfiguredChannels, readOpenClawConfig } from '../utils/channel-config';
24-
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
23+
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
24+
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
2525
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
2626
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
2727
import { logger } from '../utils/logger';
@@ -180,7 +180,20 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
180180
* resolution algorithm find them. Skip-if-exists avoids overwriting
181181
* openclaw's own deps (they take priority).
182182
*/
183+
let _extensionDepsLinked = false;
184+
185+
/**
186+
* Reset the extension-deps-linked cache so the next
187+
* ensureExtensionDepsResolvable() call re-scans and links.
188+
* Called before each Gateway launch to pick up newly installed extensions.
189+
*/
190+
export function resetExtensionDepsLinked(): void {
191+
_extensionDepsLinked = false;
192+
}
193+
183194
function ensureExtensionDepsResolvable(openclawDir: string): void {
195+
if (_extensionDepsLinked) return;
196+
184197
const extDir = join(openclawDir, 'dist', 'extensions');
185198
const topNM = join(openclawDir, 'node_modules');
186199
let linkedCount = 0;
@@ -229,13 +242,20 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
229242
if (linkedCount > 0) {
230243
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
231244
}
245+
246+
_extensionDepsLinked = true;
232247
}
233248

234249
// ── Pre-launch sync ──────────────────────────────────────────────
235250

236251
export async function syncGatewayConfigBeforeLaunch(
237252
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
238253
): Promise<void> {
254+
// Reset the extension-deps cache so that newly installed extensions
255+
// (e.g. user added a channel while the app was running) get their
256+
// node_modules linked on the next Gateway spawn.
257+
resetExtensionDepsLinked();
258+
239259
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
240260

241261
try {
@@ -260,21 +280,20 @@ export async function syncGatewayConfigBeforeLaunch(
260280

261281
// Auto-upgrade installed plugins before Gateway starts so that
262282
// the plugin manifest ID matches what sanitize wrote to the config.
283+
// Read config once and reuse for both listConfiguredChannels and plugins.allow.
263284
try {
264-
const configuredChannels = await listConfiguredChannels();
285+
const rawCfg = await readOpenClawConfig();
286+
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
265287

266288
// Also ensure plugins referenced in plugins.allow are installed even if
267289
// they have no channels.X section yet (e.g. qqbot added via plugins.allow
268290
// but never fully saved through ClawX UI).
269291
try {
270-
const rawCfg = await readOpenClawConfig();
271292
const allowList = Array.isArray(rawCfg.plugins?.allow) ? (rawCfg.plugins!.allow as string[]) : [];
272-
// Build reverse maps: dirName → channelType AND known manifest IDs → channelType
273293
const pluginIdToChannel: Record<string, string> = {};
274294
for (const [channelType, info] of Object.entries(CHANNEL_PLUGIN_MAP)) {
275295
pluginIdToChannel[info.dirName] = channelType;
276296
}
277-
// Known manifest IDs that differ from their dirName/channelType
278297

279298
pluginIdToChannel['openclaw-lark'] = 'feishu';
280299
pluginIdToChannel['feishu-openclaw-plugin'] = 'feishu';
@@ -295,22 +314,11 @@ export async function syncGatewayConfigBeforeLaunch(
295314
logger.warn('Failed to auto-upgrade plugins:', err);
296315
}
297316

317+
// Batch gateway token, browser config, and session idle into one read+write cycle.
298318
try {
299-
await syncGatewayTokenToConfig(appSettings.gatewayToken);
300-
} catch (err) {
301-
logger.warn('Failed to sync gateway token to openclaw.json:', err);
302-
}
303-
304-
try {
305-
await syncBrowserConfigToOpenClaw();
306-
} catch (err) {
307-
logger.warn('Failed to sync browser config to openclaw.json:', err);
308-
}
309-
310-
try {
311-
await syncSessionIdleMinutesToOpenClaw();
319+
await batchSyncConfigFields(appSettings.gatewayToken);
312320
} catch (err) {
313-
logger.warn('Failed to sync session idle minutes to openclaw.json:', err);
321+
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
314322
}
315323
}
316324

@@ -360,7 +368,8 @@ async function resolveChannelStartupPolicy(): Promise<{
360368
channelStartupSummary: string;
361369
}> {
362370
try {
363-
const configuredChannels = await listConfiguredChannels();
371+
const rawCfg = await readOpenClawConfig();
372+
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
364373
if (configuredChannels.length === 0) {
365374
return {
366375
skipChannels: true,

electron/gateway/event-dispatch.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,13 @@ export function dispatchProtocolEvent(
2323
break;
2424
}
2525
case 'channel.status':
26+
case 'channel.status_changed':
2627
emitter.emit('channel:status', payload as { channelId: string; status: string });
2728
break;
29+
case 'gateway.ready':
30+
case 'ready':
31+
emitter.emit('gateway:ready', payload);
32+
break;
2833
default:
2934
emitter.emit('notification', { method: event, params: payload });
3035
}

electron/gateway/manager.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface GatewayStatus {
6161
connectedAt?: number;
6262
version?: string;
6363
reconnectAttempts?: number;
64+
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
65+
gatewayReady?: boolean;
6466
}
6567

6668
/**
@@ -119,9 +121,11 @@ export class GatewayManager extends EventEmitter {
119121
private static readonly HEARTBEAT_TIMEOUT_MS_WIN = 25_000;
120122
private static readonly HEARTBEAT_MAX_MISSES_WIN = 5;
121123
public static readonly RESTART_COOLDOWN_MS = 5_000;
124+
private static readonly GATEWAY_READY_FALLBACK_MS = 30_000;
122125
private lastRestartAt = 0;
123126
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
124127
private isAutoReconnectStart = false;
128+
private gatewayReadyFallbackTimer: NodeJS.Timeout | null = null;
125129

126130
constructor(config?: Partial<ReconnectConfig>) {
127131
super();
@@ -152,6 +156,14 @@ export class GatewayManager extends EventEmitter {
152156
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
153157
// Device identity is loaded lazily in start() — not in the constructor —
154158
// so that async file I/O and key generation don't block module loading.
159+
160+
this.on('gateway:ready', () => {
161+
this.clearGatewayReadyFallback();
162+
if (this.status.state === 'running' && !this.status.gatewayReady) {
163+
logger.info('Gateway subsystems ready (event received)');
164+
this.setStatus({ gatewayReady: true });
165+
}
166+
});
155167
}
156168

157169
private async initDeviceIdentity(): Promise<void> {
@@ -231,12 +243,16 @@ export class GatewayManager extends EventEmitter {
231243
this.reconnectAttempts = 0;
232244
}
233245
this.isAutoReconnectStart = false; // consume the flag
234-
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts });
246+
this.setStatus({ state: 'starting', reconnectAttempts: this.reconnectAttempts, gatewayReady: false });
235247

236248
// Check if Python environment is ready (self-healing) asynchronously.
237249
// Fire-and-forget: only needs to run once, not on every retry.
238250
warmupManagedPythonReadiness();
239251

252+
const t0 = Date.now();
253+
let tSpawned = 0;
254+
let tReady = 0;
255+
240256
try {
241257
await runGatewayStartupSequence({
242258
port: this.status.port,
@@ -262,7 +278,6 @@ export class GatewayManager extends EventEmitter {
262278
await this.connect(port, externalToken);
263279
},
264280
onConnectedToExistingGateway: () => {
265-
266281
// If the existing gateway is actually our own spawned UtilityProcess
267282
// (e.g. after a self-restart code=1012), keep ownership so that
268283
// stop() can still terminate the process during a restart() cycle.
@@ -288,16 +303,24 @@ export class GatewayManager extends EventEmitter {
288303
},
289304
startProcess: async () => {
290305
await this.startProcess();
306+
tSpawned = Date.now();
291307
},
292308
waitForReady: async (port) => {
293309
await waitForGatewayReady({
294310
port,
295311
getProcessExitCode: () => this.processExitCode,
296312
});
313+
tReady = Date.now();
297314
},
298315
onConnectedToManagedGateway: () => {
299316
this.startHealthCheck();
300-
logger.debug('Gateway started successfully');
317+
const tConnected = Date.now();
318+
logger.info('[metric] gateway.startup', {
319+
configSyncMs: tSpawned ? tSpawned - t0 : undefined,
320+
spawnToReadyMs: tReady && tSpawned ? tReady - tSpawned : undefined,
321+
readyToConnectMs: tReady ? tConnected - tReady : undefined,
322+
totalMs: tConnected - t0,
323+
});
301324
},
302325
runDoctorRepair: async () => await runOpenClawDoctorRepair(),
303326
onDoctorRepairSuccess: () => {
@@ -390,7 +413,7 @@ export class GatewayManager extends EventEmitter {
390413

391414
this.restartController.resetDeferredRestart();
392415
this.isAutoReconnectStart = false;
393-
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
416+
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined, gatewayReady: undefined });
394417
}
395418

396419
/**
@@ -663,6 +686,25 @@ export class GatewayManager extends EventEmitter {
663686
clearTimeout(this.reloadDebounceTimer);
664687
this.reloadDebounceTimer = null;
665688
}
689+
this.clearGatewayReadyFallback();
690+
}
691+
692+
private clearGatewayReadyFallback(): void {
693+
if (this.gatewayReadyFallbackTimer) {
694+
clearTimeout(this.gatewayReadyFallbackTimer);
695+
this.gatewayReadyFallbackTimer = null;
696+
}
697+
}
698+
699+
private scheduleGatewayReadyFallback(): void {
700+
this.clearGatewayReadyFallback();
701+
this.gatewayReadyFallbackTimer = setTimeout(() => {
702+
this.gatewayReadyFallbackTimer = null;
703+
if (this.status.state === 'running' && !this.status.gatewayReady) {
704+
logger.info('Gateway ready fallback triggered (no gateway.ready event within timeout)');
705+
this.setStatus({ gatewayReady: true });
706+
}
707+
}, GatewayManager.GATEWAY_READY_FALLBACK_MS);
666708
}
667709

668710
/**
@@ -843,6 +885,7 @@ export class GatewayManager extends EventEmitter {
843885
connectedAt: Date.now(),
844886
});
845887
this.startPing();
888+
this.scheduleGatewayReadyFallback();
846889
},
847890
onMessage: (message) => {
848891
this.handleMessage(message);

electron/main/ipc-handlers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,7 +1452,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
14521452
const scheduleGatewayChannelRestart = (reason: string): void => {
14531453
if (gatewayManager.getStatus().state !== 'stopped') {
14541454
logger.info(`Scheduling Gateway restart after ${reason}`);
1455-
gatewayManager.debouncedRestart();
1455+
gatewayManager.debouncedRestart(150);
14561456
} else {
14571457
logger.info(`Gateway is stopped; skip immediate restart after ${reason}`);
14581458
}
@@ -1465,11 +1465,11 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
14651465
}
14661466
if (forceRestartChannels.has(channelType)) {
14671467
logger.info(`Scheduling Gateway restart after ${reason}`);
1468-
gatewayManager.debouncedRestart();
1468+
gatewayManager.debouncedRestart(150);
14691469
return;
14701470
}
14711471
logger.info(`Scheduling Gateway reload after ${reason}`);
1472-
gatewayManager.debouncedReload();
1472+
gatewayManager.debouncedReload(150);
14731473
};
14741474

14751475
// Get OpenClaw package status

0 commit comments

Comments
 (0)