diff --git a/electron/gateway/config-delivery.ts b/electron/gateway/config-delivery.ts index 2abb8942..9e6d9adc 100644 --- a/electron/gateway/config-delivery.ts +++ b/electron/gateway/config-delivery.ts @@ -92,6 +92,15 @@ async function mutateRunningConfig( return true; } catch (error) { if (attempt === 0 && isBaseHashConflict(error)) continue; + + // config.set may durably replace the file and then close the socket with + // code 1012 before its RPC response reaches ClawX. If the manager has + // already left running state, verify that exact commit instead of + // reporting a false save failure or replaying the mutation out of band. + if (manager.getStatus().state !== 'running') { + const persisted = await readFileConfig(resolveOpenClawConfigPath()); + if (isDeepStrictEqual(persisted.config, config)) return true; + } throw error; } } diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 7f4bcf85..ee78ffc1 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -411,12 +411,28 @@ export class GatewayManager extends EventEmitter { tSpawned = Date.now(); }, waitForReady: async (port) => { + const recoveringOwnedProcess = tSpawned === 0 + && this.process?.pid != null + && this.ownsProcess; await waitForGatewayReady({ port, getProcessExitCode: () => this.processExitCode, + // A code-1012 in-process reload normally returns within seconds. + // Do not hold the lifecycle lock for the general 2400-attempt cold + // startup budget when the owned process is alive but no longer serves WS. + ...(recoveringOwnedProcess ? { retries: 50 } : {}), }); tReady = Date.now(); }, + terminateStaleOwnedProcess: async () => { + const shouldReconnect = this.shouldReconnect; + this.shouldReconnect = false; + try { + await this.forceTerminateOwnedProcessForQuit(); + } finally { + this.shouldReconnect = shouldReconnect; + } + }, onConnectedToManagedGateway: () => { this.startHealthCheck(); const tConnected = Date.now(); diff --git a/electron/gateway/startup-orchestrator.ts b/electron/gateway/startup-orchestrator.ts index 0e86c09e..2cc04602 100644 --- a/electron/gateway/startup-orchestrator.ts +++ b/electron/gateway/startup-orchestrator.ts @@ -23,6 +23,7 @@ type StartupHooks = { waitForPortFree: (port: number) => Promise; startProcess: () => Promise; waitForReady: (port: number) => Promise; + terminateStaleOwnedProcess: () => Promise; onConnectedToManagedGateway: () => void; runDoctorRepair: () => Promise; onDoctorRepairSuccess: () => void; @@ -75,7 +76,13 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise { diff --git a/harness/reference/openclaw-config-delivery.md b/harness/reference/openclaw-config-delivery.md index f1568ed6..d227f2be 100644 --- a/harness/reference/openclaw-config-delivery.md +++ b/harness/reference/openclaw-config-delivery.md @@ -6,8 +6,8 @@ Provider, Agent, Channel, skill, proxy, image-generation, and plugin-install hel 1. If Gateway is running, call `config.get` and require its runtime-shaped `config` object and `hash`. The coordinator accepts `raw` only as a compatibility fallback for older responses. 2. Clone the runtime-shaped config, apply the mutator, and call `config.set` with the serialized result and `baseHash: hash`. Using source-shaped `raw` as the preferred baseline can misalign redacted secret paths with OpenClaw's runtime-shaped restore baseline. -3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway. -4. Treat success as converged and do not send `SIGUSR1` or replace the process. +3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway. If `config.set` durably wrote the exact requested snapshot but its response was lost when OpenClaw began a native code-1012 reload, verify that persisted snapshot after Gateway leaves running state and accept the existing commit without replaying it. +4. Treat success as converged and do not send `SIGUSR1` or schedule a redundant ClawX process replacement. 5. If Gateway is stopped or starting, apply the same mutator to `resolveOpenClawConfigPath()` under the shared config lock and do not start the Gateway. This is not a write-then-notify design. No provider, Agent, Channel, skill, proxy, image-generation, or plugin-install helper may write the active config independently. The coordinator prevents a locally read stale snapshot from overwriting concurrent Gateway or CLI config changes. diff --git a/harness/specs/rules/channel-plugin-migration-guards.md b/harness/specs/rules/channel-plugin-migration-guards.md index 9b5fc7d3..e78d3cea 100644 --- a/harness/specs/rules/channel-plugin-migration-guards.md +++ b/harness/specs/rules/channel-plugin-migration-guards.md @@ -10,7 +10,7 @@ When channel plugin ownership changes between bundled OpenClaw extensions and ex The ClawX channel configuration catalog is intentionally limited to `telegram`, `discord`, `whatsapp`, `wechat`, `dingtalk`, `feishu`, `wecom`, and `qqbot`. OpenClaw may report other channel ids, but the ClawX Channels page must not expose them as configurable or editable channel groups. Filtering an unsupported runtime channel is presentation-only and must not delete or rewrite that channel's underlying OpenClaw configuration. -Channel credentials and account maps must remain under `channels.`; `plugins.entries.` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving any supported external plugin channel while Gateway is running must start the guarded full restart path after the coordinated config and scoped-binding commits, including no-change retries and successful WeChat QR completion, so a newly copied or previously undiscovered plugin is loaded. The host save response may return while that restart is still pending, provided it explicitly reports the pending activation state and restart failures are caught and surfaced through normal Gateway status/logging. +Channel credentials and account maps must remain under `channels.`; `plugins.entries.` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving changed configuration for a supported external plugin channel while Gateway is running must use the coordinator-owned `config.set` reload without scheduling a second ClawX full restart. A no-change retry must still start the guarded full restart path after the scoped-binding commit so a newly copied or previously undiscovered plugin is loaded. Successful WeChat QR completion must likewise leave plugin activation on a single lifecycle path. The host save response may return while activation is still pending, provided it explicitly reports that state and failures are caught and surfaced through normal Gateway status/logging. If `config.set` durably commits before its response is lost to a native code-1012 reload, Main may verify that exact persisted config and treat the transaction as committed; it must not perform an out-of-band replay. For Feishu/Lark specifically: diff --git a/harness/specs/rules/openclaw-config-delivery.md b/harness/specs/rules/openclaw-config-delivery.md index 74c56377..959e8afc 100644 --- a/harness/specs/rules/openclaw-config-delivery.md +++ b/harness/specs/rules/openclaw-config-delivery.md @@ -14,7 +14,7 @@ ClawX must defer runtime config planning to the bundled OpenClaw Gateway. The Main-owned config coordinator must own the entire read-modify-write transaction. Production helpers must not write the active OpenClaw config and then notify another layer afterward. -When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write. +When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a redundant ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write. When `config.set` itself durably writes the exact requested config and then a native code-1012 reload drops its response, the coordinator may verify that persisted commit after Gateway leaves running state and accept it without replaying or rewriting the mutation. Coordinator mutators are replayable transformations. They must not perform filesystem writes, SQLite writes, settings writes, lifecycle actions, or other non-idempotent external effects; preload required external inputs before entering the mutator and perform follow-up effects only after a successful commit. diff --git a/harness/specs/tasks/optimize-channel-save-latency.md b/harness/specs/tasks/optimize-channel-save-latency.md index 53f7c8dc..ec257fad 100644 --- a/harness/specs/tasks/optimize-channel-save-latency.md +++ b/harness/specs/tasks/optimize-channel-save-latency.md @@ -4,12 +4,14 @@ title: Return promptly after durable channel saves while activation continues type: ai-coding-task scenario: gateway-backend-communication taskType: runtime-bridge -intent: Reduce the time the channel configuration modal remains blocked by returning after configuration and binding commits, while a required plugin Gateway restart continues through the guarded Main-process lifecycle path. +intent: Reduce the time the channel configuration modal remains blocked by returning after configuration and binding commits, while plugin activation uses one Gateway lifecycle path without racing native config reloads. touchedAreas: - harness/specs/tasks/optimize-channel-save-latency.md - harness/specs/tasks/fix-supported-channel-connectivity.md - harness/specs/tasks/remove-unsupported-channel-catalog-entries.md - harness/specs/rules/channel-plugin-migration-guards.md + - harness/specs/rules/openclaw-config-delivery.md + - harness/reference/openclaw-config-delivery.md - shared/host-api/contract.ts - shared/types/channel.ts - shared/i18n/locales/en/channels.json @@ -17,6 +19,9 @@ touchedAreas: - shared/i18n/locales/ja/channels.json - shared/i18n/locales/ru/channels.json - electron/services/channels-api.ts + - electron/gateway/config-delivery.ts + - electron/gateway/manager.ts + - electron/gateway/startup-orchestrator.ts - electron/utils/channel-config.ts - electron/utils/openclaw-auth.ts - src/components/channels/ChannelConfigModal.tsx @@ -24,6 +29,8 @@ touchedAreas: - tests/unit/channel-config.test.ts - tests/unit/agent-config.test.ts - tests/unit/host-services.test.ts + - tests/unit/gateway-config-delivery.test.ts + - tests/unit/gateway-startup-orchestrator.test.ts - tests/unit/openclaw-auth.test.ts - tests/unit/channels-page.test.tsx - tests/e2e/channels-plugin-save.spec.ts @@ -31,7 +38,9 @@ touchedAreas: expectedUserBehavior: - Saving a plugin-backed channel returns as soon as its configuration and scoped binding are durably committed instead of waiting for Gateway stop, startup, and readiness. - The Channels page immediately reloads the committed local configuration and then converges to runtime connection state after the scheduled Gateway restart. - - Required plugin activation still uses the guarded full Gateway restart path, including no-change retries and successful WeChat QR completion. + - Changed plugin configuration uses OpenClaw's native config reload without an additional ClawX full restart; no-change retries still use the guarded restart path when plugin discovery is required. + - A config commit whose acknowledgement is lost to native reload is verified from the durable config instead of being reported as a false save failure. + - A stale owned process that fails to recover from an in-process restart is terminated promptly and replaced instead of holding startup for the full cold-start retry budget. - Restart failures remain visible through normal Gateway status and logging rather than becoming unhandled promise rejections. requiredProfiles: - fast @@ -49,11 +58,14 @@ requiredRules: requiredTests: - tests/unit/agent-config.test.ts - tests/unit/host-services.test.ts + - tests/unit/gateway-config-delivery.test.ts + - tests/unit/gateway-startup-orchestrator.test.ts - tests/unit/channels-page.test.tsx - tests/e2e/channels-plugin-save.spec.ts acceptance: - The save response exposes when plugin activation is pending. - - A running Gateway restart is started only after the channel config and scoped binding commits complete. + - A changed plugin config relies on the coordinator-owned config.set reload and does not schedule a redundant full restart. + - A no-change plugin save starts a guarded Gateway restart only after the scoped binding commit completes. - The save response does not await Gateway restart readiness. - Immediate post-save refresh is config-only and does not issue an expensive runtime probe while Gateway is restarting. - No Renderer transport or direct Gateway request is added. diff --git a/tests/unit/gateway-config-delivery.test.ts b/tests/unit/gateway-config-delivery.test.ts index 392a8df6..89aaf352 100644 --- a/tests/unit/gateway-config-delivery.test.ts +++ b/tests/unit/gateway-config-delivery.test.ts @@ -445,6 +445,32 @@ describe('OpenClaw config delivery coordinator', () => { }); }); + it('accepts a config.set commit whose response is lost to a native restart', async () => { + const gatewayManager = createGatewayManager(); + gatewayManager.rpc.mockImplementation(async (method: string, params: unknown) => { + if (method === 'config.get') { + return { raw: '{ channels: {} }', hash: 'hash-1' }; + } + if (method === 'config.set') { + const raw = (params as { raw: string }).raw; + await writeFile(configPath, raw, 'utf8'); + gatewayManager.getStatus.mockReturnValue({ state: 'stopped' }); + throw new Error('Gateway stopped'); + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + registerOpenClawConfigCoordinator(gatewayManager); + + await expect(mutateOpenClawConfig((config) => { + (config.channels as Record).feishu = { enabled: true }; + })).resolves.toBe(true); + + expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ + channels: { feishu: { enabled: true } }, + }); + expect(gatewayManager.restart).not.toHaveBeenCalled(); + }); + it.each(['config.get', 'config.set'] as const)( 'fails closed when running %s fails', async (failedMethod) => { diff --git a/tests/unit/gateway-startup-orchestrator.test.ts b/tests/unit/gateway-startup-orchestrator.test.ts index f109243f..c40b0d49 100644 --- a/tests/unit/gateway-startup-orchestrator.test.ts +++ b/tests/unit/gateway-startup-orchestrator.test.ts @@ -34,6 +34,7 @@ function createMockHooks(overrides: Partial { await runGatewayStartupSequence(hooks); - // First attempt: owned-process path → waitForReady throws → retry - // Second attempt: not owned → normal start path → succeeds + // First attempt: owned-process path → waitForReady throws → stale process + // is terminated and retried. Second attempt starts a fresh process. expect(hasOwnedProcessCalls).toBe(2); + expect(hooks.terminateStaleOwnedProcess).toHaveBeenCalledTimes(1); expect(hooks.startProcess).toHaveBeenCalledTimes(1); expect(hooks.onConnectedToManagedGateway).toHaveBeenCalledTimes(1); expect(hooks.delay).toHaveBeenCalledWith(1000); diff --git a/tests/unit/host-services.test.ts b/tests/unit/host-services.test.ts index 2b4d9fdf..8f0f318b 100644 --- a/tests/unit/host-services.test.ts +++ b/tests/unit/host-services.test.ts @@ -820,7 +820,7 @@ describe('host services', () => { expect(migrateLegacyChannelWideBindingMock).not.toHaveBeenCalled(); }); - it('commits a plugin channel save and schedules activation without awaiting Gateway readiness', async () => { + it('commits a changed plugin channel save without racing the native config reload', async () => { listAgentsSnapshotMock.mockResolvedValue({ agents: [{ id: 'main', name: 'Main' }], defaultAgentId: 'main', @@ -851,11 +851,9 @@ describe('host services', () => { 'default', ); expect(ensureScopedChannelBindingMock).toHaveBeenCalledWith('feishu', 'default'); - expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0); + expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled(); expect(gatewayManager.debouncedReload).not.toHaveBeenCalled(); expect(gatewayManager.restart).not.toHaveBeenCalled(); - expect(ensureScopedChannelBindingMock.mock.invocationCallOrder[0]) - .toBeLessThan(gatewayManager.debouncedRestart.mock.invocationCallOrder[0]); }); it('keeps bundled Telegram on the native config reload path', async () => {