From 05900e72b617f068081c407e3a246f11ef859545 Mon Sep 17 00:00:00 2001 From: valtterimelkko Date: Fri, 30 Jan 2026 11:00:16 +0000 Subject: [PATCH] Fix: Defer gateway restarts during active agent runs and prevent macOS app config writes in remote mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add active agent run tracking functions (getActiveAgentRunCount, getActiveAgentRunIds) - Add onAgentRunComplete callback to notify when runs complete - Modify config-reload to defer restarts when active runs exist - Queue restart and apply when all runs complete - Add guard in AppState.swift to prevent config writes in remote mode - Set gateway.reload.mode to "off" as immediate fix This prevents the bot from interrupting in-flight messages during config changes, fixing the "bot typing but never responds" issue. Root cause: macOS app was writing to gateway config even in remote mode, triggering file watcher → reload handler → SIGUSR1 → shutdown during messages. --- apps/macos/Sources/Moltbot/AppState.swift | 3 ++ src/gateway/config-reload.ts | 37 +++++++++++++++++++++++ src/infra/agent-events.ts | 22 ++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/apps/macos/Sources/Moltbot/AppState.swift b/apps/macos/Sources/Moltbot/AppState.swift index 627e5851f..dfd202800 100644 --- a/apps/macos/Sources/Moltbot/AppState.swift +++ b/apps/macos/Sources/Moltbot/AppState.swift @@ -449,6 +449,9 @@ final class AppState { ? CommandResolver.parseSSHTarget(remoteTarget)?.host : nil + // Don't write to gateway config in remote mode - gateway manages its own config + guard connectionMode == .local else { return } + Task { @MainActor in // Keep app-only connection settings local to avoid overwriting remote gateway config. var root = MoltbotConfigFile.loadDict() diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index e9d6448d0..bb438e918 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -2,6 +2,7 @@ import chokidar from "chokidar"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { MoltbotConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js"; +import { getActiveAgentRunCount, onAgentRunComplete } from "../infra/agent-events.js"; export type GatewayReloadSettings = { mode: GatewayReloadMode; @@ -264,6 +265,8 @@ export function startGatewayConfigReloader(opts: { let running = false; let stopped = false; let restartQueued = false; + let queuedPlan: GatewayReloadPlan | null = null; + let queuedConfig: MoltbotConfig | null = null; const schedule = () => { if (stopped) return; @@ -306,6 +309,16 @@ export function startGatewayConfigReloader(opts: { } if (settings.mode === "restart") { if (!restartQueued) { + const activeRunCount = getActiveAgentRunCount(); + if (activeRunCount > 0) { + opts.log.warn( + `config change requires gateway restart, but deferring (${activeRunCount} active agent run${activeRunCount === 1 ? "" : "s"})`, + ); + restartQueued = true; + queuedPlan = plan; + queuedConfig = nextConfig; + return; + } restartQueued = true; opts.onRestart(plan, nextConfig); } @@ -321,6 +334,16 @@ export function startGatewayConfigReloader(opts: { return; } if (!restartQueued) { + const activeRunCount = getActiveAgentRunCount(); + if (activeRunCount > 0) { + opts.log.warn( + `config change requires gateway restart, but deferring (${activeRunCount} active agent run${activeRunCount === 1 ? "" : "s"})`, + ); + restartQueued = true; + queuedPlan = plan; + queuedConfig = nextConfig; + return; + } restartQueued = true; opts.onRestart(plan, nextConfig); } @@ -356,12 +379,26 @@ export function startGatewayConfigReloader(opts: { void watcher.close().catch(() => {}); }); + // Register callback to apply queued restart when all runs complete + const unregisterCallback = onAgentRunComplete(() => { + if (restartQueued && queuedPlan && queuedConfig && getActiveAgentRunCount() === 0) { + opts.log.info("applying queued gateway restart (all agent runs completed)"); + restartQueued = false; + const plan = queuedPlan; + const config = queuedConfig; + queuedPlan = null; + queuedConfig = null; + opts.onRestart(plan, config); + } + }); + return { stop: async () => { stopped = true; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = null; watcherClosed = true; + unregisterCallback(); await watcher.close().catch(() => {}); }, }; diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 5c41c3c95..ab16450aa 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -21,6 +21,7 @@ export type AgentRunContext = { const seqByRun = new Map(); const listeners = new Set<(evt: AgentEventPayload) => void>(); const runContextById = new Map(); +const runCompletionCallbacks = new Set<() => void>(); export function registerAgentRunContext(runId: string, context: AgentRunContext) { if (!runId) return; @@ -46,6 +47,27 @@ export function getAgentRunContext(runId: string) { export function clearAgentRunContext(runId: string) { runContextById.delete(runId); + // Notify callbacks that a run has completed + for (const callback of runCompletionCallbacks) { + try { + callback(); + } catch { + /* ignore */ + } + } +} + +export function onAgentRunComplete(callback: () => void): () => void { + runCompletionCallbacks.add(callback); + return () => runCompletionCallbacks.delete(callback); +} + +export function getActiveAgentRunCount(): number { + return runContextById.size; +} + +export function getActiveAgentRunIds(): string[] { + return Array.from(runContextById.keys()); } export function resetAgentRunContextForTest() {