memorix 1.2.7 → 1.2.8

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.
@@ -339,3 +339,28 @@
339
339
  isolated Claude Code handoff/continuation smoke. `npm publish --dry-run
340
340
  --ignore-scripts` accepted the public 1.2.7 tarball; the full prepublish
341
341
  build/test path is covered by the root release gates above.
342
+
343
+ ## 1.2.8 Vector Recovery and Codex Installation Clarity
344
+ - Reworked vector backfill failure handling so transient provider or local
345
+ index failures leave one durable, diagnosable retry with bounded backoff
346
+ instead of consuming the maintenance retry budget in each new MCP process.
347
+ Healthy work clears stale diagnostics, revives an old failed vector job when
348
+ needed, and resolves superseded historical failure rows once recovery is
349
+ complete. Provider calls are batched while process-local Orama writes remain
350
+ serialized.
351
+ - Corrected dashboard and HTTP control-plane vector reporting. A process that
352
+ does not own a hydrated MCP search index now states that vector status belongs
353
+ to the active MCP session rather than showing a misleading green `0 / 0`.
354
+ - Corrected Codex install diagnostics: an enabled official Codex plugin owns
355
+ its `.mcp.json` `memorix serve` endpoint, while first-use hook approval is an
356
+ explicit host consent step rather than a broken installation. Plugin setup
357
+ removes only the legacy source-path `memorix` entry after successful plugin
358
+ installation; custom user-managed MCP entries are preserved.
359
+ - Release verification: focused regression tests (96 assertions), `npm run
360
+ lint`, production build, and the full root test suite (all passed, 280s).
361
+ A live OpenRouter `qwen/qwen3-embedding-8b` batch returned two 4096d vectors.
362
+ An isolated tarball install reported CLI version `1.2.8`; its stdio MCP
363
+ handshake exposed 44 tools and completed `memorix_project_context`, and its
364
+ CLI `context --json` path returned a structured Workset. `npm pack --dry-run`
365
+ and `npm publish --dry-run --ignore-scripts` both accepted the final tarball
366
+ without manifest normalization warnings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -11,8 +11,8 @@
11
11
  "packages/memcode"
12
12
  ],
13
13
  "bin": {
14
- "memorix": "./dist/cli/index.js",
15
- "memcode": "./dist/cli/memcode.js"
14
+ "memorix": "dist/cli/index.js",
15
+ "memcode": "dist/cli/memcode.js"
16
16
  },
17
17
  "main": "./dist/index.js",
18
18
  "types": "./dist/index.d.ts",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memorix",
4
- "version": "1.2.7",
4
+ "version": "1.2.8",
5
5
  "description": "Shared workspace memory for Claude Code and other AI coding agents.",
