feat(runtime): SIGHUP hot-reload of room bindings (no full restart)

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 <MainPID>` 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 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-23 19:42:03 +09:00
parent e752524252
commit 9020d2c8c5
3 changed files with 55 additions and 0 deletions

View File

@@ -376,6 +376,17 @@ async function main(): Promise<void> {
};
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 <MainPID>`, 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 = {

View File

@@ -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');
});
});

View File

@@ -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,