From 9020d2c8c51c14b913453530c0e22177fe77e4a2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 23 Aug 2026 19:42:03 +0900 Subject: [PATCH] feat(runtime): SIGHUP hot-reload of room bindings (no full restart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering/deregistering a channel via a DB write from a non-main room did not take effect until a full restart, and restarting from inside an agent turn is unreliable (the agent is a child of ejclaw.service, so `systemctl restart` kills it mid-command and it can't be confirmed) — the source of repeated "auto-restart missing / claimed restart but it didn't happen" failures. Add a SIGHUP handler that calls runtimeState.reloadRoomBindings(), which re-reads ONLY room bindings from the DB (cursors/sessions untouched, no re-processing). `kill -HUP ` then makes a registration live instantly with no restart and no agent death, so it is verifiable in the same turn. Co-Authored-By: Claude Opus 4.7 --- src/index.ts | 11 +++++++++++ src/runtime-state.test.ts | 28 ++++++++++++++++++++++++++++ src/runtime-state.ts | 16 ++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/src/index.ts b/src/index.ts index c058e66..12b1260 100644 --- a/src/index.ts +++ b/src/index.ts @@ -376,6 +376,17 @@ async function main(): Promise { }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + // SIGHUP hot-reloads room bindings so a channel registered/deregistered via a + // DB write takes effect immediately — no full restart needed. Send it only to + // the main process, e.g. `kill -HUP `, so agent subprocesses are not + // signalled. + process.on('SIGHUP', () => { + try { + runtimeState.reloadRoomBindings(); + } catch (err) { + logger.error({ err }, 'Failed to reload room bindings on SIGHUP'); + } + }); // Channel callbacks (shared by all channels) const channelOpts = { diff --git a/src/runtime-state.test.ts b/src/runtime-state.test.ts index 85323ce..13adb0c 100644 --- a/src/runtime-state.test.ts +++ b/src/runtime-state.test.ts @@ -34,4 +34,32 @@ describe('createRuntimeState', () => { expect(db.deleteAllSessionsForGroup).toHaveBeenCalledWith('group-a'); expect(db.deleteSession).not.toHaveBeenCalled(); }); + + it('reloadRoomBindings re-reads bindings live without regressing cursors', () => { + db.getAllRoomBindings.mockReturnValue({ + 'dc:a': { name: 'A', folder: 'a' }, + }); + const state = createRuntimeState(); + state.loadState(); + expect(Object.keys(state.getRoomBindings())).toEqual(['dc:a']); + + // Cursor advances in memory after load. + state.setLastTimestamp('cursor-2'); + + // A new channel is registered in the DB (DB write, not live IPC). + db.getAllRoomBindings.mockReturnValue({ + 'dc:a': { name: 'A', folder: 'a' }, + 'dc:b': { name: 'B', folder: 'b' }, + }); + + state.reloadRoomBindings(); + + // Binding picked up live... + expect(Object.keys(state.getRoomBindings()).sort()).toEqual([ + 'dc:a', + 'dc:b', + ]); + // ...without resetting the message cursor (no re-processing). + expect(state.getLastTimestamp()).toBe('cursor-2'); + }); }); diff --git a/src/runtime-state.ts b/src/runtime-state.ts index 597588e..7e70e1d 100644 --- a/src/runtime-state.ts +++ b/src/runtime-state.ts @@ -20,6 +20,13 @@ import type { AgentType, RegisteredGroup } from './types.js'; export interface RuntimeState { loadState: () => void; + /** + * Re-read ONLY the room bindings from the DB (not cursors/sessions), so a + * channel registered/deregistered via a DB write takes effect live without a + * full process restart. Safe to call at runtime — it does not touch the + * message cursors, so no re-processing risk. + */ + reloadRoomBindings: () => void; saveState: () => void; clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; assignRoomForIpc: (jid: string, input: AssignRoomInput) => void; @@ -96,6 +103,14 @@ export function createRuntimeState(): RuntimeState { ); }; + const reloadRoomBindings = (): void => { + roomBindings = getAllRoomBindings(); + logger.info( + { groupCount: Object.keys(roomBindings).length }, + 'Room bindings reloaded', + ); + }; + const assignRoomForIpc = (jid: string, input: AssignRoomInput): void => { const assignedGroup = assignRoom(jid, input); if (!assignedGroup) { @@ -121,6 +136,7 @@ export function createRuntimeState(): RuntimeState { return { loadState, + reloadRoomBindings, saveState, clearSession, assignRoomForIpc,