feat(register): auto hot-reload service after channel register/deregister
Making a channel registration live no longer depends on an operator/agent remembering to restart. setup/register.ts and scripts/deregister-room.ts now call signalEjclawReload() after the DB write, which sends SIGHUP to the running service's main PID (→ runtimeState.reloadRoomBindings). Best-effort and injectable-for-tests; no-ops when the service isn't running. Also document the canonical rule in the committed, agent-loaded platform prompt (prompts/claude-platform.md): prefer these paths, never fake a restart, verify via the "Room bindings reloaded" log; a plain in-turn systemctl restart is forbidden (it kills the agent). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
41
src/runtime-reload-signal.test.ts
Normal file
41
src/runtime-reload-signal.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { signalEjclawReload } from './runtime-reload-signal.js';
|
||||
|
||||
describe('signalEjclawReload', () => {
|
||||
it('sends SIGHUP to the resolved main PID', () => {
|
||||
const sendSignal = vi.fn();
|
||||
const ok = signalEjclawReload({ getMainPid: () => 4321, sendSignal });
|
||||
expect(ok).toBe(true);
|
||||
expect(sendSignal).toHaveBeenCalledWith(4321, 'SIGHUP');
|
||||
});
|
||||
|
||||
it('no-ops when the service is not running (pid 0)', () => {
|
||||
const sendSignal = vi.fn();
|
||||
const ok = signalEjclawReload({ getMainPid: () => 0, sendSignal });
|
||||
expect(ok).toBe(false);
|
||||
expect(sendSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when PID resolution throws', () => {
|
||||
const sendSignal = vi.fn();
|
||||
const ok = signalEjclawReload({
|
||||
getMainPid: () => {
|
||||
throw new Error('no systemctl');
|
||||
},
|
||||
sendSignal,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(sendSignal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when sending the signal throws', () => {
|
||||
const ok = signalEjclawReload({
|
||||
getMainPid: () => 999,
|
||||
sendSignal: () => {
|
||||
throw new Error('ESRCH');
|
||||
},
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
69
src/runtime-reload-signal.ts
Normal file
69
src/runtime-reload-signal.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Best-effort: tell the running ejclaw main process to hot-reload its room
|
||||
* bindings (SIGHUP → runtimeState.reloadRoomBindings) so a DB-level channel
|
||||
* registration/deregistration takes effect immediately, with no full restart.
|
||||
*
|
||||
* CLI paths (setup/register, scripts/deregister-room) run as their own process
|
||||
* and mutate the DB; the running service does not notice until its bindings are
|
||||
* reloaded. Calling this right after the DB write closes that gap automatically
|
||||
* instead of relying on an operator/agent to remember a manual restart.
|
||||
*
|
||||
* Signals ONLY the main PID (not the whole cgroup), so agent subprocesses are
|
||||
* left alive. No-op (returns false) when the service is not running; the change
|
||||
* still applies on the next start. Dependencies are injectable for tests.
|
||||
*/
|
||||
export function signalEjclawReload(deps?: {
|
||||
getMainPid?: () => number;
|
||||
sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
}): boolean {
|
||||
const getMainPid = deps?.getMainPid ?? defaultGetMainPid;
|
||||
const sendSignal =
|
||||
deps?.sendSignal ?? ((pid, signal) => process.kill(pid, signal));
|
||||
|
||||
let mainPid: number;
|
||||
try {
|
||||
mainPid = getMainPid();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'Could not resolve ejclaw MainPID; skipping live reload (change applies on next restart)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(mainPid) || mainPid <= 0) {
|
||||
logger.warn(
|
||||
{ mainPid },
|
||||
'ejclaw not running; skipping live reload (change applies on next start)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
sendSignal(mainPid, 'SIGHUP');
|
||||
logger.info(
|
||||
{ mainPid },
|
||||
'Signaled ejclaw to hot-reload room bindings (SIGHUP)',
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err), mainPid },
|
||||
'Failed to send SIGHUP to ejclaw',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGetMainPid(): number {
|
||||
const out = execFileSync(
|
||||
'systemctl',
|
||||
['--user', 'show', '-p', 'MainPID', '--value', 'ejclaw.service'],
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
return Number.parseInt(out, 10);
|
||||
}
|
||||
Reference in New Issue
Block a user