fix(channel): Feishu channel gateway wont restart after config (#291)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-03-04 21:45:44 +08:00
committed by GitHub
Unverified
parent 5ddb7d281d
commit be800f6cfc
5 changed files with 186 additions and 9 deletions

View File

@@ -33,16 +33,19 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { shouldAttemptConfigAutoRepair } from './startup-recovery'; import { shouldAttemptConfigAutoRepair } from './startup-recovery';
import { import {
type GatewayLifecycleState,
getDeferredRestartAction,
getReconnectSkipReason, getReconnectSkipReason,
isLifecycleSuperseded, isLifecycleSuperseded,
nextLifecycleEpoch, nextLifecycleEpoch,
shouldDeferRestart,
} from './process-policy'; } from './process-policy';
/** /**
* Gateway connection status * Gateway connection status
*/ */
export interface GatewayStatus { export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; state: GatewayLifecycleState;
port: number; port: number;
pid?: number; pid?: number;
uptime?: number; uptime?: number;
@@ -218,6 +221,8 @@ export class GatewayManager extends EventEmitter {
private deviceIdentity: DeviceIdentity | null = null; private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null;
private lifecycleEpoch = 0; private lifecycleEpoch = 0;
private deferredRestartPending = false;
private restartInFlight: Promise<void> | null = null;
constructor(config?: Partial<ReconnectConfig>) { constructor(config?: Partial<ReconnectConfig>) {
super(); super();
@@ -292,6 +297,56 @@ export class GatewayManager extends EventEmitter {
} }
} }
private isRestartDeferred(): boolean {
return shouldDeferRestart({
state: this.status.state,
startLock: this.startLock,
});
}
private markDeferredRestart(reason: string): void {
if (!this.deferredRestartPending) {
logger.info(
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${this.status.state}, startLock=${this.startLock})`
);
} else {
logger.debug(
`Gateway restart already deferred; keeping pending request (${reason}, state=${this.status.state}, startLock=${this.startLock})`
);
}
this.deferredRestartPending = true;
}
private flushDeferredRestart(trigger: string): void {
const action = getDeferredRestartAction({
hasPendingRestart: this.deferredRestartPending,
state: this.status.state,
startLock: this.startLock,
shouldReconnect: this.shouldReconnect,
});
if (action === 'none') return;
if (action === 'wait') {
logger.debug(
`Deferred Gateway restart still waiting (${trigger}, state=${this.status.state}, startLock=${this.startLock})`
);
return;
}
this.deferredRestartPending = false;
if (action === 'drop') {
logger.info(
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${this.status.state}, shouldReconnect=${this.shouldReconnect})`
);
return;
}
logger.info(`Executing deferred Gateway restart now (${trigger})`);
void this.restart().catch((error) => {
logger.warn('Deferred Gateway restart failed:', error);
});
}
/** /**
* Get current Gateway status * Get current Gateway status
*/ */
@@ -445,6 +500,7 @@ export class GatewayManager extends EventEmitter {
throw error; throw error;
} finally { } finally {
this.startLock = false; this.startLock = false;
this.flushDeferredRestart('start:finally');
} }
} }
@@ -521,6 +577,7 @@ export class GatewayManager extends EventEmitter {
} }
this.pendingRequests.clear(); this.pendingRequests.clear();
this.deferredRestartPending = false;
this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined });
} }
@@ -528,9 +585,29 @@ export class GatewayManager extends EventEmitter {
* Restart Gateway process * Restart Gateway process
*/ */
async restart(): Promise<void> { async restart(): Promise<void> {
if (this.isRestartDeferred()) {
this.markDeferredRestart('restart');
return;
}
if (this.restartInFlight) {
logger.debug('Gateway restart already in progress, joining existing request');
await this.restartInFlight;
return;
}
logger.debug('Gateway restart requested'); logger.debug('Gateway restart requested');
this.restartInFlight = (async () => {
await this.stop(); await this.stop();
await this.start(); await this.start();
})();
try {
await this.restartInFlight;
} finally {
this.restartInFlight = null;
this.flushDeferredRestart('restart:finally');
}
} }
/** /**
@@ -1639,6 +1716,7 @@ export class GatewayManager extends EventEmitter {
// Log state transitions // Log state transitions
if (previousState !== this.status.state) { if (previousState !== this.status.state) {
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`); logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
this.flushDeferredRestart(`status:${previousState}->${this.status.state}`);
} }
} }
} }

View File

@@ -21,3 +21,36 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string
} }
return null; return null;
} }
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
export interface RestartDeferralContext {
state: GatewayLifecycleState;
startLock: boolean;
}
/**
* Restart requests should not interrupt an in-flight startup/reconnect flow.
* Doing so can kill a just-spawned process and leave the manager stopped.
*/
export function shouldDeferRestart(context: RestartDeferralContext): boolean {
return context.startLock || context.state === 'starting' || context.state === 'reconnecting';
}
export interface DeferredRestartActionContext extends RestartDeferralContext {
hasPendingRestart: boolean;
shouldReconnect: boolean;
}
export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute';
/**
* Decide what to do with a pending deferred restart once lifecycle changes.
*/
export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction {
if (!context.hasPendingRestart) return 'none';
if (shouldDeferRestart(context)) return 'wait';
if (!context.shouldReconnect) return 'drop';
if (context.state === 'running') return 'drop';
return 'execute';
}

View File

@@ -138,7 +138,7 @@ export function registerIpcHandlers(
registerClawHubHandlers(clawHubService); registerClawHubHandlers(clawHubService);
// OpenClaw handlers // OpenClaw handlers
registerOpenClawHandlers(gatewayManager); registerOpenClawHandlers();
// Provider handlers // Provider handlers
registerProviderHandlers(gatewayManager); registerProviderHandlers(gatewayManager);
@@ -695,7 +695,7 @@ function registerGatewayHandlers(
* OpenClaw-related IPC handlers * OpenClaw-related IPC handlers
* For checking package status and channel configuration * For checking package status and channel configuration
*/ */
function registerOpenClawHandlers(gatewayManager: GatewayManager): void { function registerOpenClawHandlers(): void {
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json'); const targetManifest = join(targetDir, 'openclaw.plugin.json');
@@ -808,7 +808,9 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
}; };
} }
await saveChannelConfig(channelType, config); await saveChannelConfig(channelType, config);
gatewayManager.debouncedRestart(); logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
);
return { return {
success: true, success: true,
pluginInstalled: installResult.installed, pluginInstalled: installResult.installed,
@@ -816,8 +818,12 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
}; };
} }
await saveChannelConfig(channelType, config); await saveChannelConfig(channelType, config);
// Debounced restart so the gateway picks up the new channel config. // Do not force stop/start here. Recent Gateway builds detect channel config
gatewayManager.debouncedRestart(); // changes and perform an internal service restart; forcing another restart
// from Electron can race with reconnect and kill the newly spawned process.
logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Failed to save channel config:', error); console.error('Failed to save channel config:', error);

