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:
Codex
2026-08-23 19:50:24 +09:00
parent 9020d2c8c5
commit f74536a8b2
5 changed files with 137 additions and 2 deletions

View 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);
});
});

View 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);
}