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:
@@ -14,3 +14,11 @@ MEDIA:/absolute/path/preview.mp4
|
|||||||
```
|
```
|
||||||
|
|
||||||
`MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments. Use absolute local paths only, do not repeat the same path in the visible text, and do not use generic markdown links or plain file paths as attachment directives. Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
|
`MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments. Use absolute local paths only, do not repeat the same path in the visible text, and do not use generic markdown links or plain file paths as attachment directives. Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
|
||||||
|
|
||||||
|
## Making channel registration live (never fake a restart)
|
||||||
|
|
||||||
|
You run INSIDE `ejclaw.service`, so a plain `systemctl --user restart ejclaw.service` from within your turn kills you mid-command and cannot be confirmed — never claim a restart/reload succeeded without evidence.
|
||||||
|
|
||||||
|
The canonical registration paths already make the change live for you: `bun setup/index.ts --step register …` and `bun scripts/deregister-room.ts …` both send `SIGHUP` to the running service's main PID after the DB write (via `signalEjclawReload()`), which triggers `runtimeState.reloadRoomBindings()`. So a registered/deregistered channel is picked up immediately with no full restart. Prefer these paths.
|
||||||
|
|
||||||
|
If you ever change room bindings by a raw DB write, reload manually with `kill -HUP "$(systemctl --user show -p MainPID --value ejclaw.service)"` (main PID only — your agent subprocess survives). Then VERIFY before reporting: confirm the `Room bindings reloaded` log or that `getRegisteredGroup(jid)` resolves. Only a genuine code/env change needs a full restart, which must use the detached `systemd-run … systemctl --user restart` form and be verified in a separate scheduled task.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import path from 'path';
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
import { DATA_DIR, GROUPS_DIR, STORE_DIR } from '../src/config.js';
|
import { DATA_DIR, GROUPS_DIR, STORE_DIR } from '../src/config.js';
|
||||||
|
import { signalEjclawReload } from '../src/runtime-reload-signal.js';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const dryRun = args.includes('--dry-run');
|
const dryRun = args.includes('--dry-run');
|
||||||
@@ -355,5 +356,15 @@ try {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nDone. Restart ejclaw to drop the room(s) from live bindings:');
|
// Make the deregistration live immediately by hot-reloading the running
|
||||||
console.log(' systemctl --user restart ejclaw.service');
|
// service's room bindings (no full restart needed).
|
||||||
|
const reloaded = signalEjclawReload();
|
||||||
|
if (reloaded) {
|
||||||
|
console.log(
|
||||||
|
'\nDone. Signaled ejclaw to hot-reload room bindings (SIGHUP) — the room(s) are now dropped from live bindings.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'\nDone. ejclaw was not running (or MainPID unavailable); the change applies on next start.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { assignRoom, initDatabase } from '../src/db.js';
|
|||||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||||
import { ensurePairedWorkspaceProvisioned } from '../src/paired-workspace-manager.js';
|
import { ensurePairedWorkspaceProvisioned } from '../src/paired-workspace-manager.js';
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
|
import { signalEjclawReload } from '../src/runtime-reload-signal.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
interface RegisterArgs {
|
interface RegisterArgs {
|
||||||
@@ -133,12 +134,17 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
logger.info({ folder: parsed.folder }, 'Ensured group log directory exists');
|
logger.info({ folder: parsed.folder }, 'Ensured group log directory exists');
|
||||||
|
|
||||||
|
// Make the registration live immediately: signal the running service to
|
||||||
|
// hot-reload its room bindings (no full restart, no operator follow-up).
|
||||||
|
const reloaded = signalEjclawReload();
|
||||||
|
|
||||||
emitStatus('REGISTER_CHANNEL', {
|
emitStatus('REGISTER_CHANNEL', {
|
||||||
JID: parsed.jid,
|
JID: parsed.jid,
|
||||||
NAME: parsed.name,
|
NAME: parsed.name,
|
||||||
FOLDER: parsed.folder,
|
FOLDER: parsed.folder,
|
||||||
CHANNEL: parsed.channel,
|
CHANNEL: parsed.channel,
|
||||||
STATUS: 'success',
|
STATUS: 'success',
|
||||||
|
RELOADED: reloaded ? 'yes' : 'no',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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