memorix 1.8.9 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +6 -0
  4. package/dist/cli/index.js +3261 -1348
  5. package/dist/index.js +324 -187
  6. package/dist/maintenance-runner.js +285 -103
  7. package/dist/media-audio-runner.js +285 -103
  8. package/dist/media-video-runner.js +285 -103
  9. package/dist/memcode-runtime/CHANGELOG.md +18 -0
  10. package/dist/sdk.js +323 -186
  11. package/dist/vector-backfill-runner.d.ts +4 -1
  12. package/dist/vector-backfill-runner.js +288 -104
  13. package/docs/API_REFERENCE.md +14 -0
  14. package/docs/CONFIGURATION.md +46 -0
  15. package/docs/DEVELOPMENT.md +2 -2
  16. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
  17. package/docs/PERFORMANCE.md +32 -0
  18. package/docs/README.md +4 -2
  19. package/llms-full.txt +1 -0
  20. package/llms.txt +1 -0
  21. package/package.json +1 -1
  22. package/plugins/antigravity/memorix/plugin.json +1 -1
  23. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  24. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  25. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  26. package/plugins/copilot/memorix/plugin.json +1 -1
  27. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  28. package/plugins/hermes/memorix/plugin.yaml +1 -1
  29. package/plugins/omp/memorix/package.json +1 -1
  30. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/pi/memorix/package.json +1 -1
  32. package/server.json +2 -2
  33. package/src/cli/command-guide.ts +9 -1
  34. package/src/cli/commands/serve-http.ts +11 -0
  35. package/src/cli/commands/sync-store.ts +191 -0
  36. package/src/cli/commands/sync.ts +8 -0
  37. package/src/memory/observations.ts +15 -2
  38. package/src/runtime/maintenance-targets.ts +6 -0
  39. package/src/runtime/vector-backfill-runner.ts +185 -7
  40. package/src/store/orama-store.ts +85 -102
  41. package/src/sync/adapters/fs.ts +179 -0
  42. package/src/sync/adapters/github.ts +258 -0
  43. package/src/sync/adapters/object-store.ts +267 -0
  44. package/src/sync/adapters/postgres.ts +269 -0
  45. package/src/sync/engine.ts +353 -0
  46. package/src/sync/journal.ts +162 -0
  47. package/src/sync/merge.ts +116 -0
  48. package/src/sync/namespace.ts +15 -0
  49. package/src/sync/paging.ts +45 -0
  50. package/src/sync/policy.ts +37 -0
  51. package/src/sync/remote-factory.ts +49 -0
  52. package/src/sync/store-port.ts +288 -0
  53. package/src/sync/types.ts +183 -0
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { defineCommand } from 'citty';
23
23
  import type { IncomingMessage, ServerResponse } from 'node:http';
24
+ import { getHeapStatistics } from 'node:v8';
24
25
  import type { ObservationStore } from '../../store/obs-store.js';
25
26
  import { resolveToolProfile } from '../../server/tool-profile.js';
26
27
  import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
