Skip to content

Commit 0593fa9

Browse files
authored
feat(ai): add message WAL drain topology (#13890) (#13906)
* feat(ai): add message WAL drain topology (#13890) * fix(ai): keep message drain inactive without replay processor (#13890)
1 parent 8114360 commit 0593fa9

21 files changed

Lines changed: 855 additions & 85 deletions

File tree

ai/config.template.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,10 @@ class Config extends ConfigProvider {
577577
// process; cloud deployments own their drain story per-container (mirror of
578578
// the chromaDaemonEnabled split).
579579
embedDaemonEnabled : leaf(null, 'NEO_ORCHESTRATOR_EMBED_DAEMON_ENABLED', 'boolean'),
580+
// The message daemon observes the accepted A2A-message WAL. Local profile may
581+
// supervise it as a child process; cloud deployments use Memory Core's
582+
// messageWal.inProcessDrain host mode instead.
583+
messageDaemonEnabled : leaf(null, 'NEO_ORCHESTRATOR_MESSAGE_DAEMON_ENABLED', 'boolean'),
580584
goldenPathRepoEnrichmentEnabled: leaf(null, 'NEO_ORCHESTRATOR_GOLDEN_PATH_REPO_ENRICHMENT_ENABLED', 'boolean'),
581585
// `null` = use the deployment-profile default (local enables, cloud disables);
582586
// the swarm-heartbeat lane emits wake-substrate pulses through bridge delivery.

ai/daemons/embed/drainLock.mjs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,23 @@ import path from 'path';
3434
*/
3535
export const DRAIN_LOCK_FILENAME = '.drain-lock';
3636

37+
const DEFAULT_LOCK_LABEL = 'WAL';
38+
const DEFAULT_REMEDIATION =
39+
'Disable one drain host for this deployment — the embed daemon OR memoryWal.inProcessDrain.';
40+
3741
/**
3842
* @summary Error thrown when the drain lock is already held by a DIFFERENT live host. Carries the
3943
* holder descriptor so the caller can fail loud with an actionable, holder-naming message.
4044
*/
4145
export class DrainLockHeldError extends Error {
42-
constructor({lockPath, holder, requester}) {
46+
constructor({lockPath, holder, requester, lockLabel = DEFAULT_LOCK_LABEL, remediation = DEFAULT_REMEDIATION}) {
4347
const who = holder
4448
? `${holder.owner} pid ${holder.pid} (since ${holder.startedAt})`
4549
: 'an unknown live process';
4650

47-
super(`WAL drain lock ${lockPath} is held by ${who}; ${requester.owner} pid ${requester.pid} ` +
51+
super(`${lockLabel} drain lock ${lockPath} is held by ${who}; ${requester.owner} pid ${requester.pid} ` +
4852
'refuses to start a second drain loop on the same WAL directory (sole-drainer invariant). ' +
49-
'Disable one drain host for this deployment — the embed daemon OR memoryWal.inProcessDrain.');
53+
remediation);
5054

5155
this.name = 'DrainLockHeldError';
5256
this.code = 'DRAIN_LOCK_HELD';
@@ -150,7 +154,9 @@ export function acquireDrainLock({
150154
now = Date.now,
151155
fs: fsImpl = fs,
152156
isAlive = defaultIsAlive,
153-
log = () => {}
157+
log = () => {},
158+
lockLabel = DEFAULT_LOCK_LABEL,
159+
remediation = DEFAULT_REMEDIATION
154160
} = {}) {
155161
const lockPath = path.join(dir, DRAIN_LOCK_FILENAME);
156162

@@ -172,7 +178,7 @@ export function acquireDrainLock({
172178
const holder = readHolder(lockPath, fsImpl);
173179

174180
if (holder && holder.pid !== pid && isAlive(holder.pid)) {
175-
throw new DrainLockHeldError({lockPath, holder, requester: {owner, pid}});
181+
throw new DrainLockHeldError({lockPath, holder, requester: {owner, pid}, lockLabel, remediation});
176182
}
177183

178184
// Stale (dead holder), our own leftover, or corrupt → reclaim and retry the wx claim.
@@ -186,5 +192,5 @@ export function acquireDrainLock({
186192
}
187193

188194
// Both passes lost the wx race: another host re-claimed between our unlink and re-claim.
189-
throw new DrainLockHeldError({lockPath, holder: readHolder(lockPath, fsImpl), requester: {owner, pid}});
195+
throw new DrainLockHeldError({lockPath, holder: readHolder(lockPath, fsImpl), requester: {owner, pid}, lockLabel, remediation});
190196
}

ai/daemons/message/daemon.mjs

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/**
2+
* @summary Local supervised daemon for the A2A message WAL drain topology.
3+
*
4+
* Mirrors the memory embed daemon's deployment shape: local/orchestrator-managed profiles run this
5+
* process with PID/log state under `.neo-ai-data`, while containerized/single-process deployments
6+
* host the same loop inside Memory Core via `messageWal.inProcessDrain`. The graph replay
7+
* processor is intentionally injectable so topology can land before projection semantics.
8+
*/
9+
import Neo from '../../../src/Neo.mjs';
10+
import * as core from '../../../src/core/_export.mjs';
11+
import InstanceManager from '../../../src/manager/Instance.mjs';
12+
import memoryCoreConfig from '../../mcp/server/memory-core/config.mjs';
13+
14+
import fs from 'fs-extra';
15+
import path from 'path';
16+
import {execSync} from 'child_process';
17+
18+
import {startMessageDrainLoop} from './drainCycle.mjs';
19+
import {acquireMessageDrainLock} from './drainLock.mjs';
20+
import {getMissingMessageWalLeaves} from '../../services/memory-core/helpers/messageWalStore.mjs';
21+
22+
const missingLeaves = getMissingMessageWalLeaves(memoryCoreConfig.messageWal,
23+
['dir', 'daemonDataDir', 'pollIntervalMs', 'batchSize', 'maxRetries', 'backoffBaseMs']);
24+
25+
if (missingLeaves.length > 0) {
26+
console.error(`[Message Daemon] messageWal config leaves missing: ${missingLeaves.join(', ')} — ` +
27+
'sync the messageWal block from config.template.mjs into the local config.mjs ' +
28+
'(node ai/scripts/setup/initServerConfigs.mjs --migrate-config) and restart. Exiting.');
29+
process.exit(1);
30+
}
31+
32+
const DAEMON_DATA_DIR = memoryCoreConfig.messageWal.daemonDataDir;
33+
const LOG_FILE = path.join(DAEMON_DATA_DIR, 'message-daemon.log');
34+
const PID_FILE = path.join(DAEMON_DATA_DIR, 'message-daemon.pid');
35+
const LOG_RETENTION_DAYS = 30;
36+
37+
fs.ensureDirSync(DAEMON_DATA_DIR);
38+
39+
function rotateLogIfNewDay() {
40+
if (!fs.existsSync(LOG_FILE)) return;
41+
try {
42+
const stats = fs.statSync(LOG_FILE);
43+
const fileDay = stats.mtime.toISOString().split('T')[0];
44+
const todayDay = new Date().toISOString().split('T')[0];
45+
if (fileDay !== todayDay) {
46+
fs.renameSync(LOG_FILE, `${LOG_FILE}.${fileDay}`);
47+
}
48+
} catch (e) {
49+
process.stderr.write(`[Message Daemon] Log rotation failed: ${e.message}\n`);
50+
}
51+
}
52+
53+
function pruneOldLogs() {
54+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
55+
try {
56+
for (const entry of fs.readdirSync(DAEMON_DATA_DIR)) {
57+
if (!entry.startsWith('message-daemon.log.') || entry === 'message-daemon.log') continue;
58+
const fullPath = path.join(DAEMON_DATA_DIR, entry);
59+
try {
60+
if (fs.statSync(fullPath).mtime.getTime() < cutoff) {
61+
fs.unlinkSync(fullPath);
62+
}
63+
} catch (e) {}
64+
}
65+
} catch (e) {}
66+
}
67+
68+
function writeLog(level, message) {
69+
rotateLogIfNewDay();
70+
const line = `[${new Date().toISOString()}] [PID:${process.pid}] [${level}] ${message}`;
71+
72+
try {
73+
fs.appendFileSync(LOG_FILE, line + '\n', 'utf8');
74+
} catch (e) {}
75+
76+
if (level === 'ERROR') {
77+
console.error(line);
78+
} else {
79+
console.log(line);
80+
}
81+
}
82+
83+
pruneOldLogs();
84+
85+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
86+
87+
async function enforceSingleton() {
88+
if (fs.existsSync(PID_FILE)) {
89+
try {
90+
const oldPid = parseInt(fs.readFileSync(PID_FILE, 'utf8'), 10);
91+
if (!isNaN(oldPid) && oldPid > 0 && oldPid !== process.pid) {
92+
let isAlive = false;
93+
try {
94+
process.kill(oldPid, 0);
95+
isAlive = true;
96+
} catch (e) {}
97+
98+
if (isAlive) {
99+
try {
100+
const cmd = execSync(`ps -p ${oldPid} -o command=`).toString().trim();
101+
if (cmd.includes('daemons/message/daemon.mjs')) {
102+
writeLog('INFO', `[Message Daemon] Found existing instance (PID: ${oldPid}). Sending SIGTERM...`);
103+
process.kill(oldPid, 'SIGTERM');
104+
} else {
105+
writeLog('INFO', `[Message Daemon] Stale PID file found. PID ${oldPid} used by a different process. Proceeding.`);
106+
isAlive = false;
107+
}
108+
} catch (psErr) {
109+
writeLog('INFO', `[Message Daemon] Could not verify process name. Sending SIGTERM to PID ${oldPid} to be safe...`);
110+
process.kill(oldPid, 'SIGTERM');
111+
}
112+
}
113+
114+
if (isAlive) {
115+
let alive = true;
116+
for (let i = 0; i < 30; i++) {
117+
await wait(100);
118+
try {
119+
process.kill(oldPid, 0);
120+
} catch (e) {
121+
alive = false;
122+
break;
123+
}
124+
}
125+
if (alive) {
126+
writeLog('INFO', `[Message Daemon] PID ${oldPid} did not exit after 3s. Escalating to SIGKILL...`);
127+
try {
128+
process.kill(oldPid, 'SIGKILL');
129+
} catch (e) {}
130+
}
131+
}
132+
133+
try {
134+
fs.unlinkSync(PID_FILE);
135+
} catch (e) {}
136+
}
137+
} catch (e) {
138+
writeLog('ERROR', `[Message Daemon] Failed to check existing PID file: ${e.message || e}`);
139+
}
140+
}
141+
142+
try {
143+
fs.writeFileSync(PID_FILE, process.pid.toString(), {encoding: 'utf8', flag: 'wx'});
144+
} catch (e) {
145+
if (e.code === 'EEXIST') {
146+
writeLog('ERROR', '[Message Daemon] Failed to claim PID file (EEXIST). Another instance started simultaneously. Exiting.');
147+
process.exit(1);
148+
}
149+
throw e;
150+
}
151+
152+
let cleanedUp = false;
153+
const cleanup = () => {
154+
if (cleanedUp) return;
155+
cleanedUp = true;
156+
try {
157+
if (fs.existsSync(PID_FILE)) {
158+
const currentPid = parseInt(fs.readFileSync(PID_FILE, 'utf8'), 10);
159+
if (currentPid === process.pid) {
160+
fs.unlinkSync(PID_FILE);
161+
}
162+
}
163+
} catch (e) {}
164+
process.exit();
165+
};
166+
167+
process.on('SIGINT', cleanup);
168+
process.on('SIGTERM', cleanup);
169+
process.on('exit', cleanup);
170+
process.on('uncaughtException', err => {
171+
writeLog('ERROR', `[Message Daemon] Uncaught exception: ${err && err.stack ? err.stack : err}`);
172+
cleanup();
173+
});
174+
}
175+
176+
async function main() {
177+
await enforceSingleton();
178+
179+
let drainLock;
180+
try {
181+
drainLock = acquireMessageDrainLock({dir: memoryCoreConfig.messageWal.dir, owner: 'daemon', log: writeLog});
182+
} catch (err) {
183+
if (err.code === 'DRAIN_LOCK_HELD') {
184+
writeLog('ERROR', `[Message Daemon] ${err.message} Exiting.`);
185+
process.exit(1);
186+
}
187+
throw err;
188+
}
189+
190+
process.on('exit', () => drainLock.release());
191+
192+
writeLog('INFO', `[Message Daemon] Started. Observing message WAL dir: ${memoryCoreConfig.messageWal.dir} (poll: ${memoryCoreConfig.messageWal.pollIntervalMs}ms, batch: ${memoryCoreConfig.messageWal.batchSize})`);
193+
194+
startMessageDrainLoop({
195+
getConfig: () => memoryCoreConfig.messageWal,
196+
log : writeLog
197+
});
198+
}
199+
200+
main();

0 commit comments

Comments
 (0)