View File

@@ -94,7 +94,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
if (!disabled && textareaRef.current) { if (!disabled && textareaRef.current) {
textareaRef.current.focus(); textareaRef.current.focus();
} }
}, []); }, [disabled]);
// ── File staging via native dialog ───────────────────────────── // ── File staging via native dialog ─────────────────────────────

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
getDeferredRestartAction,
getReconnectSkipReason, getReconnectSkipReason,
isLifecycleSuperseded, isLifecycleSuperseded,
nextLifecycleEpoch, nextLifecycleEpoch,
shouldDeferRestart,
} from '@electron/gateway/process-policy'; } from '@electron/gateway/process-policy';
describe('gateway process policy helpers', () => { describe('gateway process policy helpers', () => {
@@ -49,4 +51,62 @@ describe('gateway process policy helpers', () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe('restart deferral policy', () => {
it('defers restart while startup or reconnect is in progress', () => {
expect(shouldDeferRestart({ state: 'starting', startLock: false })).toBe(true);
expect(shouldDeferRestart({ state: 'reconnecting', startLock: false })).toBe(true);
expect(shouldDeferRestart({ state: 'running', startLock: true })).toBe(true);
});
it('does not defer restart for stable states when no start lock', () => {
expect(shouldDeferRestart({ state: 'running', startLock: false })).toBe(false);
expect(shouldDeferRestart({ state: 'stopped', startLock: false })).toBe(false);
expect(shouldDeferRestart({ state: 'error', startLock: false })).toBe(false);
});
it('drops deferred restart once lifecycle recovers to running', () => {
expect(
getDeferredRestartAction({
hasPendingRestart: true,
state: 'running',
startLock: false,
shouldReconnect: true,
})
).toBe('drop');
});
it('waits deferred restart while lifecycle is still busy', () => {
expect(
getDeferredRestartAction({
hasPendingRestart: true,
state: 'starting',
startLock: false,
shouldReconnect: true,
})
).toBe('wait');
});
it('executes deferred restart when manager is idle and not running', () => {
expect(
getDeferredRestartAction({
hasPendingRestart: true,
state: 'error',
startLock: false,
shouldReconnect: true,
})
).toBe('execute');
});
it('drops deferred restart when reconnect is disabled', () => {
expect(
getDeferredRestartAction({
hasPendingRestart: true,
state: 'stopped',
startLock: false,
shouldReconnect: false,
})
).toBe('drop');
});
});
}); });