@@ -1714,6 +1715,8 @@ export default defineCommand({
1714
1715
  // This is the readiness signal for background start / status.
1715
1716
  if (url.pathname === '/health') {
1716
1717
  const { getEmbeddingRuntimeHealth } = await import('../../embedding/provider.js');
1718
+ const memory = process.memoryUsage();
1719
+ const heap = getHeapStatistics();
1717
1720
  res.writeHead(200, { 'Content-Type': 'application/json' });
1718
1721
  res.end(JSON.stringify({
1719
1722
  status: 'ok',
@@ -1725,6 +1728,14 @@ export default defineCommand({
1725
1728
  projectId: defaultProject?.id ?? '__unresolved__',
1726
1729
  projectName: defaultProject?.name ?? null,
1727
1730
  readyAt: httpReadyAt,
1731
+ memory: {
1732
+ rss: memory.rss,
1733
+ heapUsed: memory.heapUsed,
1734
+ heapTotal: memory.heapTotal,
1735
+ external: memory.external,
1736
+ arrayBuffers: memory.arrayBuffers,
1737
+ heapLimit: heap.heap_size_limit,
1738
+ },
1728
1739
  embedding: getEmbeddingRuntimeHealth(),
1729
1740
  }));
1730
1741
  return;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `memorix sync store <push|pull|status>` — multi-device store replication.
3
+ *
4
+ * Separated from `memorix sync rules|workspace` (cross-agent rules) so the two
5
+ * meanings of "sync" never collide. Store sync is opt-in and provider-agnostic;
6
+ * the remote is selected by `MEMORIX_SYNC_PROVIDER`.
7
+ */
8
+
9
+ import { defineCommand } from 'citty';
10
+
11
+ import { getCliProjectContext, emitError, emitResult } from './operator-shared.js';
12
+ import { getObservationStore, initObservationStore } from '../../store/obs-store.js';
13
+ import { runSync } from '../../sync/engine.js';
14
+ import { createSqliteSyncStore } from '../../sync/store-port.js';
15
+ import { resolveSyncConfig, createRemote } from '../../sync/remote-factory.js';
16
+ import type { SyncReport } from '../../sync/types.js';
17
+
18
+ function renderReport(report: SyncReport): string {
19
+ const lines = [
20
+ `Store sync (${report.remote}) — device ${report.deviceId}${report.dryRun ? ' [dry-run]' : ''}`,
21
+ `- Pushed changes: ${report.pushed}`,
22
+ `- Pulled batches: ${report.pulledBatches}`,
23
+ `- Applied upserts: ${report.applied}`,
24
+ `- Applied deletes: ${report.tombstoned}`,
25
+ `- Skipped (older): ${report.skipped}`,
26
+ `- Excluded locally: ${report.excluded}`,
27
+ `- Conflicts recorded: ${report.conflicts}`,
28
+ `- Pending outbox: ${report.pending}`,
29
+ ];
30
+ if (report.dryRun && report.decisions.length > 0) {
31
+ lines.push('', 'Decisions:');
32
+ for (const d of report.decisions) {
33
+ lines.push(` ${d.syncKey} ${d.kind} -> ${d.outcome} (${d.reason})`);
34
+ }
35
+ }
36
+ return lines.join('\n');
37
+ }
38
+
39
+ export default defineCommand({
40
+ meta: {
41
+ name: 'sync-store',
42
+ description: 'Replicate the local observation store across your devices (opt-in, provider-agnostic)',
43
+ },
44
+ args: {
45
+ dry: { type: 'boolean', description: 'Preview changes without writing anything', default: false },
46
+ json: { type: 'boolean', description: 'Emit JSON', default: false },
47
+ 'push-only': { type: 'boolean', description: 'Only push local changes', default: false },
48
+ 'pull-only': { type: 'boolean', description: 'Only pull remote changes', default: false },
49
+ through: { type: 'string', description: 'Compaction cutoff: device=sequence,device=sequence' },
50
+ yes: { type: 'boolean', description: 'Confirm destructive remote compaction', default: false },
51
+ },
52
+ run: async ({ args }) => {
53
+ const asJson = Boolean(args.json);
54
+ const action = (args._ as string[])?.[0] || 'status';
55
+ if (action === 'device') {
56
+ const { project, dataDir } = await getCliProjectContext();
57
+ await initObservationStore(dataDir);
58
+ const syncStore = createSqliteSyncStore(dataDir, getObservationStore(), project.id);
59
+ const subaction = (args._ as string[])?.[1] || '';
60
+ if (subaction !== 'rotate') {
61
+ emitError('expected "memorix sync store device rotate"', asJson);
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ const deviceId = syncStore.rotateDevice();
66
+ emitResult({ project: project.id, deviceId, rotated: true }, `Sync device identity rotated: ${deviceId}`, asJson);
67
+ return;
68
+ }
69
+ if (action !== 'push' && action !== 'pull' && action !== 'status' && action !== 'compact') {
70
+ emitError(`unknown action "${action}" (expected push|pull|status|compact|device rotate)`, asJson);
71
+ process.exitCode = 2;
72
+ return;
73
+ }
74
+
75
+ let config;
76
+ try {
77
+ config = resolveSyncConfig();
78
+ } catch (err) {
79
+ emitError((err as Error).message, asJson);
80
+ process.exitCode = 2;
81
+ return;
82
+ }
83
+
84
+ if (!config.enabled || !config.provider) {
85
+ emitResult(
86
+ { enabled: false },
87
+ 'Store sync is disabled. Set MEMORIX_SYNC_PROVIDER=fs|github|s3|postgres to enable it.',
88
+ asJson,
89
+ );
90
+ return;
91
+ }
92
+
93
+ const { project, dataDir } = await getCliProjectContext();
94
+ await initObservationStore(dataDir);
95
+ const obsStore = getObservationStore();
96
+ const syncStore = createSqliteSyncStore(dataDir, obsStore, project.id);
97
+
98
+ if (action === 'compact') {
99
+ if (!args.yes && !args.dry) {
100
+ emitError('remote compaction deletes relay events; repeat with --yes after specifying --through device=sequence', asJson);
101
+ process.exitCode = 2;
102
+ return;
103
+ }
104
+ const raw = String(args.through ?? '');
105
+ const through: Record<string, number> = {};
106
+ for (const item of raw.split(',').map((value) => value.trim()).filter(Boolean)) {
107
+ const [device, sequence] = item.split('=', 2);
108
+ const parsed = Number(sequence);
109
+ if (!device || !Number.isSafeInteger(parsed) || parsed < 0) {
110
+ emitError('invalid --through; expected device=sequence[,device=sequence]', asJson);
111
+ process.exitCode = 2;
112
+ return;
113
+ }
114
+ through[device] = parsed;
115
+ }
116
+ if (Object.keys(through).length === 0) {
117
+ emitError('--through is required for compaction', asJson);
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+ let remote;
122
+ try {
123
+ remote = await createRemote(config.provider, syncStore.namespace());
124
+ } catch (err) {
125
+ emitError((err as Error).message, asJson);
126
+ process.exitCode = 1;
127
+ return;
128
+ }
129
+ try {
130
+ await remote.init({ create: false });
131
+ const result = await remote.compact(through, { dryRun: Boolean(args.dry) });
132
+ emitResult({ project: project.id, through, ...result, dryRun: Boolean(args.dry) }, `Compaction candidates: ${result.candidates}; deleted: ${result.deleted}`, asJson);
133
+ } catch (err) {
134
+ emitError((err as Error).message, asJson);
135
+ process.exitCode = 1;
136
+ } finally {
137
+ await remote.close();
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (action === 'status') {
143
+ let remote;
144
+ try {
145
+ remote = await createRemote(config.provider, syncStore.namespace());
146
+ } catch (err) {
147
+ emitError((err as Error).message, asJson);
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ try {
152
+ const report = await runSync(syncStore, remote, {
153
+ deviceId: syncStore.deviceId(),
154
+ dryRun: true,
155
+ push: true,
156
+ pull: true,
157
+ });
158
+ emitResult({ project: project.id, ...report }, renderReport(report), asJson);
159
+ } catch (err) {
160
+ emitError((err as Error).message, asJson);
161
+ process.exitCode = 1;
162
+ }
163
+ return;
164
+ }
165
+
166
+ const doPush = action === 'push' || !args['pull-only'];
167
+ const doPull = action === 'pull' || !args['push-only'];
168
+
169
+ let remote;
170
+ try {
171
+ remote = await createRemote(config.provider, syncStore.namespace());
172
+ } catch (err) {
173
+ emitError((err as Error).message, asJson);
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+
178
+ try {
179
+ const report = await runSync(syncStore, remote, {
180
+ deviceId: syncStore.deviceId(),
181
+ dryRun: Boolean(args.dry),
182
+ push: doPush,
183
+ pull: doPull,
184
+ });
185
+ emitResult({ project: project.id, ...report }, renderReport(report), asJson);
186
+ } catch (err) {
187
+ emitError((err as Error).message, asJson);
188
+ process.exitCode = 1;
189
+ }
190
+ },
191
+ });
@@ -51,6 +51,14 @@ export default defineCommand({
51
51
  },
52
52
  run: async ({ args }) => {
53
53
  const section = (args._ as string[])?.[0] || '';
54
+
55
+ // Store replication is a distinct concern from rules/workspace sync.
56
+ if (section === 'store') {
57
+ const storeCmd = (await import('./sync-store.js')).default;
58
+ const rest = (args._ as string[]).slice(1);
59
+ await storeCmd.run!({ args: { ...args, _: rest } } as never);
60
+ return;
61
+ }
54
62
  const asJson = !!args.json;
55
63
 
56
64
  if (section === 'rules' || section === 'workspace') {
@@ -83,6 +83,7 @@ export function resetObservationRuntime(): void {
83
83
  embeddingWorkerProjectRoot = undefined;
84
84
  vectorMissingIds.clear();
85
85
  vectorSchemaUpgradePromise = null;
86
+ embeddingFailureLogTimestamps.clear();
86
87
  }
87
88
 
88
89
  // ── Vector-missing tracking ──────────────────────────────────────
@@ -100,11 +101,17 @@ let lastVectorBackfill: {
100
101
  } | null = null;
101
102
  const embeddingFailureLogTimestamps = new Map<string, number>();
102
103
  const EMBEDDING_FAILURE_LOG_COOLDOWN_MS = 30_000;
104
+ const MAX_EMBEDDING_FAILURE_KEYS = 1_024;
103
105
 
104
106
  function logEmbeddingFailureOnce(key: string, message: string): void {
105
107
  const now = Date.now();
106
108
  const last = embeddingFailureLogTimestamps.get(key) ?? 0;
107
109
  if (now - last < EMBEDDING_FAILURE_LOG_COOLDOWN_MS) return;
110
+ if (!embeddingFailureLogTimestamps.has(key) && embeddingFailureLogTimestamps.size >= MAX_EMBEDDING_FAILURE_KEYS) {
111
+ const oldest = embeddingFailureLogTimestamps.keys().next().value;
112
+ if (oldest !== undefined) embeddingFailureLogTimestamps.delete(oldest);
113
+ }
114
+ embeddingFailureLogTimestamps.delete(key);
108
115
  embeddingFailureLogTimestamps.set(key, now);
109
116
  console.error(message);
110
117
  }
@@ -149,13 +156,19 @@ function queueVectorBackfill(
149
156
  if (!dataDir) return;
150
157
  void import('../runtime/maintenance-jobs.js')
151
158
  .then(({ MaintenanceJobStore }) => {
152
- new MaintenanceJobStore(dataDir).enqueue({
159
+ const job = new MaintenanceJobStore(dataDir).enqueue({
153
160
  projectId,
154
161
  kind: 'vector-backfill',
155
162
  dedupeKey: 'vector-backfill',
156
163
  payload: { limit: 12 },
157
164
  });
158
- if (options.detachedWorker && options.projectRoot) {
165
+ // A retrying job is intentionally in cooldown. Starting a new detached
166
+ // process for every hook event would turn one provider outage into a
167
+ // process storm even though the durable queue already deduplicated it.
168
+ // Keep compatibility with lightweight test/embedding adapters that only
169
+ // implement enqueue and return no job receipt.
170
+ const due = !job || (job.status === 'pending' && job.runAfter <= Date.now());
171
+ if (due && options.detachedWorker && options.projectRoot) {
159
172
  void import('../runtime/vector-backfill-runner.js')
160
173
  .then(({ launchDetachedVectorBackfill }) => {
161
174
  launchDetachedVectorBackfill({
@@ -56,4 +56,10 @@ export class MaintenanceTargetStore {
56
56
  `).get(projectId);
57
57
  return row ? rowToTarget(row) : undefined;
58
58
  }
59
+
60
+ list(): MaintenanceTarget[] {
61
+ return (this.db.prepare(`
62
+ SELECT * FROM maintenance_targets ORDER BY updated_at DESC
63
+ `).all() as any[]).map(rowToTarget);
64
+ }
59
65
  }
@@ -1,5 +1,14 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
3
12
  import path from 'node:path';
4
13
  import { fileURLToPath } from 'node:url';
5
14
 
@@ -11,8 +20,12 @@ import { initObservationStore } from '../store/obs-store.js';
11
20
  import { getDeferredCachedVectorHydration } from '../store/orama-store.js';
12
21
  import { closeAllDatabases } from '../store/sqlite-db.js';
13
22
  import { MaintenanceJobStore, MaintenanceJobWorker } from './maintenance-jobs.js';
23
+ import { MaintenanceTargetStore, type MaintenanceTarget } from './maintenance-targets.js';
14
24
  import { createProjectMaintenanceHandler } from './project-maintenance.js';
15
25
 
26
+ const VECTOR_BACKFILL_LOCK_FILE = '.memorix-vector-backfill.lock';
27
+ const STALE_LOCK_RECOVERY_MS = 24 * 60 * 60 * 1_000;
28
+
16
29
  export interface VectorBackfillRequest {
17
30
  projectId: string;
18
31
  projectRoot: string;
@@ -23,6 +36,20 @@ export interface VectorBackfillLauncherOptions {
23
36
  runnerPath?: string;
24
37
  exists?: (path: string) => boolean;
25
38
  spawn?: typeof spawn;
39
+ lockPath?: string;
40
+ }
41
+
42
+ interface VectorBackfillLock {
43
+ parentPid: number;
44
+ childPid?: number;
45
+ startedAt: number;
46
+ projectId: string;
47
+ }
48
+
49
+ interface VectorBackfillLockHandle {
50
+ path: string;
51
+ setChildPid(pid: number): void;
52
+ release(): void;
26
53
  }
27
54
 
28
55
  function isNonEmptyString(value: unknown): value is string {
@@ -68,6 +95,111 @@ export function parseVectorBackfillRequest(raw: string): VectorBackfillRequest {
68
95
  };
69
96
  }
70
97
 
98
+ export function vectorBackfillLockPath(dataDir: string): string {
99
+ return path.join(dataDir, VECTOR_BACKFILL_LOCK_FILE);
100
+ }
101
+
102
+ function processIsAlive(pid: number | undefined): boolean {
103
+ if (!Number.isSafeInteger(pid) || pid == null || pid <= 0) return false;
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (error) {
108
+ // EPERM means the process exists but this process cannot signal it.
109
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
110
+ }
111
+ }
112
+
113
+ function readVectorBackfillLock(lockPath: string): VectorBackfillLock | undefined {
114
+ try {
115
+ const value = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<VectorBackfillLock>;
116
+ if (
117
+ typeof value.parentPid !== 'number'
118
+ || !Number.isSafeInteger(value.parentPid)
119
+ || typeof value.startedAt !== 'number'
120
+ || !Number.isFinite(value.startedAt)
121
+ || typeof value.projectId !== 'string'
122
+ ) return undefined;
123
+ return {
124
+ parentPid: value.parentPid,
125
+ ...(typeof value.childPid === 'number' ? { childPid: value.childPid } : {}),
126
+ startedAt: value.startedAt,
127
+ projectId: value.projectId,
128
+ };
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ function writeVectorBackfillLock(lockPath: string, value: VectorBackfillLock): void {
135
+ writeFileSync(lockPath, `${JSON.stringify(value)}\n`, 'utf8');
136
+ }
137
+
138
+ function acquireVectorBackfillLock(
139
+ dataDir: string,
140
+ projectId: string,
141
+ preferredPath?: string,
142
+ ): VectorBackfillLockHandle | undefined {
143
+ const lockPath = preferredPath ?? vectorBackfillLockPath(dataDir);
144
+ mkdirSync(path.dirname(lockPath), { recursive: true });
145
+ const initial: VectorBackfillLock = {
146
+ parentPid: process.pid,
147
+ startedAt: Date.now(),
148
+ projectId,
149
+ };
150
+
151
+ for (let attempt = 0; attempt < 2; attempt++) {
152
+ try {
153
+ const fd = openSync(lockPath, 'wx');
154
+ try {
155
+ writeFileSync(fd, `${JSON.stringify(initial)}\n`, 'utf8');
156
+ } finally {
157
+ closeSync(fd);
158
+ }
159
+ return {
160
+ path: lockPath,
161
+ setChildPid(pid: number) {
162
+ try {
163
+ writeVectorBackfillLock(lockPath, { ...initial, childPid: pid });
164
+ } catch {
165
+ // The child can still release the lock on normal shutdown; a dead
166
+ // owner is recoverable on the next launch.
167
+ }
168
+ },
169
+ release() {
170
+ try { unlinkSync(lockPath); } catch { /* already released */ }
171
+ },
172
+ };
173
+ } catch (error) {
174
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return undefined;
175
+ const existing = readVectorBackfillLock(lockPath);
176
+ const ownerAlive = existing && (processIsAlive(existing.childPid) || processIsAlive(existing.parentPid));
177
+ if (ownerAlive) return undefined;
178
+
179
+ // A malformed lock is not deleted immediately: another process may be
180
+ // between create and write. A dead, well-formed owner is safe to reap;
181
+ // an unreadable lock needs a long recovery window so it cannot cause a
182
+ // second heavy worker during a slow filesystem operation.
183
+ let stale = false;
184
+ try {
185
+ stale = Date.now() - statSync(lockPath).mtimeMs >= STALE_LOCK_RECOVERY_MS;
186
+ } catch {
187
+ stale = false;
188
+ }
189
+ if (!existing && !stale) return undefined;
190
+ try { unlinkSync(lockPath); } catch { return undefined; }
191
+ }
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ export function releaseVectorBackfillLock(lockPath = process.env.MEMORIX_VECTOR_BACKFILL_LOCK): void {
197
+ if (!lockPath) return;
198
+ const lock = readVectorBackfillLock(lockPath);
199
+ if (lock && lock.childPid !== process.pid) return;
200
+ try { unlinkSync(lockPath); } catch { /* already released */ }
201
+ }
202
+
71
203
  /**
72
204
  * Start a detached one-shot worker. The caller has already persisted the
73
205
  * observation and durable vector job, so failure to start is recoverable by a
@@ -81,6 +213,9 @@ export function launchDetachedVectorBackfill(
81
213
  const exists = options.exists ?? existsSync;
82
214
  if (!exists(runnerPath)) return false;
83
215
 
216
+ const lock = acquireVectorBackfillLock(request.dataDir, request.projectId, options.lockPath);
217
+ if (!lock) return false;
218
+
84
219
  try {
85
220
  const child = (options.spawn ?? spawn)(process.execPath, [runnerPath], {
86
221
  cwd: request.projectRoot,
@@ -90,13 +225,24 @@ export function launchDetachedVectorBackfill(
90
225
  stdio: 'ignore',
91
226
  // The request contains only local project metadata, never credentials.
92
227
  // Environment transport avoids a live stdin pipe keeping the CLI alive.
93
- env: { ...process.env, MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request) },
228
+ env: {
229
+ ...process.env,
230
+ MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request),
231
+ MEMORIX_VECTOR_BACKFILL_LOCK: lock.path,
232
+ },
94
233
  windowsHide: true,
95
234
  }) as ChildProcess;
235
+ if (child.pid) lock.setChildPid(child.pid);
236
+ else lock.release();
96
237
  child.once?.('error', () => {});
238
+ child.once?.('close', () => lock.release());
239
+ // Test doubles and unusual spawn adapters may not expose ChildProcess
240
+ // events. Do not leave a lock behind when there is no lifecycle to own it.
241
+ if (typeof child.once !== 'function') lock.release();
97
242
  child.unref();
98
- return true;
243
+ return Boolean(child.pid);
99
244
  } catch {
245
+ lock.release();
100
246
  return false;
101
247
  }
102
248
  }
@@ -110,12 +256,43 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
110
256
  await prepareSearchIndex();
111
257
  await getDeferredCachedVectorHydration()?.catch(() => {});
112
258
 
259
+ const queue = new MaintenanceJobStore(request.dataDir);
260
+ const targets = new MaintenanceTargetStore(request.dataDir);
261
+ let preparedRoot = request.projectRoot;
262
+ const prepareTarget = async (target: MaintenanceTarget): Promise<void> => {
263
+ if (target.projectRoot === preparedRoot) return;
264
+ initProjectRoot(target.projectRoot);
265
+ loadDotenv(target.projectRoot);
266
+ preparedRoot = target.projectRoot;
267
+ // The data directory is shared across projects. The in-process index is
268
+ // intentionally global, so switching the target only changes the config
269
+ // and the job scope; it does not create another corpus/index copy.
270
+ };
271
+
272
+ const handler = async (job: Parameters<ReturnType<typeof createProjectMaintenanceHandler>>[0]) => {
273
+ const target = targets.get(job.projectId)
274
+ ?? (job.projectId === request.projectId
275
+ ? { projectId: request.projectId, projectRoot: request.projectRoot, dataDir: request.dataDir, updatedAt: Date.now() }
276
+ : undefined);
277
+ if (!target) return { action: 'reschedule' as const, delayMs: 30_000 };
278
+ await prepareTarget(target);
279
+ return createProjectMaintenanceHandler(job.projectId, target.dataDir, target.projectRoot)(job);
280
+ };
281
+
113
282
  const worker = new MaintenanceJobWorker(
114
- new MaintenanceJobStore(request.dataDir),
115
- createProjectMaintenanceHandler(request.projectId, request.dataDir, request.projectRoot),
116
- { projectId: request.projectId, kinds: ['vector-backfill'] },
283
+ queue,
284
+ handler,
285
+ { kinds: ['vector-backfill'] },
117
286
  );
118
- return worker.runOnce();
287
+ // Drain currently due vector jobs while this one process owns the lock. A
288
+ // hook storm therefore creates at most one heavy process, and pending jobs
289
+ // for other projects are picked up without requiring another hook event.
290
+ let result: Awaited<ReturnType<typeof worker.runOnce>> = { state: 'idle' };
291
+ while (true) {
292
+ result = await worker.runOnce();
293
+ if (result.state === 'idle') break;
294
+ }
295
+ return result;
119
296
  }
120
297
 
121
298
  async function readStdin(): Promise<string> {
@@ -137,6 +314,7 @@ export async function main(): Promise<void> {
137
314
  process.stderr.write(`[memorix] vector backfill worker failed: ${detail}\n`);
138
315
  process.exitCode = 1;
139
316
  } finally {
317
+ releaseVectorBackfillLock();
140
318
  closeAllDatabases();
141
319
  }
142
320
  }