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:
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ─────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user