Fix: Defer gateway restarts during active agent runs and prevent macOS app config writes in remote mode

- 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.
This commit is contained in:
valtterimelkko 2026-01-30 11:00:16 +00:00
parent cb742ebb27
commit 05900e72b6
3 changed files with 62 additions and 0 deletions

View File

@ -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()

View File

@ -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(() => {});
},
};

View File

@ -21,6 +21,7 @@ export type AgentRunContext = {
const seqByRun = new Map<string, number>();
const listeners = new Set<(evt: AgentEventPayload) => void>();
const runContextById = new Map<string, AgentRunContext>();
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() {