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:
11
src/index.ts
11
src/index.ts
@@ -376,6 +376,17 @@ async function main(): Promise<void> {
|
|||||||
};
|
};
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
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)
|
// Channel callbacks (shared by all channels)
|
||||||
const channelOpts = {
|
const channelOpts = {
|
||||||
|
|||||||
@@ -34,4 +34,32 @@ describe('createRuntimeState', () => {
|
|||||||
expect(db.deleteAllSessionsForGroup).toHaveBeenCalledWith('group-a');
|
expect(db.deleteAllSessionsForGroup).toHaveBeenCalledWith('group-a');
|
||||||
expect(db.deleteSession).not.toHaveBeenCalled();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ import type { AgentType, RegisteredGroup } from './types.js';
|
|||||||
|
|
||||||
export interface RuntimeState {
|
export interface RuntimeState {
|
||||||
loadState: () => void;
|
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;
|
saveState: () => void;
|
||||||
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void;
|
||||||
assignRoomForIpc: (jid: string, input: AssignRoomInput) => 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 assignRoomForIpc = (jid: string, input: AssignRoomInput): void => {
|
||||||
const assignedGroup = assignRoom(jid, input);
|
const assignedGroup = assignRoom(jid, input);
|
||||||
if (!assignedGroup) {
|
if (!assignedGroup) {
|
||||||
@@ -121,6 +136,7 @@ export function createRuntimeState(): RuntimeState {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loadState,
|
loadState,
|
||||||
|
reloadRoomBindings,
|
||||||
saveState,
|
saveState,
|
||||||
clearSession,
|
clearSession,
|
||||||
assignRoomForIpc,
|
assignRoomForIpc,
|
||||||
|
|||||||
Reference in New Issue
Block a user