6
6
  "author": {
7
7
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -26,6 +26,8 @@ export interface AgentMcpCheck {
26
26
  exists: boolean;
27
27
  status: AgentIntegrationStatus;
28
28
  issues: string[];
29
+ /** This endpoint is supplied by an enabled Codex plugin, not config.toml. */
30
+ managedByPlugin?: boolean;
29
31
  server?: {
30
32
  transport: 'stdio' | 'http';
31
33
  command?: string;
@@ -229,6 +231,10 @@ function codexPluginPath(): string {
229
231
  return `${homedir()}/.codex/plugins/${CODEX_PLUGIN_NAME}`;
230
232
  }
231
233
 
234
+ function codexPluginMcpPath(): string {
235
+ return `${codexPluginPath()}/.mcp.json`;
236
+ }
237
+
232
238
  function codexMarketplacePath(): string {
233
239
  return `${homedir()}/.agents/plugins/marketplace.json`;
234
240
  }
@@ -267,6 +273,7 @@ async function inspectCodexPluginBundle(): Promise<AgentPluginCheck> {
267
273
  const pluginPath = codexPluginPath();
268
274
  const manifestPath = `${pluginPath}/.codex-plugin/plugin.json`;
269
275
  const hooksPath = `${pluginPath}/hooks/hooks.json`;
276
+ const mcpPath = codexPluginMcpPath();
270
277
  if (!existsSync(pluginPath)) {
271
278
  return {
272
279
  scope: 'global',
@@ -317,6 +324,20 @@ async function inspectCodexPluginBundle(): Promise<AgentPluginCheck> {
317
324
  if (version !== getCliVersion()) issues.push('codex-plugin-version-mismatch');
318
325
  if (manifest.hooks !== './hooks/hooks.json') issues.push('codex-hook-manifest-missing');
319
326
 
327
+ if (!existsSync(mcpPath)) {
328
+ issues.push('codex-plugin-mcp-manifest-missing');
329
+ } else {
330
+ try {
331
+ const mcpConfig = asRecord(JSON.parse(await readFile(mcpPath, 'utf-8')));
332
+ const server = coerceMcpServer('memorix', asRecord(mcpConfig?.mcpServers)?.memorix);
333
+ if (!server || !isRecommendedStdioServer(server)) {
334
+ issues.push('codex-plugin-mcp-manifest-invalid');
335
+ }
336
+ } catch {
337
+ issues.push('codex-plugin-mcp-manifest-unreadable');
338
+ }
339
+ }
340
+
320
341
  let declared: string[] = [];
321
342
  if (!existsSync(hooksPath)) {
322
343
  issues.push('codex-hook-manifest-missing');
@@ -479,7 +500,9 @@ async function inspectCodexHookTrust(): Promise<AgentPluginCheck> {
479
500
  kind: 'hook-trust',
480
501
  path: configPath,
481
502
  exists: true,
482
- status: issues.length > 0 ? 'repairable' : 'ok',
503
+ // Codex records hook approval only after the user has reviewed it. Memorix
504
+ // must not treat an intentional, host-owned consent step as a broken setup.
505
+ status: issues.length > 0 ? 'skipped' : 'ok',
483
506
  issues,
484
507
  hookTrust: { trusted, expected: [...CODEX_HOOK_STATE_NAMES] },
485
508
  };
@@ -562,7 +585,7 @@ function getClaudeLocalConfigPath(): string {
562
585
  return `${homedir()}/.claude.json`;
563
586
  }
564
587
 
565
- function coerceClaudeLocalServer(name: string, value: unknown): MCPServerEntry | null {
588
+ function coerceMcpServer(name: string, value: unknown): MCPServerEntry | null {
566
589
  const entry = asRecord(value);
567
590
  if (!entry) return null;
568
591
  const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
@@ -626,7 +649,7 @@ async function inspectClaudeLocalMcp(projectRoot: string): Promise<AgentMcpCheck
626
649
  }
627
650
 
628
651
  const servers = asRecord(localProject.project.mcpServers);
629
- const server = coerceClaudeLocalServer('memorix', servers?.memorix);
652
+ const server = coerceMcpServer('memorix', servers?.memorix);
630
653
  if (!server) {
631
654
  return {
632
655
  scope: 'local',
@@ -683,6 +706,59 @@ async function installClaudeLocalMcpConfig(projectRoot: string): Promise<void> {
683
706
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
684
707
  }
685
708
 
709
+ async function inspectCodexPluginMcp(): Promise<AgentMcpCheck | null> {
710
+ const runtime = inspectCodexPluginRuntime();
711
+ if (!runtime.runtime?.installed || !runtime.runtime.enabled) return null;
712
+
713
+ const mcpPath = codexPluginMcpPath();
714
+ if (!existsSync(mcpPath)) {
715
+ return {
716
+ scope: 'global',
717
+ path: mcpPath,
718
+ exists: false,
719
+ status: 'repairable',
720
+ issues: ['codex-plugin-mcp-manifest-missing'],
721
+ managedByPlugin: true,
722
+ };
723
+ }
724
+
725
+ try {
726
+ const config = asRecord(JSON.parse(await readFile(mcpPath, 'utf-8')));
727
+ const server = coerceMcpServer('memorix', asRecord(config?.mcpServers)?.memorix);
728
+ if (!server) {
729
+ return {
730
+ scope: 'global',
731
+ path: mcpPath,
732
+ exists: true,
733
+ status: 'repairable',
734
+ issues: ['codex-plugin-mcp-manifest-invalid'],
735
+ managedByPlugin: true,
736
+ };
737
+ }
738
+ const issues: string[] = [];
739
+ if (looksLikeStaleMemorixCommand(server)) issues.push('stale-command-path');
740
+ if (!isRecommendedStdioServer(server)) issues.push('nonstandard-mcp-command');
741
+ return {
742
+ scope: 'global',
743
+ path: mcpPath,
744
+ exists: true,
745
+ status: issues.length > 0 ? 'repairable' : 'ok',
746
+ issues,
747
+ managedByPlugin: true,
748
+ server: sanitizeServer(server),
749
+ };
750
+ } catch {
751
+ return {
752
+ scope: 'global',
753
+ path: mcpPath,
754
+ exists: true,
755
+ status: 'repairable',
756
+ issues: ['codex-plugin-mcp-manifest-unreadable'],
757
+ managedByPlugin: true,
758
+ };
759
+ }
760
+ }
761
+
686
762
  async function inspectMcp(agent: AgentName, projectRoot: string, scope: AgentIntegrationScope): Promise<AgentIntegrationEntry['mcp']> {
687
763
  if (!isMcpConfigAgent(agent)) {
688
764
  return { status: 'skipped', issues: ['mcp-managed-by-package'], checks: [] };
@@ -697,6 +773,14 @@ async function inspectMcp(agent: AgentName, projectRoot: string, scope: AgentInt
697
773
  continue;
698
774
  }
699
775
 
776
+ if (agent === 'codex' && targetScope === 'global') {
777
+ const pluginCheck = await inspectCodexPluginMcp();
778
+ if (pluginCheck) {
779
+ checks.push(pluginCheck);
780
+ continue;
781
+ }
782
+ }
783
+
700
784
  const configPath = adapter.getConfigPath(targetScope === 'project' ? projectRoot : undefined);
701
785
  if (targetScope === 'global' && configPath === adapter.getConfigPath(projectRoot)) continue;
702
786
 
@@ -872,11 +956,20 @@ export function formatAgentIntegrationReport(report: AgentIntegrationReport): st
872
956
  lines.push(`${entry.agent}: ${status}`);
873
957
  if (entry.mcp.issues.length > 0) lines.push(` MCP: ${entry.mcp.issues.join(', ')}`);
874
958
  if (entry.guidance.issues.length > 0) lines.push(` Guidance: ${entry.guidance.issues.join(', ')}`);
875
- if (entry.plugin.issues.length > 0) lines.push(` Plugin: ${entry.plugin.issues.join(', ')}`);
959
+ const hookTrustPending = entry.plugin.issues.includes('codex-hook-trust-pending');
960
+ const pluginIssues = entry.plugin.issues.filter((issue) => issue !== 'codex-hook-trust-pending');
961
+ if (pluginIssues.length > 0) lines.push(` Plugin: ${pluginIssues.join(', ')}`);
962
+ if (hookTrustPending) {
963
+ lines.push(' Hooks: waiting for Codex approval on first use; MCP remains available.');
964
+ }
876
965
  }
877
966
 
878
967
  lines.push('');
879
- lines.push(`Repair: ${report.repairCommand}`);
968
+ if (report.summary.missing > 0 || report.summary.repairable > 0) {
969
+ lines.push(`Repair: ${report.repairCommand}`);
970
+ } else {
971
+ lines.push('No file repair needed.');
972
+ }
880
973
  return lines.join('\n');
881
974
  }
882
975
 
@@ -899,6 +992,10 @@ export async function repairAgentIntegrations(options: {
899
992
  if (isMcpConfigAgent(entry.agent)) {
900
993
  for (const check of entry.mcp.checks) {
901
994
  if (check.status === 'ok' || check.status === 'skipped') continue;
995
+ if (check.managedByPlugin) {
996
+ skipped.push(`${entry.agent}:mcp:${check.scope}:plugin-managed`);
997
+ continue;
998
+ }
902
999
  if (check.status === 'missing' && !canInstallMissing) {
903
1000
  skipped.push(`${entry.agent}:mcp:${check.scope}:missing`);
904
1001
  continue;
@@ -839,10 +839,21 @@ export default defineCommand({
839
839
  };
840
840
  } catch { /* best effort */ }
841
841
 
842
- let vectorStatus = { total: 0, missing: 0, missingIds: [] as number[], backfillRunning: false };
842
+ let vectorStatus = {
843
+ available: false,
844
+ total: 0,
845
+ missing: 0,
846
+ missingIds: [] as number[],
847
+ backfillRunning: false,
848
+ };
843
849
  try {
844
- const { getVectorStatus } = await import('../../memory/observations.js');
845
- vectorStatus = getVectorStatus(statsProjectId);
850
+ const { getSearchIndexStatus, getVectorStatus } = await import('../../memory/observations.js');
851
+ // The HTTP control plane has a different module graph from stdio MCP.
852
+ // Only report vector counts when this process actually owns a hydrated
853
+ // index; an empty singleton must not look like a healthy 0/0 state.
854
+ if (getSearchIndexStatus(statsProjectId).prepared) {
855
+ vectorStatus = { available: true, ...getVectorStatus(statsProjectId) };
856
+ }
846
857
  } catch { /* best effort */ }
847
858
 
848
859
  // Real search mode from the last actual search execution
@@ -484,6 +484,40 @@ function mergeTomlMcpConfig(existingContent: string | null, generatedContent: st
484
484
  return `${parts.join('\n\n')}\n`;
485
485
  }
486
486
 
487
+ function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
488
+ if (server.name !== 'memorix' || server.command.trim().toLowerCase() !== 'node') return false;
489
+ const startsMemorixStdio = server.args.some((arg) => arg.trim().toLowerCase() === 'serve');
490
+ const sourceEntry = server.args.some((arg) => {
491
+ const normalized = arg.replace(/\\/g, '/').toLowerCase();
492
+ return /\/memorix\/dist\/cli\/index\.(?:[cm]?js)$/.test(normalized);
493
+ });
494
+ return startsMemorixStdio && sourceEntry;
495
+ }
496
+
497
+ /**
498
+ * Codex plugins now own Memorix stdio MCP registration. Remove only the old
499
+ * source-path form emitted by pre-plugin setup, never a user's custom server.
500
+ */
501
+ export async function removeLegacyCodexMemorixMcpConfig(
502
+ configPath = path.join(homedir(), '.codex', 'config.toml'),
503
+ ): Promise<{ configPath: string; removed: boolean }> {
504
+ let existingContent: string;
505
+ try {
506
+ existingContent = await readFile(configPath, 'utf-8');
507
+ } catch {
508
+ return { configPath, removed: false };
509
+ }
510
+
511
+ const adapter = new CodexMCPAdapter();
512
+ const legacyServer = adapter.parse(existingContent).find(isLegacyCodexMemorixNodeServer);
513
+ if (!legacyServer) return { configPath, removed: false };
514
+
515
+ let nextContent = removeTomlSection(existingContent, 'mcp_servers.memorix.env');
516
+ nextContent = removeTomlSection(nextContent, 'mcp_servers.memorix');
517
+ await writeFile(configPath, nextContent ? `${nextContent}\n` : '', 'utf-8');
518
+ return { configPath, removed: true };
519
+ }
520
+
487
521
  function mergeYamlMcpConfig(existingContent: string | null, generatedContent: string): string {
488
522
  let existing: Record<string, unknown> = {};
489
523
  try {
@@ -1091,6 +1125,16 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1091
1125
  const install = tryInstallCodexPlugin();
1092
1126
  if (install.ok) p.log.success(install.message);
1093
1127
  else p.log.warn(install.message);
1128
+ if (install.ok) {
1129
+ try {
1130
+ const migration = await removeLegacyCodexMemorixMcpConfig();
1131
+ if (migration.removed) {
1132
+ p.log.info(`codex: removed legacy source-path MCP config from ${migration.configPath}`);
1133
+ }
1134
+ } catch {
1135
+ p.log.warn('codex: plugin installed, but the legacy MCP config could not be checked');
1136
+ }
1137
+ }
1094
1138
  } else if (agent === 'claude' && result.marketplaceRoot) {
1095
1139
  const install = tryInstallClaudePlugin(result.marketplaceRoot);
1096
1140
  if (install.ok) p.log.success(install.message);
@@ -351,6 +351,17 @@ async function handleApi(
351
351
  sourceCounts,
352
352
  recentObservations: sorted,
353
353
  embedding: embeddingStatus,
354
+ // A standalone dashboard has no access to an active MCP
355
+ // process's in-memory Orama index. Keep this explicit so the
356
+ // UI never presents an empty local singleton as a healthy
357
+ // all-vectors-indexed result.
358
+ vectorStatus: {
359
+ available: false,
360
+ total: 0,
361
+ missing: 0,
362
+ missingIds: [],
363
+ backfillRunning: false,
364
+ },
354
365
  storage: storageInfo,
355
366
  maintenance,
356
367
  ...(lifecycle ? { lifecycle } : {}),
@@ -102,6 +102,10 @@ function normalizeEmbeddingFailure(error: unknown): { key: string; message: stri
102
102
  };
103
103
  }
104
104
 
105
+ function vectorBackfillError(error: unknown): string {
106
+ return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
107
+ }
108
+
105
109
  function queueVectorBackfill(projectId: string): void {
106
110
  const dataDir = projectDir;
107
111
  if (!dataDir) return;
@@ -1202,6 +1206,7 @@ export async function backfillVectorEmbeddings(options: {
1202
1206
  attempted: number;
1203
1207
  succeeded: number;
1204
1208
  failed: number;
1209
+ lastError?: string;
1205
1210
  }> {
1206
1211
  if (vectorBackfillRunning) {
1207
1212
  return { attempted: 0, succeeded: 0, failed: 0 };
@@ -1218,19 +1223,63 @@ export async function backfillVectorEmbeddings(options: {
1218
1223
  let succeeded = 0;
1219
1224
  let failed = 0;
1220
1225
  let lastFailure: string | undefined;
1226
+ const recordFailure = (message: string): void => {
1227
+ // A single batch may have several symptoms after one root cause. Preserve
1228
+ // the first one so an informative provider/index error is not overwritten
1229
+ // by later missing-result fallout.
1230
+ if (!lastFailure) lastFailure = message;
1231
+ };
1232
+
1233
+ const candidates: Array<{ id: number; observation: Observation; text: string }> = [];
1234
+ for (const id of ids) {
1235
+ const observation = observationById.get(id);
1236
+ if (!observation) {
1237
+ vectorMissingIds.delete(id);
1238
+ continue;
1239
+ }
1240
+ candidates.push({
1241
+ id,
1242
+ observation,
1243
+ text: [observation.title, observation.narrative, ...observation.facts].join(' '),
1244
+ });
1245
+ }
1221
1246
 
1222
1247
  try {
1223
- for (const id of ids) {
1224
- const obs = observationById.get(id);
1225
- if (!obs) {
1226
- vectorMissingIds.delete(id);
1227
- continue;
1248
+ if (candidates.length === 0) {
1249
+ return { attempted: ids.length, succeeded, failed };
1250
+ }
1251
+
1252
+ let embeddings: number[][] | null = null;
1253
+ try {
1254
+ const provider = await getEmbeddingProvider();
1255
+ if (!provider) {
1256
+ if (isEmbeddingExplicitlyDisabled()) {
1257
+ for (const candidate of candidates) vectorMissingIds.delete(candidate.id);
1258
+ } else {
1259
+ recordFailure('embedding provider unavailable');
1260
+ failed += candidates.length;
1261
+ }
1262
+ } else {
1263
+ // API providers batch and cache this efficiently; local providers can use
1264
+ // their native batch path. Index writes stay sequential because Orama is
1265
+ // process-local mutable state.
1266
+ embeddings = await provider.embedBatch(candidates.map((candidate) => candidate.text));
1228
1267
  }
1268
+ } catch (error) {
1269
+ recordFailure(vectorBackfillError(error));
1270
+ failed += candidates.length;
1271
+ }
1229
1272
 
1230
- const text = [obs.title, obs.narrative, ...obs.facts].join(' ');
1231
- try {
1232
- const embedding = await generateEmbedding(text);
1233
- if (embedding) {
1273
+ if (embeddings) {
1274
+ for (let index = 0; index < candidates.length; index++) {
1275
+ const { id, observation: obs } = candidates[index];
1276
+ const embedding = embeddings[index];
1277
+ if (!embedding || embedding.length === 0) {
1278
+ recordFailure('embedding provider returned no vector');
1279
+ failed++;
1280
+ continue;
1281
+ }
1282
+ try {
1234
1283
  if (!isVectorCompatibleWithCurrentIndex(embedding)) {
1235
1284
  await upgradeVectorSchemaAfterFirstEmbedding(embedding);
1236
1285
  }
@@ -1239,7 +1288,7 @@ export async function backfillVectorEmbeddings(options: {
1239
1288
  console.error(
1240
1289
  `[memorix] Backfill embedding mismatch for obs-${id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in queue)`,
1241
1290
  );
1242
- lastFailure = `dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`;
1291
+ recordFailure(`dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`);
1243
1292
  failed++;
1244
1293
  continue;
1245
1294
  }
@@ -1277,17 +1326,10 @@ export async function backfillVectorEmbeddings(options: {
1277
1326
  await insertObservation(doc);
1278
1327
  vectorMissingIds.delete(id);
1279
1328
  succeeded++;
1280
- } else if (isEmbeddingExplicitlyDisabled()) {
1281
- // Embedding explicitly off — nothing to backfill from
1282
- vectorMissingIds.delete(id);
1283
- } else {
1284
- // Provider temporarily unavailable — keep in queue for next backfill cycle
1285
- lastFailure = 'embedding provider unavailable';
1329
+ } catch (error) {
1330
+ recordFailure(vectorBackfillError(error));
1286
1331
  failed++;
1287
1332
  }
1288
- } catch (err) {
1289
- lastFailure = err instanceof Error ? err.message : String(err);
1290
- failed++;
1291
1333
  }
1292
1334
  }
1293
1335
  } finally {
@@ -1301,5 +1343,10 @@ export async function backfillVectorEmbeddings(options: {
1301
1343
  };
1302
1344
  }
1303
1345
 
1304
- return { attempted: ids.length, succeeded, failed };
1346
+ return {
1347
+ attempted: ids.length,
1348
+ succeeded,
1349
+ failed,
1350
+ ...(lastFailure ? { lastError: lastFailure } : {}),
1351
+ };
1305
1352
  }
@@ -67,7 +67,18 @@ export interface MaintenanceJobSummary {
67
67
 
68
68
  export type MaintenanceJobRunResult =
69
69
  | { action: 'complete' }
70
- | { action: 'reschedule'; delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown> };
70
+ | {
71
+ action: 'reschedule';
72
+ delayMs: number;
73
+ resetAttempts?: boolean;
74
+ payload?: Record<string, unknown>;
75
+ /** Keep recoverable work out of the immediate pending queue during a cooldown. */
76
+ status?: 'pending' | 'retry';
77
+ /** Persist a sanitized diagnostic without consuming the job's retry budget. */
78
+ lastError?: string;
79
+ /** Clear a stale transient diagnostic after the job makes progress. */
80
+ clearLastError?: boolean;
81
+ };
71
82
 
72
83
  export type MaintenanceJobHandler = (
73
84
  job: MaintenanceJob,
@@ -160,6 +171,12 @@ export class MaintenanceJobStore {
160
171
  `).get(input.projectId, input.kind, dedupeKey);
161
172
 
162
173
  if (existing) {
174
+ // A vector backfill in cooldown is a circuit-breaker state. A new MCP
175
+ // process must not pull it forward and recreate the original retry storm.
176
+ if (input.kind === 'vector-backfill' && existing.status === 'retry') {
177
+ this.commit();
178
+ return rowToJob(existing);
179
+ }
163
180
  const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
164
181
  this.db.prepare(`
165
182
  UPDATE maintenance_jobs
@@ -171,6 +188,30 @@ export class MaintenanceJobStore {
171
188
  return rowToJob(updated);
172
189
  }
173
190
 
191
+ // Older releases exhausted vector jobs on transient provider/index errors.
192
+ // Reuse one failed record so the next healthy session recovers work instead
193
+ // of adding another permanent failure row for the same project.
194
+ if (input.kind === 'vector-backfill') {
195
+ const failed = this.db.prepare(`
196
+ SELECT * FROM maintenance_jobs
197
+ WHERE project_id = ? AND kind = ? AND dedupe_key = ? AND status = 'failed'
198
+ ORDER BY updated_at DESC
199
+ LIMIT 1
200
+ `).get(input.projectId, input.kind, dedupeKey);
201
+ if (failed) {
202
+ this.db.prepare(`
203
+ UPDATE maintenance_jobs
204
+ SET status = 'pending', attempts = 0, max_attempts = ?, run_after = ?,
205
+ payload_json = ?, lease_owner = NULL, lease_expires_at = NULL,
206
+ last_error = NULL, completed_at = NULL, updated_at = ?
207
+ WHERE id = ?
208
+ `).run(maxAttempts, runAfter, payloadJson, now, failed.id);
209
+ const revived = this.getRow(failed.id)!;
210
+ this.commit();
211
+ return rowToJob(revived);
212
+ }
213
+ }
214
+
174
215
  const id = randomUUID();
175
216
  this.db.prepare(`
176
217
  INSERT INTO maintenance_jobs (
@@ -361,21 +402,57 @@ export class MaintenanceJobStore {
361
402
  reschedule(
362
403
  id: string,
363
404
  workerId: string,
364
- options: { delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown>; now?: number },
405
+ options: {
406
+ delayMs: number;
407
+ resetAttempts?: boolean;
408
+ payload?: Record<string, unknown>;
409
+ status?: 'pending' | 'retry';
410
+ lastError?: string;
411
+ clearLastError?: boolean;
412
+ now?: number;
413
+ },
365
414
  ): MaintenanceJob | undefined {
366
415
  const now = options.now ?? Date.now();
367
416
  const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
368
417
  const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
418
+ const status = options.status === 'retry' ? 'retry' : 'pending';
419
+ const hasLastError = options.lastError !== undefined;
420
+ const lastError = hasLastError ? errorText(options.lastError) : null;
369
421
  this.db.prepare(`
370
422
  UPDATE maintenance_jobs
371
- SET status = 'pending', run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
423
+ SET status = ?, run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
372
424
  payload_json = COALESCE(?, payload_json),
425
+ last_error = CASE
426
+ WHEN ? THEN NULL
427
+ WHEN ? THEN ?
428
+ ELSE last_error
429
+ END,
373
430
  lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
374
431
  WHERE id = ? AND status = 'running' AND lease_owner = ?
375
- `).run(now + delayMs, options.resetAttempts ? 1 : 0, payloadJson, now, id, workerId);
432
+ `).run(
433
+ status,
434
+ now + delayMs,
435
+ options.resetAttempts ? 1 : 0,
436
+ payloadJson,
437
+ options.clearLastError ? 1 : 0,
438
+ hasLastError ? 1 : 0,
439
+ lastError,
440
+ now,
441
+ id,
442
+ workerId,
443
+ );
376
444
  return this.get(id);
377
445
  }
378
446
 
447
+ resolveFailedVectorBackfills(projectId: string, dedupeKey = 'vector-backfill', now = Date.now()): number {
448
+ const result = this.db.prepare(`
449
+ UPDATE maintenance_jobs
450
+ SET status = 'completed', completed_at = ?, updated_at = ?
451
+ WHERE project_id = ? AND kind = 'vector-backfill' AND dedupe_key = ? AND status = 'failed'
452
+ `).run(now, now, projectId, dedupeKey);
453
+ return Number(result.changes ?? 0);
454
+ }
455
+
379
456
  fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
380
457
  this.begin();
381
458
  try {
@@ -486,6 +563,9 @@ export class MaintenanceJobWorker {
486
563
  delayMs: result.delayMs,
487
564
  resetAttempts: result.resetAttempts,
488
565
  payload: result.payload,
566
+ status: result.status,
567
+ lastError: result.lastError,
568
+ clearLastError: result.clearLastError,
489
569
  now,
490
570
  });
491
571
  return { state: 'rescheduled', job: updated };