clawmem 0.10.6 → 0.11.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.
package/AGENTS.md CHANGED
@@ -523,7 +523,7 @@ The `memory_relations` table is populated by multiple independent sources:
523
523
 
524
524
  **Graph traversal asymmetry:** `adaptiveTraversal()` traverses all edge types outbound (source→target) but only `semantic` and `entity` edges inbound (target→source). Temporal and causal edges are directional only.
525
525
 
526
- **MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
526
+ **MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via **max-score** (best supporting path wins). Note: meta-path fusion is max-score, NOT RRF — Forward Push yields absolute propagation mass where magnitude carries signal, so rank-only fusion would discard the difference between a strong path hit and a barely-surviving tail hit (`src/graph-traversal.ts:413-425`). This is distinct from the `query`/`intent_search` *outer* retrieval, which does fuse BM25+vector via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
527
527
 
528
528
  ### When to Run `build_graphs`
529
529
 
package/CLAUDE.md CHANGED
@@ -523,7 +523,7 @@ The `memory_relations` table is populated by multiple independent sources:
523
523
 
524
524
  **Graph traversal asymmetry:** `adaptiveTraversal()` traverses all edge types outbound (source→target) but only `semantic` and `entity` edges inbound (target→source). Temporal and causal edges are directional only.
525
525
 
526
- **MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
526
+ **MPFP graph retrieval (v0.2.0):** Multi-Path Fact Propagation runs predefined meta-path patterns in parallel (`[semantic, semantic]`, `[entity, temporal]`, `[semantic, causal]`, etc.), fuses via **max-score** (best supporting path wins). Note: meta-path fusion is max-score, NOT RRF — Forward Push yields absolute propagation mass where magnitude carries signal, so rank-only fusion would discard the difference between a strong path hit and a barely-surviving tail hit (`src/graph-traversal.ts:413-425`). This is distinct from the `query`/`intent_search` *outer* retrieval, which does fuse BM25+vector via RRF. Hop-synchronized edge cache batches DB queries per hop instead of per pattern. Forward Push with α=0.15 teleport probability bounds active nodes sublinearly. Tier 3 only (`query`/`intent_search`), not hooks. Patterns selected per MAGMA intent: WHY → `[semantic, causal]`, ENTITY → `[entity, semantic]`, WHEN → `[temporal, semantic]`.
527
527
 
528
528
  ### When to Run `build_graphs`
529
529
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.10.6",
3
+ "version": "0.11.0",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clawmem.ts CHANGED
@@ -18,6 +18,10 @@ import {
18
18
  DEFAULT_RERANK_MODEL,
19
19
  DEFAULT_GLOB,
20
20
  extractSnippet,
21
+ FatalVectorError,
22
+ VecDimensionMismatchError,
23
+ VecModelMismatchError,
24
+ EmbedLeaseLostError,
21
25
  } from "./store.ts";
22
26
  import {
23
27
  getDefaultLlamaCpp,
@@ -28,6 +32,11 @@ import {
28
32
  LlamaCpp,
29
33
  type Queryable,
30
34
  } from "./llm.ts";
35
+ import {
36
+ acquireWorkerLease,
37
+ releaseWorkerLease,
38
+ renewWorkerLease,
39
+ } from "./worker-lease.ts";
31
40
  import {
32
41
  loadConfig,
33
42
  addCollection as collectionsAdd,
@@ -385,200 +394,331 @@ async function cmdEmbed(args: string[]) {
385
394
 
386
395
  const s = getStore();
387
396
 
388
- if (values.force) {
389
- console.log(`${c.yellow}Force mode: clearing all embeddings${c.reset}`);
390
- s.clearAllEmbeddings();
397
+ // Embedding lease: serialize embed commands (manual / embed timer / update --embed)
398
+ // so two embeds cannot run at once. It is RENEWABLE (token-fenced heartbeat), not a
399
+ // fixed-TTL lease — a full-vault rebuild outlasts any fixed TTL and would be reclaimed
400
+ // mid-run. Without serialization, two embeds using different same-dimension models can
401
+ // silently build a heterogeneous vector space (dimension checks can't catch that).
402
+ // See EMBED-LEASE-RENEWAL-DESIGN.md / INCIDENT-2026-06-22.
403
+ const LEASE_NAME = "embedding";
404
+ const LEASE_TTL_MS = 60_000;
405
+ const lease = acquireWorkerLease(s, LEASE_NAME, LEASE_TTL_MS);
406
+ if (!lease.acquired || !lease.token) {
407
+ console.log(`${c.yellow}Another embed is already in progress (lease held); skipping.${c.reset}`);
408
+ return;
391
409
  }
410
+ const leaseToken = lease.token;
411
+ // Passed into every vector mutation (clear, stale-clean, table-create, insert) so
412
+ // each verifies ownership before mutating — a process that lost the lease mid-await
413
+ // cannot wipe/recreate/write the vector store under the new holder.
414
+ const leaseGuard = { workerName: LEASE_NAME, token: leaseToken };
415
+ let leaseLost = false;
416
+ const heartbeat = setInterval(() => {
417
+ if (!renewWorkerLease(s, LEASE_NAME, leaseToken, LEASE_TTL_MS)) leaseLost = true;
418
+ }, Math.floor(LEASE_TTL_MS / 2));
392
419
 
393
- // Clean stale embeddings (orphaned hashes from updated/deleted documents)
394
- const cleaned = s.cleanStaleEmbeddings();
395
- if (cleaned > 0) {
396
- console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
397
- }
420
+ try {
421
+ const embedUrl = process.env.CLAWMEM_EMBED_URL;
422
+ if (embedUrl) {
423
+ console.log(`Using remote GPU embedding: ${embedUrl}`);
424
+ } else {
425
+ // Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
426
+ setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
427
+ }
428
+ const llm = getDefaultLlamaCpp();
398
429
 
399
- // Use fragment-based pipeline: split documents into semantic fragments and embed each
400
- const hashes = s.getHashesNeedingFragments();
401
- if (hashes.length === 0) {
402
- console.log(`${c.green}All documents already embedded${c.reset}`);
403
- return;
404
- }
430
+ // Probe the live model's output dimension (+ model name). Returns null on ANY
431
+ // failure so a down/flaky endpoint can NEVER trigger a destructive clear.
432
+ const probeEmbed = async (): Promise<{ dim: number; model: string } | null> => {
433
+ try {
434
+ const r = await llm.embed("clawmem dimension probe");
435
+ return r && r.embedding && r.embedding.length > 0
436
+ ? { dim: r.embedding.length, model: r.model ?? "" }
437
+ : null;
438
+ } catch { return null; }
439
+ };
405
440
 
406
- // Count total fragments first for ETA
407
- let totalFragEstimate = 0;
408
- const docFragCounts: number[] = [];
409
- for (const { body, path } of hashes) {
410
- let frontmatter: Record<string, any> | undefined;
411
- try {
412
- const parsed = parseDocument(body, path);
413
- frontmatter = parsed.meta as any;
414
- } catch { /* skip */ }
415
- const frags = splitDocument(body, frontmatter);
416
- docFragCounts.push(frags.length);
417
- totalFragEstimate += frags.length;
418
- }
419
- console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
420
-
421
- const embedUrl = process.env.CLAWMEM_EMBED_URL;
422
- if (embedUrl) {
423
- console.log(`Using remote GPU embedding: ${embedUrl}`);
424
- } else {
425
- // Local CPU mode: disable inactivity timeout to prevent context disposal mid-batch
426
- setDefaultLlamaCpp(new LlamaCpp({ inactivityTimeoutMs: 0 }));
427
- }
428
- const llm = getDefaultLlamaCpp();
429
- let embedded = 0;
430
- let totalFragments = 0;
431
- let failedFragments = 0;
432
- const batchStart = Date.now();
433
-
434
- // Cloud API: global batch pacing state (persists across documents)
435
- // TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
436
- const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
437
- const CLOUD_BATCH_SIZE = 50;
438
- const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
439
- const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
440
- const CHARS_PER_TOKEN = 4;
441
- let lastBatchSentAt = 0; // global timestamp of last batch send
442
-
443
- for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
444
- const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
445
- const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
446
- const canId = canonicalDocId(collection, path);
447
-
448
- // Parse frontmatter for fragment splitting
449
- let frontmatter: Record<string, any> | undefined;
450
- try {
451
- const parsed = parseDocument(body, path);
452
- frontmatter = parsed.meta as any;
453
- } catch {
454
- // No frontmatter or parsing error — fine, skip it
455
- }
456
-
457
- const fragments = splitDocument(body, frontmatter);
458
- const docStart = Date.now();
459
- const prevTotalFragments = totalFragments;
460
- const prevFailedFragments = failedFragments;
461
- let seq0Succeeded = false;
462
- console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
463
-
464
- if (isCloudEmbed) {
465
- // Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
466
- const allTexts: string[] = [];
467
- for (const frag of fragments) {
468
- const label = frag.label || title;
469
- allTexts.push(formatDocForEmbedding(frag.content, label));
441
+ // Bind the whole run to one (dim, model); every fragment is validated before it is
442
+ // stored. On a fresh vault these are set from the first successful fragment.
443
+ let expectedDim: number | null = null;
444
+ let expectedModel: string | null = null;
445
+
446
+ if (values.force) {
447
+ // Probe FIRST — validate the endpoint before destroying anything (so a force
448
+ // re-embed against a dead endpoint cannot clear the vault and then fail).
449
+ const probe = await probeEmbed();
450
+ if (!probe) {
451
+ console.error(`${c.red}Force re-embed aborted: could not reach the embedding endpoint. Nothing was cleared.${c.reset}`);
452
+ return;
470
453
  }
471
-
472
- for (let batchStart = 0; batchStart < allTexts.length; batchStart += CLOUD_BATCH_SIZE) {
473
- // Global TPM-aware delay: compute required wait based on last batch's token count,
474
- // then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
475
- // including first batch of each document (inter-document pacing).
476
- if (lastBatchSentAt > 0) {
477
- // Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
478
- // Free: 100000 (default), Paid: 2000000, Premium: 50000000
479
- const batchEnd0 = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
480
- const estimatedTokens = allTexts.slice(batchStart, batchEnd0)
481
- .reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
482
- // Use current batch estimate (not previous batch actuals previous batch may differ in size)
483
- const batchTokens = estimatedTokens;
484
- const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
485
- const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
486
- const elapsed = Date.now() - lastBatchSentAt;
487
- const remainingMs = requiredGapMs - elapsed;
488
- if (remainingMs > 0) {
489
- const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
490
- await new Promise(r => setTimeout(r, jittered));
491
- }
454
+ console.log(`${c.yellow}Force mode: clearing all embeddings (rebuilding at dim ${probe.dim})${c.reset}`);
455
+ expectedDim = probe.dim;
456
+ expectedModel = probe.model || null;
457
+ s.clearAllEmbeddings(leaseGuard);
458
+ } else {
459
+ // Implicit run: NON-DESTRUCTIVE drift check (dimension AND model). Catches
460
+ // drift even when the worklist is empty (query embeddings would already be
461
+ // incompatible with the stored table). Never clears aborts with instructions.
462
+ const existingDim = s.getVecTableDim(); // throws VecSchemaError on malformed DDL → caught below
463
+ if (existingDim !== null) {
464
+ const probe = await probeEmbed();
465
+ if (probe && probe.dim !== existingDim) {
466
+ console.error(`${c.red}Embedding dimension changed (${existingDim} → ${probe.dim}). Run 'clawmem embed --force' to clear and rebuild the full vault.${c.reset}`);
467
+ return;
492
468
  }
469
+ // Same dimension but a DIFFERENT model still mixes the vector space (cosine
470
+ // across two models is meaningless) and the dim check cannot see it. Compare
471
+ // the probe's model against what the vault was built with; abort on mismatch.
472
+ const existingModels = s.getVecModels();
473
+ if (existingModels.length > 1) {
474
+ console.error(`${c.red}Vault already contains mixed embedding models: ${existingModels.join(", ")}. Run 'clawmem embed --force' to rebuild with a single model.${c.reset}`);
475
+ return;
476
+ }
477
+ if (probe && probe.model && existingModels.length === 1 && existingModels[0] !== probe.model) {
478
+ console.error(`${c.red}Embedding model changed (${existingModels[0]} → ${probe.model}) at the same dimension. Mixing models in one vector space breaks similarity. Run 'clawmem embed --force' to rebuild with the current model.${c.reset}`);
479
+ return;
480
+ }
481
+ expectedDim = existingDim;
482
+ if (probe && probe.model) expectedModel = probe.model;
483
+ }
484
+ }
485
+
486
+ // Clean stale embeddings (orphaned hashes from updated/deleted documents)
487
+ const cleaned = s.cleanStaleEmbeddings(leaseGuard);
488
+ if (cleaned > 0) {
489
+ console.log(`${c.yellow}Cleaned ${cleaned} stale embedding(s) from orphaned documents${c.reset}`);
490
+ }
493
491
 
494
- const batchEnd = Math.min(batchStart + CLOUD_BATCH_SIZE, allTexts.length);
495
- const batchTexts = allTexts.slice(batchStart, batchEnd);
496
- lastBatchSentAt = Date.now();
497
- const reqStart = Date.now();
492
+ // Use fragment-based pipeline: split documents into semantic fragments and embed each
493
+ const hashes = s.getHashesNeedingFragments();
494
+ if (hashes.length === 0) {
495
+ console.log(`${c.green}All documents already embedded${c.reset}`);
496
+ return;
497
+ }
498
498
 
499
- try {
500
- const results = await llm.embedBatch(batchTexts);
501
- const reqMs = Date.now() - reqStart;
502
- const tokensUsed = llm.lastBatchTokens;
499
+ // Count total fragments first for ETA
500
+ let totalFragEstimate = 0;
501
+ const docFragCounts: number[] = [];
502
+ for (const { body, path } of hashes) {
503
+ let frontmatter: Record<string, any> | undefined;
504
+ try {
505
+ const parsed = parseDocument(body, path);
506
+ frontmatter = parsed.meta as any;
507
+ } catch { /* skip */ }
508
+ const frags = splitDocument(body, frontmatter);
509
+ docFragCounts.push(frags.length);
510
+ totalFragEstimate += frags.length;
511
+ }
512
+ console.log(`Embedding ${hashes.length} documents (${totalFragEstimate} fragments total)...`);
513
+
514
+ let embedded = 0;
515
+ let totalFragments = 0;
516
+ let failedFragments = 0;
517
+ const batchStart = Date.now();
518
+
519
+ // Cloud API: global batch pacing state (persists across documents)
520
+ // TPM is the binding constraint, not RPM. 50 frags × ~800 tokens ≈ 40K tokens/batch → max ~2.5 batches/min at 100K TPM.
521
+ const isCloudEmbed = !!process.env.CLAWMEM_EMBED_API_KEY;
522
+ const CLOUD_BATCH_SIZE = 50;
523
+ const CLOUD_TPM_LIMIT = parseInt(process.env.CLAWMEM_EMBED_TPM_LIMIT || "100000", 10);
524
+ const CLOUD_TPM_SAFETY = 0.85; // use 85% of limit to leave headroom for retries
525
+ const CHARS_PER_TOKEN = 4;
526
+ let lastBatchSentAt = 0; // global timestamp of last batch send
527
+
528
+ // Bind the run to one (dim, model) and validate every embedding before it is stored.
529
+ // The first successful fragment sets the binding on a fresh vault; any later drift —
530
+ // a dimension change OR a same-dimension model swap from a flapping endpoint — throws
531
+ // a fatal error that aborts the whole run (caught below). This is what dimension
532
+ // checks alone cannot do: catch a different 2560-d model.
533
+ const bindAndValidate = (result: { embedding: number[] | Float32Array; model?: string }) => {
534
+ const dim = result.embedding.length;
535
+ if (expectedDim === null) {
536
+ expectedDim = dim;
537
+ if (!expectedModel && result.model) expectedModel = result.model;
538
+ } else if (dim !== expectedDim) {
539
+ throw new VecDimensionMismatchError(expectedDim, dim);
540
+ }
541
+ if (expectedModel && result.model && result.model !== expectedModel) {
542
+ throw new VecModelMismatchError(expectedModel, result.model);
543
+ }
544
+ };
545
+
546
+ for (let docIdx = 0; docIdx < hashes.length; docIdx++) {
547
+ // Abort cleanly if the heartbeat reported the lease was reclaimed.
548
+ if (leaseLost) throw new EmbedLeaseLostError();
549
+
550
+ const { hash, body, path, title: docTitle, collection } = hashes[docIdx]!;
551
+ const title = docTitle || basename(path).replace(/\.(md|txt)$/i, "");
552
+ const canId = canonicalDocId(collection, path);
553
+
554
+ // Parse frontmatter for fragment splitting
555
+ let frontmatter: Record<string, any> | undefined;
556
+ try {
557
+ const parsed = parseDocument(body, path);
558
+ frontmatter = parsed.meta as any;
559
+ } catch {
560
+ // No frontmatter or parsing error — fine, skip it
561
+ }
503
562
 
504
- for (let i = 0; i < results.length; i++) {
505
- const seq = batchStart + i;
506
- const frag = fragments[seq]!;
507
- const result = results[i];
563
+ const fragments = splitDocument(body, frontmatter);
564
+ const docStart = Date.now();
565
+ const prevFailedFragments = failedFragments;
566
+ let seq0Succeeded = false;
567
+
568
+ // Mark the doc 'pending' and increment embed_attempts ONCE before its first
569
+ // fragment, so a crash mid-document leaves it retryable (re-selected by
570
+ // getHashesNeedingFragments). Completion setters below are state-only.
571
+ s.markEmbedStart(hash);
572
+ console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
573
+
574
+ if (isCloudEmbed) {
575
+ // Batch mode: collect all texts, send in chunks of CLOUD_BATCH_SIZE
576
+ const allTexts: string[] = [];
577
+ for (const frag of fragments) {
578
+ const label = frag.label || title;
579
+ allTexts.push(formatDocForEmbedding(frag.content, label));
580
+ }
581
+
582
+ for (let batchStartIdx = 0; batchStartIdx < allTexts.length; batchStartIdx += CLOUD_BATCH_SIZE) {
583
+ // Abort before each batch if the lease was reclaimed — a large document
584
+ // must not keep writing after another process took the lease (HIGH-3).
585
+ if (leaseLost) throw new EmbedLeaseLostError();
586
+ // Global TPM-aware delay: compute required wait based on last batch's token count,
587
+ // then wait only the remaining time since lastBatchSentAt. Applies to ALL batches
588
+ // including first batch of each document (inter-document pacing).
589
+ if (lastBatchSentAt > 0) {
590
+ // Adaptive TPM-aware delay. Set CLAWMEM_EMBED_TPM_LIMIT to match your tier:
591
+ // Free: 100000 (default), Paid: 2000000, Premium: 50000000
592
+ const batchEnd0 = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
593
+ const estimatedTokens = allTexts.slice(batchStartIdx, batchEnd0)
594
+ .reduce((sum, t) => sum + Math.ceil(t.length / CHARS_PER_TOKEN), 0);
595
+ // Use current batch estimate (not previous batch actuals — previous batch may differ in size)
596
+ const batchTokens = estimatedTokens;
597
+ const safeTPM = CLOUD_TPM_LIMIT * CLOUD_TPM_SAFETY;
598
+ const requiredGapMs = Math.max(500, (batchTokens / safeTPM) * 60_000);
599
+ const elapsed = Date.now() - lastBatchSentAt;
600
+ const remainingMs = requiredGapMs - elapsed;
601
+ if (remainingMs > 0) {
602
+ const jittered = Math.floor(remainingMs * (0.85 + Math.random() * 0.3));
603
+ await new Promise(r => setTimeout(r, jittered));
604
+ }
605
+ }
606
+
607
+ const batchEnd = Math.min(batchStartIdx + CLOUD_BATCH_SIZE, allTexts.length);
608
+ const batchTexts = allTexts.slice(batchStartIdx, batchEnd);
609
+ lastBatchSentAt = Date.now();
610
+ const reqStart = Date.now();
611
+
612
+ try {
613
+ const results = await llm.embedBatch(batchTexts);
614
+ const reqMs = Date.now() - reqStart;
615
+ const tokensUsed = llm.lastBatchTokens;
616
+
617
+ for (let i = 0; i < results.length; i++) {
618
+ const seq = batchStartIdx + i;
619
+ const frag = fragments[seq]!;
620
+ const result = results[i];
621
+ if (result) {
622
+ bindAndValidate(result);
623
+ s.ensureVecTable(result.embedding.length, leaseGuard);
624
+ s.insertEmbedding(
625
+ hash, seq, frag.startLine, new Float32Array(result.embedding),
626
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
627
+ leaseGuard
628
+ );
629
+ totalFragments++;
630
+ if (seq === 0) seq0Succeeded = true;
631
+ } else {
632
+ failedFragments++;
633
+ }
634
+ }
635
+ console.error(` batch ${batchStartIdx + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
636
+ } catch (err) {
637
+ if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch → abort the whole run
638
+ failedFragments += batchTexts.length;
639
+ console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStartIdx + 1}-${batchEnd}: ${err}${c.reset}`);
640
+ }
641
+ }
642
+ } else {
643
+ // Local mode: embed one at a time (no rate limit concern)
644
+ for (let seq = 0; seq < fragments.length; seq++) {
645
+ // Abort before each fragment if the lease was reclaimed — bounds any
646
+ // post-loss writing to at most one fragment of a large doc (HIGH-3).
647
+ if (leaseLost) throw new EmbedLeaseLostError();
648
+ const frag = fragments[seq]!;
649
+ const label = frag.label || title;
650
+ const text = formatDocForEmbedding(frag.content, label);
651
+
652
+ try {
653
+ const fragStart = Date.now();
654
+ const result = await llm.embed(text);
655
+ const fragMs = Date.now() - fragStart;
508
656
  if (result) {
509
- s.ensureVecTable(result.embedding.length);
657
+ bindAndValidate(result);
658
+ s.ensureVecTable(result.embedding.length, leaseGuard);
510
659
  s.insertEmbedding(
511
660
  hash, seq, frag.startLine, new Float32Array(result.embedding),
512
- result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
661
+ result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId,
662
+ leaseGuard
513
663
  );
514
664
  totalFragments++;
515
665
  if (seq === 0) seq0Succeeded = true;
666
+ if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
667
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
668
+ }
516
669
  } else {
517
670
  failedFragments++;
671
+ console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) → null result [${text.length} chars]`);
518
672
  }
519
- }
520
- console.error(` batch ${batchStart + 1}-${batchEnd}/${allTexts.length} (${results.filter(r => r).length} ok) ${reqMs}ms${tokensUsed ? ` ${tokensUsed} tok` : ""}`);
521
- } catch (err) {
522
- failedFragments += batchTexts.length;
523
- console.error(`${c.yellow}Warning: batch embed failed for ${path} frags ${batchStart + 1}-${batchEnd}: ${err}${c.reset}`);
524
- }
525
- }
526
- } else {
527
- // Local mode: embed one at a time (no rate limit concern)
528
- for (let seq = 0; seq < fragments.length; seq++) {
529
- const frag = fragments[seq]!;
530
- const label = frag.label || title;
531
- const text = formatDocForEmbedding(frag.content, label);
532
-
533
- try {
534
- const fragStart = Date.now();
535
- const result = await llm.embed(text);
536
- const fragMs = Date.now() - fragStart;
537
- if (result) {
538
- s.ensureVecTable(result.embedding.length);
539
- s.insertEmbedding(
540
- hash, seq, frag.startLine, new Float32Array(result.embedding),
541
- result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
542
- );
543
- totalFragments++;
544
- if (seq === 0) seq0Succeeded = true;
545
- if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
546
- console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
547
- }
548
- } else {
673
+ } catch (err) {
674
+ if (err instanceof FatalVectorError) throw err; // dim/model/schema mismatch abort the whole run
549
675
  failedFragments++;
550
- console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) null result [${text.length} chars]`);
676
+ console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
551
677
  }
552
- } catch (err) {
553
- failedFragments++;
554
- console.error(`${c.yellow}Warning: failed to embed fragment ${seq} (${frag.type}) of ${path}: ${err}${c.reset}`);
555
678
  }
556
679
  }
680
+
681
+ // Embed-state completion: mark synced ONLY when the WHOLE document succeeded (no
682
+ // failed fragments) — a partial embed must not be silently permanent. Any failure
683
+ // → 'failed' (state-only; attempts already incremented at markEmbedStart) so the
684
+ // worklist retries it, bounded by embed_attempts < 3.
685
+ const docFragsFail = failedFragments - prevFailedFragments;
686
+ if (seq0Succeeded && docFragsFail === 0) {
687
+ s.markEmbedSynced(hash);
688
+ } else if (!seq0Succeeded) {
689
+ s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
690
+ } else {
691
+ s.markEmbedFailed(hash, `${docFragsFail} fragment(s) failed`);
692
+ }
693
+
694
+ embedded++;
695
+ const docMs = Date.now() - docStart;
696
+ const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
697
+ console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
557
698
  }
558
699
 
559
- // Track embed state per document — seq=0 (primary) must succeed for synced status
560
- const docFragsOk = totalFragments - prevTotalFragments;
561
- const docFragsFail = failedFragments - prevFailedFragments;
562
- if (seq0Succeeded) {
563
- s.markEmbedSynced(hash);
564
- } else if (docFragsOk === 0 && docFragsFail > 0) {
565
- s.markEmbedFailed(hash, "all fragments failed");
700
+ const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
701
+ console.log();
702
+ console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
703
+ } catch (err) {
704
+ // Fatal aborts must NOT exit 0 — otherwise the embed timer / `update --embed`
705
+ // cannot tell the run was incomplete. Set a nonzero exit code (cleanup still
706
+ // runs in finally). Non-fatal errors propagate unchanged.
707
+ if (err instanceof EmbedLeaseLostError) {
708
+ // Checked before FatalVectorError because EmbedLeaseLostError extends it.
709
+ console.error(`${c.red}Embed aborted: lost the embedding lease (another embed process took over). Re-run 'clawmem embed'.${c.reset}`);
710
+ process.exitCode = 1;
711
+ } else if (err instanceof FatalVectorError) {
712
+ console.error(`${c.red}Embed aborted: ${(err as Error).message}${c.reset}`);
713
+ process.exitCode = 1;
566
714
  } else {
567
- // seq=0 failed but some later fragments succeeded — mark failed so seq=0 gets retried
568
- s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
715
+ throw err;
569
716
  }
570
-
571
- embedded++;
572
- const docMs = Date.now() - docStart;
573
- const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
574
- console.error(` → doc done in ${(docMs / 1000).toFixed(1)}s | ${embedded}/${hashes.length} docs, ${totalFragments} frags, ${failedFragments} fails [${elapsed}s elapsed]`);
717
+ } finally {
718
+ clearInterval(heartbeat);
719
+ releaseWorkerLease(s, LEASE_NAME, leaseToken);
720
+ await disposeDefaultLlamaCpp();
575
721
  }
576
-
577
- const totalSec = ((Date.now() - batchStart) / 1000).toFixed(1);
578
- console.log();
579
- console.log(`${c.green}Embedded ${embedded} documents (${totalFragments} fragments, ${failedFragments} failed) in ${totalSec}s${c.reset}`);
580
-
581
- await disposeDefaultLlamaCpp();
582
722
  }
583
723
 
584
724
  async function cmdStatus() {
@@ -1891,13 +2031,34 @@ async function cmdDoctor() {
1891
2031
  const s = getStore();
1892
2032
  const needsEmbed = s.getHashesNeedingEmbedding();
1893
2033
  const hasVectors = !!s.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'").get();
1894
- if (hasVectors) {
1895
- console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
1896
- } else {
2034
+ // ALWAYS run the consistency check — the WORST desync (content_vectors rows but
2035
+ // vectors_vec entirely absent) lives in the no-table case, so it must not be
2036
+ // skipped. set-diff, NOT counts (one missing + one orphan cancel in a count check).
2037
+ // Pending docs are reported separately — they are in neither table, not a desync.
2038
+ const vc = s.getVectorConsistency();
2039
+ if (!hasVectors && vc.cvCount === 0) {
1897
2040
  console.log(`${c.yellow}!${c.reset} Vector index: not created yet (run 'clawmem embed')`);
2041
+ } else if (!hasVectors) {
2042
+ console.log(`${c.red}✗${c.reset} Vector index: vectors_vec is MISSING but ${vc.cvCount} content_vectors row(s) exist — full desync. Run 'clawmem embed --force' to rebuild.`);
2043
+ issues++;
2044
+ } else {
2045
+ console.log(`${c.green}✓${c.reset} Vector index: exists (${needsEmbed} need embedding)`);
2046
+ if (vc.cvMissingVv > 0 || vc.vvOrphan > 0) {
2047
+ console.log(`${c.red}✗${c.reset} Vector consistency: ${vc.cvMissingVv} metadata row(s) missing a vector, ${vc.vvOrphan} orphan vector(s) (content_vectors=${vc.cvCount}, vectors_vec=${vc.vvCount}). Run 'clawmem embed --force' to rebuild.`);
2048
+ issues++;
2049
+ } else {
2050
+ console.log(`${c.green}✓${c.reset} Vector consistency: ${vc.vvCount} vectors match ${vc.cvCount} metadata rows (${vc.pending} pending)`);
2051
+ }
1898
2052
  }
1899
- } catch {
1900
- console.log(`${c.yellow}!${c.reset} Vector index: could not check`);
2053
+ // Mixed embedding models = a heterogeneous vector space (cosine across different
2054
+ // models is meaningless) even when keys/dimensions are consistent. Flag it.
2055
+ const vecModels = s.getVecModels();
2056
+ if (vecModels.length > 1) {
2057
+ console.log(`${c.red}✗${c.reset} Embedding models: vault has MIXED models (${vecModels.join(", ")}) — heterogeneous vector space. Run 'clawmem embed --force' to rebuild with one model.`);
2058
+ issues++;
2059
+ }
2060
+ } catch (err) {
2061
+ console.log(`${c.yellow}!${c.reset} Vector index: could not check (${(err as Error).message})`);
1901
2062
  }
1902
2063
 
1903
2064
  // 4. Content types
@@ -422,15 +422,20 @@ class ClawMemProvider(MemoryProvider):
422
422
  if not self._bin or not query or len(query) < 5:
423
423
  return
424
424
 
425
- # Increment generation so older threads can't overwrite newer results
425
+ # Increment generation so older threads can't overwrite newer results,
426
+ # and snapshot the session id + transcript path under the same lock so a
427
+ # concurrent on_session_switch() can't make the worker read a torn
428
+ # (new id / old path) pair — the worker uses the snapshot, never live state.
426
429
  with self._prefetch_lock:
427
430
  self._prefetch_generation += 1
428
431
  my_gen = self._prefetch_generation
432
+ run_session_id = self._session_id
433
+ run_transcript_path = self._transcript_path
429
434
 
430
435
  def _run():
431
436
  hook_input = {
432
- "session_id": self._session_id,
433
- "transcript_path": self._transcript_path,
437
+ "session_id": run_session_id,
438
+ "transcript_path": run_transcript_path,
434
439
  "prompt": query,
435
440
  "hook_event_name": "UserPromptSubmit",
436
441
  }
@@ -531,6 +536,50 @@ class ClawMemProvider(MemoryProvider):
531
536
 
532
537
  logger.info("clawmem: session %s extraction complete", self._session_id[:8])
533
538
 
539
+ def on_session_switch(
540
+ self,
541
+ new_session_id: str,
542
+ *,
543
+ parent_session_id: str = "",
544
+ reset: bool = False,
545
+ **kwargs,
546
+ ) -> None:
547
+ """Refresh session-derived state when Hermes rotates session_id mid-process.
548
+
549
+ Fires on /new (reset=True), /resume, /branch, and compression (reset=False).
550
+ ClawMem reads _session_id and the session-keyed _transcript_path live in
551
+ queue_prefetch / sync_turn / on_session_end / on_pre_compress, so a switch
552
+ must repoint them and drop the prior session's prefetch + bootstrap context
553
+ (unconditional — NOT gated on reset, or stale recall leaks into the new
554
+ session). Cache coherence, not a vault write, so it runs for all contexts.
555
+ """
556
+ new_id = str(new_session_id or "").strip()
557
+ if not new_id or not self._bin:
558
+ return
559
+ # Idempotent re-fire (duplicate dispatch) with no reset is a no-op.
560
+ if new_id == self._session_id and not reset:
561
+ return
562
+
563
+ new_path = self._transcript_path
564
+ if self._hermes_home:
565
+ transcript_dir = Path(self._hermes_home) / "clawmem-transcripts"
566
+ transcript_dir.mkdir(parents=True, exist_ok=True)
567
+ new_path = str(transcript_dir / f"{new_id}.jsonl")
568
+
569
+ with self._prefetch_lock:
570
+ self._session_id = new_id
571
+ self._transcript_path = new_path
572
+ # Bump generation MONOTONICALLY (never reset to 0): an in-flight
573
+ # prefetch worker then fails its `my_gen == _prefetch_generation`
574
+ # check and discards its result instead of leaking it into the new
575
+ # session. Advancing consumed_gen drops any already-cached result.
576
+ self._prefetch_generation += 1
577
+ self._prefetch_result = ""
578
+ self._prefetch_result_gen = 0
579
+ self._prefetch_consumed_gen = self._prefetch_generation
580
+ # Startup context is session-derived; must not cross session ids.
581
+ self._bootstrap_context = ""
582
+
534
583
  def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str:
535
584
  """Run precompact-extract (side effect only — Hermes ignores return).
536
585
 
package/src/llm.ts CHANGED
@@ -290,6 +290,7 @@ function normalizeRemoteLlmReasoningEffort(value?: string): string | null {
290
290
 
291
291
  function buildRemoteChatCompletionsUrl(remoteLlmUrl: string): string {
292
292
  const baseUrl = remoteLlmUrl.replace(/\/+$/, "");
293
+ if (baseUrl.endsWith("/chat/completions")) return baseUrl;
293
294
  const endpoint = baseUrl.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions";
294
295
  return `${baseUrl}${endpoint}`;
295
296
  }
package/src/store.ts CHANGED
@@ -592,6 +592,25 @@ function initializeDatabase(db: Database): void {
592
592
  }
593
593
  }
594
594
 
595
+ // Centralize the embed-lifecycle reset on content change: ANY path that updates
596
+ // documents.hash (updateDocument, reactivateDocument, indexer reactivation,
597
+ // decision/antipattern merges, saveMemory, beads sync, …) gets the doc's embed
598
+ // state reset, so the new content is re-embedded with a fresh retry budget and is
599
+ // never excluded by the OLD content's exhausted embed_attempts. One trigger covers
600
+ // every caller (codex HIGH, INCIDENT-2026-06-22). It fires only on an actual change
601
+ // (`IS NOT` is null-safe) and does not touch hash, so it cannot recurse. Created
602
+ // right after the embed_* column migrations above, so the columns it references
603
+ // exist. NOT wrapped in try/catch: a creation failure means this load-bearing
604
+ // reset is silently absent, so let it surface loudly rather than hide a footgun.
605
+ db.exec(`
606
+ CREATE TRIGGER IF NOT EXISTS reset_embed_on_hash_change
607
+ AFTER UPDATE OF hash ON documents
608
+ FOR EACH ROW WHEN OLD.hash IS NOT NEW.hash
609
+ BEGIN
610
+ UPDATE documents SET embed_state = 'pending', embed_attempts = 0, embed_error = NULL WHERE id = NEW.id;
611
+ END;
612
+ `);
613
+
595
614
  // Migration: add A-MEM columns to documents
596
615
  const amemMigrations: [string, string][] = [
597
616
  ["amem_keywords", "ALTER TABLE documents ADD COLUMN amem_keywords TEXT"],
@@ -923,32 +942,153 @@ function initializeDatabase(db: Database): void {
923
942
  }
924
943
 
925
944
 
926
- // Per-database dimension cache (WeakMap keyed by db object — no collisions for in-memory DBs)
927
- const vecTableDimsCache = new WeakMap<Database, number>();
928
-
929
945
  // v0.8.1 Ext 6b: per-database cache for the query_text column presence on
930
946
  // context_usage. Set once at migration time so insertUsageFn can pick the
931
947
  // correct INSERT shape without running PRAGMA on every write. Falls back
932
948
  // to `false` (safe — equivalent to pre-migration behavior) when absent.
933
949
  const contextUsageHasQueryTextCache = new WeakMap<Database, boolean>();
934
950
 
935
- function ensureVecTableInternal(db: Database, dimensions: number): void {
936
- if (vecTableDimsCache.get(db) === dimensions) return;
937
-
938
- const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
939
- if (tableInfo) {
940
- const match = tableInfo.sql.match(/float\[(\d+)\]/);
941
- const hasHashSeq = tableInfo.sql.includes('hash_seq');
942
- const hasCosine = tableInfo.sql.includes('distance_metric=cosine');
943
- const existingDims = match?.[1] ? parseInt(match[1], 10) : null;
944
- if (existingDims === dimensions && hasHashSeq && hasCosine) {
945
- vecTableDimsCache.set(db, dimensions);
951
+ /**
952
+ * Fatal, non-recoverable vector-store errors. These abort the embed run rather
953
+ * than being swallowed as per-fragment failures. A dimension/schema mismatch must
954
+ * NEVER silently drop an existing table the only path that clears vectors is the
955
+ * explicit clearAllEmbeddings (gated behind `embed --force`). See
956
+ * INCIDENT-2026-06-22 §12 + EMBED-LEASE-RENEWAL-DESIGN.md.
957
+ */
958
+ export class FatalVectorError extends Error {}
959
+
960
+ export class VecDimensionMismatchError extends FatalVectorError {
961
+ constructor(public readonly existingDim: number, public readonly requestedDim: number) {
962
+ super(`Embedding dimension changed: vectors_vec is float[${existingDim}] but the model now returns float[${requestedDim}]. Run 'clawmem embed --force' to clear and rebuild the full vault.`);
963
+ this.name = "VecDimensionMismatchError";
964
+ }
965
+ }
966
+
967
+ export class VecSchemaError extends FatalVectorError {
968
+ constructor(message: string) {
969
+ super(message);
970
+ this.name = "VecSchemaError";
971
+ }
972
+ }
973
+
974
+ export class VecModelMismatchError extends FatalVectorError {
975
+ constructor(public readonly expectedModel: string, public readonly actualModel: string) {
976
+ super(`Embedding model changed mid-run: the vault is being built with "${expectedModel}" but the endpoint now returns "${actualModel}". Mixing different models in one vector space (even at the same dimension) makes cosine similarity meaningless. Run 'clawmem embed --force' to rebuild with the current model.`);
977
+ this.name = "VecModelMismatchError";
978
+ }
979
+ }
980
+
981
+ // A FatalVectorError so it propagates out of the per-fragment catch and aborts the
982
+ // embed run. Thrown both by cmdEmbed (between fragments) and INSIDE insertEmbedding's
983
+ // write transaction (atomic lease-token fence) when the lease was reclaimed.
984
+ export class EmbedLeaseLostError extends FatalVectorError {
985
+ constructor() {
986
+ super("Embedding lease lost (another embed process took over). Re-run 'clawmem embed'.");
987
+ this.name = "EmbedLeaseLostError";
988
+ }
989
+ }
990
+
991
+ /** A held embedding-lease identity, passed into vector mutations so each can verify
992
+ * ownership before mutating — a process that lost the lease cannot wipe/recreate/write
993
+ * the vector store under the new holder. */
994
+ export type LeaseGuard = { workerName: string; token: string };
995
+
996
+ /**
997
+ * Throw EmbedLeaseLostError if the caller no longer holds the named lease. Call as the
998
+ * FIRST statement inside a write transaction (before any mutation), so the check and
999
+ * the writes are atomic under SQLite's single-writer serialization — no other holder
1000
+ * can interleave a reclaim between the check and the mutation. No-op when leaseGuard
1001
+ * is omitted (non-embed callers).
1002
+ */
1003
+ function assertLeaseHeld(db: Database, leaseGuard?: LeaseGuard): void {
1004
+ if (!leaseGuard) return;
1005
+ const row = db.prepare(`SELECT lease_token FROM worker_leases WHERE worker_name = ?`).get(leaseGuard.workerName) as { lease_token: string } | null;
1006
+ if (!row || row.lease_token !== leaseGuard.token) {
1007
+ throw new EmbedLeaseLostError();
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Single source of truth for vectors_vec schema validation. Reads the table DDL
1013
+ * and returns:
1014
+ * - null → table ABSENT
1015
+ * - <integer> → table VALID (vec0 schema: hash_seq + float[N] + cosine); the N
1016
+ * - throws VecSchemaError → table PRESENT but its schema is unexpected
1017
+ * (missing float[N], hash_seq, or distance_metric=cosine; malformed/legacy DDL).
1018
+ * No caching: a stale per-process dim cache could bypass this validation when
1019
+ * another process changed the table. The embed lease serializes embeds, and the
1020
+ * old cache fast-path is removed so validation is unconditional on every call.
1021
+ * Case-insensitive, whitespace-tolerant.
1022
+ */
1023
+ function readVecTableDim(db: Database): number | null {
1024
+ const info = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
1025
+ if (!info) return null;
1026
+ const m = info.sql.match(/float\s*\[\s*(\d+)\s*\]/i);
1027
+ const hasHashSeq = /\bhash_seq\b/i.test(info.sql);
1028
+ const hasCosine = /distance_metric\s*=\s*cosine/i.test(info.sql);
1029
+ if (!m || !hasHashSeq || !hasCosine) {
1030
+ throw new VecSchemaError(`vectors_vec exists but its schema is unexpected (need hash_seq + float[N] + distance_metric=cosine): ${info.sql}`);
1031
+ }
1032
+ return parseInt(m[1]!, 10);
1033
+ }
1034
+
1035
+ function ensureVecTableInternal(db: Database, dimensions: number, leaseGuard?: LeaseGuard): void {
1036
+ const existing = readVecTableDim(db); // null=absent, N=valid, throws VecSchemaError if malformed
1037
+ if (existing !== null) {
1038
+ // NEVER drop an existing table on a dimension mismatch — that silently destroys
1039
+ // the vault's vectors while the metadata-based worklist skips the now-vectorless
1040
+ // docs (INCIDENT-2026-06-22). Throw; the run aborts. Clear+rebuild happens only
1041
+ // via clearAllEmbeddings (`embed --force`).
1042
+ if (existing !== dimensions) throw new VecDimensionMismatchError(existing, dimensions);
1043
+ return; // exists at the right dim + valid schema → nothing to do
1044
+ }
1045
+ // Table absent → create it ATOMICALLY. The lease assertion, a RE-CHECK of absence,
1046
+ // and the CREATE run in ONE immediate-write-lock transaction, so no other process
1047
+ // can clear/recreate between the check and the CREATE (closes the TOCTOU): a process
1048
+ // that lost its lease cannot create an empty table at the wrong dimension under the
1049
+ // new holder, and two creators cannot race. (vec0 CREATE works inside a transaction.)
1050
+ db.transaction(() => {
1051
+ assertLeaseHeld(db, leaseGuard);
1052
+ const recheck = readVecTableDim(db);
1053
+ if (recheck !== null) {
1054
+ // Another holder created it while we waited for the write lock.
1055
+ if (recheck !== dimensions) throw new VecDimensionMismatchError(recheck, dimensions);
946
1056
  return;
947
1057
  }
948
- db.exec("DROP TABLE IF EXISTS vectors_vec");
949
- }
950
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
951
- vecTableDimsCache.set(db, dimensions);
1058
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}] distance_metric=cosine)`);
1059
+ }).immediate();
1060
+ }
1061
+
1062
+ /**
1063
+ * Public accessor for the vectors_vec dimension. Returns null if absent, the
1064
+ * integer dimension if valid, or throws VecSchemaError if the table exists with
1065
+ * a malformed/unexpected schema (see readVecTableDim).
1066
+ */
1067
+ export function getVecTableDim(db: Database): number | null {
1068
+ return readVecTableDim(db);
1069
+ }
1070
+
1071
+ /**
1072
+ * The DISTINCT non-empty embedding models stored in the vault (empty array if no
1073
+ * embeddings exist). Used to detect model drift BETWEEN runs — a different model at
1074
+ * the SAME dimension produces a heterogeneous vector space that dimension checks
1075
+ * cannot catch. Returning ALL distinct models (not just the majority) lets callers
1076
+ * also detect an ALREADY-heterogeneous vault (length > 1) instead of hiding the
1077
+ * minority behind a majority match. The implicit embed path aborts and requires
1078
+ * `embed --force` on any drift.
1079
+ */
1080
+ export function getVecModels(db: Database): string[] {
1081
+ // Join to ACTIVE documents so stale/orphaned content_vectors rows (an obsolete
1082
+ // model on an inactive hash, not yet cleaned) cannot trigger a permanent
1083
+ // "mixed models" abort that would block the very cleanup that removes them.
1084
+ const rows = db.prepare(
1085
+ `SELECT DISTINCT cv.model
1086
+ FROM content_vectors cv
1087
+ JOIN documents d ON d.hash = cv.hash AND d.active = 1
1088
+ WHERE cv.model IS NOT NULL AND cv.model != ''
1089
+ ORDER BY cv.model`
1090
+ ).all() as { model: string }[];
1091
+ return rows.map(r => r.model);
952
1092
  }
953
1093
 
954
1094
  // =============================================================================
@@ -959,7 +1099,9 @@ export type Store = {
959
1099
  db: Database;
960
1100
  dbPath: string;
961
1101
  close: () => void;
962
- ensureVecTable: (dimensions: number) => void;
1102
+ ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => void;
1103
+ getVecTableDim: () => number | null;
1104
+ getVecModels: () => string[];
963
1105
 
964
1106
  // Index health
965
1107
  getHashesNeedingEmbedding: () => number;
@@ -976,7 +1118,6 @@ export type Store = {
976
1118
  deleteLLMCache: () => number;
977
1119
  deleteInactiveDocuments: () => number;
978
1120
  cleanupOrphanedContent: () => number;
979
- cleanupOrphanedVectors: () => number;
980
1121
  vacuumDatabase: () => void;
981
1122
 
982
1123
  // Context
@@ -1025,9 +1166,10 @@ export type Store = {
1025
1166
  // Vector/embedding operations
1026
1167
  getHashesForEmbedding: () => { hash: string; body: string; path: string }[];
1027
1168
  getHashesNeedingFragments: () => { hash: string; body: string; path: string; title: string; collection: string }[];
1028
- clearAllEmbeddings: () => void;
1029
- insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string) => void;
1030
- cleanStaleEmbeddings: () => number;
1169
+ clearAllEmbeddings: (leaseGuard?: LeaseGuard) => void;
1170
+ getVectorConsistency: () => { cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number };
1171
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => void;
1172
+ cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => number;
1031
1173
 
1032
1174
  // SAME: Observation metadata
1033
1175
  updateObservationFields: (docPath: string, collectionName: string, fields: { observation_type?: string; facts?: string; narrative?: string; concepts?: string; files_read?: string; files_modified?: string }) => void;
@@ -1052,6 +1194,7 @@ export type Store = {
1052
1194
  snoozeDocument: (collection: string, path: string, until: string | null) => void;
1053
1195
 
1054
1196
  // Embed state tracking
1197
+ markEmbedStart: (hash: string) => void;
1055
1198
  markEmbedSynced: (hash: string) => void;
1056
1199
  markEmbedFailed: (hash: string, error: string) => void;
1057
1200
  getEmbedStats: () => { pending: number; synced: number; failed: number };
@@ -1150,7 +1293,9 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1150
1293
  db,
1151
1294
  dbPath: resolvedPath,
1152
1295
  close: () => db.close(),
1153
- ensureVecTable: (dimensions: number) => ensureVecTableInternal(db, dimensions),
1296
+ ensureVecTable: (dimensions: number, leaseGuard?: LeaseGuard) => ensureVecTableInternal(db, dimensions, leaseGuard),
1297
+ getVecTableDim: () => getVecTableDim(db),
1298
+ getVecModels: () => getVecModels(db),
1154
1299
 
1155
1300
  // Index health
1156
1301
  getHashesNeedingEmbedding: () => getHashesNeedingEmbedding(db),
@@ -1167,7 +1312,6 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1167
1312
  deleteLLMCache: () => deleteLLMCache(db),
1168
1313
  deleteInactiveDocuments: () => deleteInactiveDocuments(db),
1169
1314
  cleanupOrphanedContent: () => cleanupOrphanedContent(db),
1170
- cleanupOrphanedVectors: () => cleanupOrphanedVectors(db),
1171
1315
  vacuumDatabase: () => vacuumDatabase(db),
1172
1316
 
1173
1317
  // Context
@@ -1216,9 +1360,10 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1216
1360
  // Vector/embedding operations
1217
1361
  getHashesForEmbedding: () => getHashesForEmbedding(db),
1218
1362
  getHashesNeedingFragments: () => getHashesNeedingFragments(db),
1219
- clearAllEmbeddings: () => clearAllEmbeddings(db),
1220
- insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId),
1221
- cleanStaleEmbeddings: () => cleanStaleEmbeddings(db),
1363
+ clearAllEmbeddings: (leaseGuard?: LeaseGuard) => clearAllEmbeddings(db, leaseGuard),
1364
+ getVectorConsistency: () => getVectorConsistency(db),
1365
+ insertEmbedding: (hash: string, seq: number, pos: number, embedding: Float32Array, model: string, embeddedAt: string, fragmentType?: string, fragmentLabel?: string, canonicalId?: string, leaseGuard?: LeaseGuard) => insertEmbedding(db, hash, seq, pos, embedding, model, embeddedAt, fragmentType, fragmentLabel, canonicalId, leaseGuard),
1366
+ cleanStaleEmbeddings: (leaseGuard?: LeaseGuard) => cleanStaleEmbeddings(db, leaseGuard),
1222
1367
 
1223
1368
  // SAME: Observation metadata
1224
1369
  updateObservationFields: (docPath: string, collectionName: string, fields) => updateObservationFieldsFn(db, docPath, collectionName, fields),
@@ -1243,11 +1388,23 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1243
1388
  snoozeDocument: (collection: string, path: string, until: string | null) => snoozeDocumentFn(db, collection, path, until),
1244
1389
 
1245
1390
  // Embed state tracking
1391
+ markEmbedStart: (hash: string) => {
1392
+ // Increment embed_attempts exactly ONCE per attempt, at the start, and set
1393
+ // 'pending' so a crash mid-document leaves the doc retryable (and selected by
1394
+ // getHashesNeedingFragments). The completion setters below are state-only — no
1395
+ // further increment — so a start + a failure for the same attempt cannot
1396
+ // double-count the retry budget.
1397
+ db.prepare(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(hash);
1398
+ },
1246
1399
  markEmbedSynced: (hash: string) => {
1247
- db.prepare(`UPDATE documents SET embed_state = 'synced' WHERE hash = ? AND active = 1`).run(hash);
1400
+ // Success resets the retry budget: embed_attempts counts CONSECUTIVE failures
1401
+ // of the current content, so a successful (re-)embed must clear it — otherwise
1402
+ // a doc re-embedded many times (repeated content edits) accumulates attempts
1403
+ // and is wrongly excluded by the worklist's `embed_attempts < 3` guard.
1404
+ db.prepare(`UPDATE documents SET embed_state = 'synced', embed_attempts = 0, embed_error = NULL WHERE hash = ? AND active = 1`).run(hash);
1248
1405
  },
1249
1406
  markEmbedFailed: (hash: string, error: string) => {
1250
- db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ?, embed_attempts = COALESCE(embed_attempts, 0) + 1 WHERE hash = ? AND active = 1`).run(error, hash);
1407
+ db.prepare(`UPDATE documents SET embed_state = 'failed', embed_error = ? WHERE hash = ? AND active = 1`).run(error, hash);
1251
1408
  },
1252
1409
  getEmbedStats: () => {
1253
1410
  const stats = db.prepare(`
@@ -1923,51 +2080,9 @@ export function cleanupOrphanedContent(db: Database): number {
1923
2080
  return result.changes;
1924
2081
  }
1925
2082
 
1926
- /**
1927
- * Remove orphaned vector embeddings that are not referenced by any active document.
1928
- * Returns the number of orphaned embedding chunks deleted.
1929
- */
1930
- export function cleanupOrphanedVectors(db: Database): number {
1931
- // Check if vectors_vec table exists
1932
- const tableExists = db.prepare(`
1933
- SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'
1934
- `).get();
1935
-
1936
- if (!tableExists) {
1937
- return 0;
1938
- }
1939
-
1940
- // Count orphaned vectors first
1941
- const countResult = db.prepare(`
1942
- SELECT COUNT(*) as c FROM content_vectors cv
1943
- WHERE NOT EXISTS (
1944
- SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
1945
- )
1946
- `).get() as { c: number };
1947
-
1948
- if (countResult.c === 0) {
1949
- return 0;
1950
- }
1951
-
1952
- // Delete from vectors_vec first
1953
- db.exec(`
1954
- DELETE FROM vectors_vec WHERE hash_seq IN (
1955
- SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
1956
- WHERE NOT EXISTS (
1957
- SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
1958
- )
1959
- )
1960
- `);
1961
-
1962
- // Delete from content_vectors
1963
- db.exec(`
1964
- DELETE FROM content_vectors WHERE hash NOT IN (
1965
- SELECT hash FROM documents WHERE active = 1
1966
- )
1967
- `);
1968
-
1969
- return countResult.c;
1970
- }
2083
+ // (Removed cleanupOrphanedVectors — an exposed, unfenced, non-transactional vector
2084
+ // mutation with no production caller. Use cleanStaleEmbeddings, which is lease-fenced
2085
+ // and atomic. See INCIDENT-2026-06-22 / codex review.)
1971
2086
 
1972
2087
  /**
1973
2088
  * Run VACUUM to reclaim unused space in the database.
@@ -1996,35 +2111,40 @@ export function canonicalDocId(collection: string, path: string): string {
1996
2111
  * to any active document. Also cleans the corresponding vectors_vec rows.
1997
2112
  * Returns the number of stale embeddings removed.
1998
2113
  */
1999
- export function cleanStaleEmbeddings(db: Database): number {
2000
- // Find orphaned hashes in content_vectors that have no active document
2001
- const staleRows = db.prepare(`
2002
- SELECT DISTINCT cv.hash
2003
- FROM content_vectors cv
2004
- LEFT JOIN documents d ON d.hash = cv.hash AND d.active = 1
2005
- WHERE d.id IS NULL
2006
- `).all() as { hash: string }[];
2007
-
2008
- if (staleRows.length === 0) return 0;
2009
-
2010
- const staleHashes = staleRows.map(r => r.hash);
2011
-
2012
- // Get all hash_seq keys for stale rows to clean vectors_vec
2013
- const placeholders = staleHashes.map(() => '?').join(',');
2014
- const staleVecKeys = db.prepare(`
2015
- SELECT hash || '_' || seq as hash_seq FROM content_vectors WHERE hash IN (${placeholders})
2016
- `).all(...staleHashes) as { hash_seq: string }[];
2017
-
2018
- // Delete from vectors_vec
2019
- if (staleVecKeys.length > 0) {
2020
- const vecPlaceholders = staleVecKeys.map(() => '?').join(',');
2021
- db.prepare(`DELETE FROM vectors_vec WHERE hash_seq IN (${vecPlaceholders})`).run(...staleVecKeys.map(r => r.hash_seq));
2022
- }
2114
+ export function cleanStaleEmbeddings(db: Database, leaseGuard?: LeaseGuard): number {
2115
+ // Atomic + lease-fenced: the ownership check and the deletes share one transaction
2116
+ // so a process that lost its lease cannot delete vectors out from under the new holder.
2117
+ return db.transaction(() => {
2118
+ assertLeaseHeld(db, leaseGuard);
2119
+ // Find orphaned hashes in content_vectors that have no active document
2120
+ const staleRows = db.prepare(`
2121
+ SELECT DISTINCT cv.hash
2122
+ FROM content_vectors cv
2123
+ LEFT JOIN documents d ON d.hash = cv.hash AND d.active = 1
2124
+ WHERE d.id IS NULL
2125
+ `).all() as { hash: string }[];
2126
+
2127
+ if (staleRows.length === 0) return 0;
2128
+
2129
+ const staleHashes = staleRows.map(r => r.hash);
2130
+
2131
+ // Get all hash_seq keys for stale rows to clean vectors_vec
2132
+ const placeholders = staleHashes.map(() => '?').join(',');
2133
+ const staleVecKeys = db.prepare(`
2134
+ SELECT hash || '_' || seq as hash_seq FROM content_vectors WHERE hash IN (${placeholders})
2135
+ `).all(...staleHashes) as { hash_seq: string }[];
2136
+
2137
+ // Delete from vectors_vec
2138
+ if (staleVecKeys.length > 0) {
2139
+ const vecPlaceholders = staleVecKeys.map(() => '?').join(',');
2140
+ db.prepare(`DELETE FROM vectors_vec WHERE hash_seq IN (${vecPlaceholders})`).run(...staleVecKeys.map(r => r.hash_seq));
2141
+ }
2023
2142
 
2024
- // Delete from content_vectors
2025
- db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
2143
+ // Delete from content_vectors
2144
+ db.prepare(`DELETE FROM content_vectors WHERE hash IN (${placeholders})`).run(...staleHashes);
2026
2145
 
2027
- return staleVecKeys.length;
2146
+ return staleVecKeys.length;
2147
+ }).immediate(); // immediate write lock: assert ownership under the lock before the deletes
2028
2148
  }
2029
2149
 
2030
2150
  // =============================================================================
@@ -2410,6 +2530,8 @@ export function reactivateDocument(
2410
2530
  modifiedAt: string
2411
2531
  ): void {
2412
2532
  const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
2533
+ // The reset_embed_on_hash_change trigger resets embed_state/attempts/error iff the
2534
+ // hash actually changes, so re-adding unchanged content preserves its valid vectors.
2413
2535
  db.prepare(`UPDATE documents SET active = 1, title = ?, hash = ?, modified_at = ? WHERE id = ?`)
2414
2536
  .run(safeTitle, hash, modifiedAt, documentId);
2415
2537
  }
@@ -2439,6 +2561,8 @@ export function updateDocument(
2439
2561
  modifiedAt: string
2440
2562
  ): void {
2441
2563
  const safeTitle = (typeof title === "string") ? title : String(title ?? "Untitled");
2564
+ // The reset_embed_on_hash_change trigger resets embed_state/attempts/error when the
2565
+ // hash actually changes, so this only needs to set the content fields.
2442
2566
  db.prepare(`UPDATE documents SET title = ?, hash = ?, modified_at = ? WHERE id = ?`)
2443
2567
  .run(safeTitle, hash, modifiedAt, documentId);
2444
2568
  }
@@ -3305,6 +3429,9 @@ export function getHashesForEmbedding(db: Database): { hash: string; body: strin
3305
3429
  export function getHashesNeedingFragments(db: Database): { hash: string; body: string; path: string; title: string; collection: string }[] {
3306
3430
  // Select docs that either have no fragments at all OR are missing the primary (seq=0) fragment.
3307
3431
  // The seq=0 embedding is critical — surprisal scoring, semantic graph, and health checks depend on it.
3432
+ // Also retry docs left 'pending' (crash mid-doc) or 'failed' (partial fragment failure) so partial
3433
+ // embeds are not permanently silent — bounded by embed_attempts < 3. The OR-branch is parenthesized
3434
+ // so embed_attempts < 3 and d.active = 1 always apply to every selected row (SQL precedence).
3308
3435
  return db.prepare(`
3309
3436
  SELECT d.hash, c.doc as body, MIN(d.path) as path, MIN(d.title) as title, MIN(d.collection) as collection
3310
3437
  FROM documents d
@@ -3312,8 +3439,8 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
3312
3439
  LEFT JOIN content_vectors v ON d.hash = v.hash AND v.fragment_type IS NOT NULL
3313
3440
  LEFT JOIN content_vectors v0 ON d.hash = v0.hash AND v0.seq = 0
3314
3441
  WHERE d.active = 1
3315
- AND (v.hash IS NULL OR v0.hash IS NULL)
3316
3442
  AND COALESCE(d.embed_attempts, 0) < 3
3443
+ AND ((v.hash IS NULL OR v0.hash IS NULL) OR d.embed_state IN ('pending', 'failed'))
3317
3444
  GROUP BY d.hash
3318
3445
  `).all() as { hash: string; body: string; path: string; title: string; collection: string }[];
3319
3446
  }
@@ -3322,12 +3449,53 @@ export function getHashesNeedingFragments(db: Database): { hash: string; body: s
3322
3449
  * Clear all embeddings from the database (force re-index).
3323
3450
  * Deletes all rows from content_vectors and drops the vectors_vec table.
3324
3451
  */
3325
- export function clearAllEmbeddings(db: Database): void {
3326
- db.exec(`DELETE FROM content_vectors`);
3327
- db.exec(`DROP TABLE IF EXISTS vectors_vec`);
3328
- // Reset embed state so failed docs get retried after force re-embed
3329
- try { db.exec(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = 0 WHERE active = 1`); } catch { /* column may not exist yet */ }
3330
- vecTableDimsCache.delete(db);
3452
+ export function clearAllEmbeddings(db: Database, leaseGuard?: LeaseGuard): void {
3453
+ // Atomic: the lease check + DELETE content_vectors + DROP vectors_vec + reset
3454
+ // embed_state all commit together. The in-transaction assertLeaseHeld means a
3455
+ // `--force` process that lost its lease during the endpoint probe cannot wipe the
3456
+ // vault out from under the new holder (the destructive op is the highest-risk
3457
+ // mutation, so it MUST be fenced). A crash/concurrent reader never observes a
3458
+ // half-cleared state.
3459
+ db.transaction(() => {
3460
+ assertLeaseHeld(db, leaseGuard);
3461
+ db.exec(`DELETE FROM content_vectors`);
3462
+ db.exec(`DROP TABLE IF EXISTS vectors_vec`);
3463
+ // Reset embed state so failed docs get retried after force re-embed
3464
+ try { db.exec(`UPDATE documents SET embed_state = 'pending', embed_error = NULL, embed_attempts = 0 WHERE active = 1`); } catch { /* column may not exist yet */ }
3465
+ }).immediate(); // immediate write lock: assert ownership under the lock before this destructive op
3466
+ }
3467
+
3468
+ /**
3469
+ * Vault-wide content_vectors ↔ vectors_vec consistency snapshot for `doctor`.
3470
+ * Computes BOTH key-set differences (not just counts — one missing + one orphan
3471
+ * cancel in a count check) under a single read transaction so both scans see the
3472
+ * same committed snapshot. Any nonzero cvMissingVv / vvOrphan is an invariant
3473
+ * violation (every content_vectors row must have a vectors_vec entry and vice-versa).
3474
+ * `pending` is reported separately (pending docs are in neither table, so they are
3475
+ * not a desync). See INCIDENT-2026-06-22 §12.
3476
+ */
3477
+ export function getVectorConsistency(db: Database): {
3478
+ cvCount: number; vvCount: number; cvMissingVv: number; vvOrphan: number; pending: number;
3479
+ } {
3480
+ return db.transaction(() => {
3481
+ const cvKeys = new Set<string>(
3482
+ (db.prepare(`SELECT hash || '_' || seq AS k FROM content_vectors`).all() as { k: string }[]).map(r => r.k)
3483
+ );
3484
+ let vvKeys = new Set<string>();
3485
+ try {
3486
+ vvKeys = new Set<string>(
3487
+ (db.prepare(`SELECT hash_seq FROM vectors_vec`).all() as { hash_seq: string }[]).map(r => r.hash_seq)
3488
+ );
3489
+ } catch { /* vectors_vec absent → empty set (cvMissingVv will surface it) */ }
3490
+ let cvMissingVv = 0;
3491
+ for (const k of cvKeys) if (!vvKeys.has(k)) cvMissingVv++;
3492
+ let vvOrphan = 0;
3493
+ for (const k of vvKeys) if (!cvKeys.has(k)) vvOrphan++;
3494
+ const pending = (db.prepare(
3495
+ `SELECT COUNT(*) AS n FROM documents WHERE active = 1 AND (embed_state = 'pending' OR embed_state IS NULL)`
3496
+ ).get() as { n: number }).n;
3497
+ return { cvCount: cvKeys.size, vvCount: vvKeys.size, cvMissingVv, vvOrphan, pending };
3498
+ })();
3331
3499
  }
3332
3500
 
3333
3501
  /**
@@ -3344,16 +3512,31 @@ export function insertEmbedding(
3344
3512
  embeddedAt: string,
3345
3513
  fragmentType?: string,
3346
3514
  fragmentLabel?: string,
3347
- canonicalId?: string
3515
+ canonicalId?: string,
3516
+ leaseGuard?: LeaseGuard
3348
3517
  ): void {
3349
3518
  const hashSeq = `${hash}_${seq}`;
3350
- // vec0 virtual tables don't support INSERT OR REPLACEdelete first if exists.
3351
- // Try-catch: table may not exist yet during dimension migration (ensureVecTable drops+recreates).
3352
- try { db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq); } catch {}
3353
- db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
3354
- db.prepare(
3355
- `INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3356
- ).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
3519
+ // Atomic vec0 + metadata write: the DELETE (vec0's "upsert"no INSERT OR
3520
+ // REPLACE on vec0), the vector INSERT, and the content_vectors UPSERT commit
3521
+ // together, so an interruption can never leave a vector without its metadata
3522
+ // row or vice-versa (the cv↔vv invariant the doctor check enforces).
3523
+ // ensureVecTable is always called before this and now throws (never drops)
3524
+ // on a dimension mismatch, so vectors_vec reliably exists here the old
3525
+ // DELETE error-suppression for the "table missing mid-migration" case is gone.
3526
+ //
3527
+ // leaseGuard (optional): an in-transaction embedding-lease fence. The token
3528
+ // check and the writes share ONE transaction, so a process that lost the lease
3529
+ // while awaiting the model (between its loop-level leaseLost check and this
3530
+ // write) cannot commit a stale vector — SQLite serializes the transaction, so
3531
+ // no other holder can interleave a reclaim between the check and the INSERTs.
3532
+ db.transaction(() => {
3533
+ assertLeaseHeld(db, leaseGuard);
3534
+ db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq);
3535
+ db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(hashSeq, embedding);
3536
+ db.prepare(
3537
+ `INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at, fragment_type, fragment_label, canonical_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3538
+ ).run(hash, seq, pos, model, embeddedAt, fragmentType ?? null, fragmentLabel ?? null, canonicalId ?? null);
3539
+ }).immediate(); // immediate write lock: the lease assert reads under the lock, so a lost-lease write can't slip through
3357
3540
  }
3358
3541
 
3359
3542
  // =============================================================================
@@ -114,6 +114,40 @@ export function releaseWorkerLease(
114
114
  return result.changes > 0;
115
115
  }
116
116
 
117
+ /**
118
+ * Extend a lease's expiry if the caller's token still owns it. Returns `true`
119
+ * if THIS holder still owns the lease and the TTL was extended; `false` if the
120
+ * row is gone or was reclaimed by another token (we lost the lease). A long
121
+ * embed run renews on a heartbeat well inside its TTL; on a `false` result the
122
+ * caller MUST abort, because another process now holds the lease and could run
123
+ * concurrently. Token-fenced like releaseWorkerLease (the UPDATE matches only
124
+ * our own (worker_name, lease_token) row); any DB error → `false` (fail-safe:
125
+ * a renewal failure is treated as lease-lost → abort).
126
+ */
127
+ export function renewWorkerLease(
128
+ store: Store,
129
+ workerName: string,
130
+ token: string,
131
+ ttlMs: number,
132
+ now: Date = new Date(),
133
+ ): boolean {
134
+ if (ttlMs <= 0) {
135
+ throw new Error(`renewWorkerLease: ttlMs must be positive, got ${ttlMs}`);
136
+ }
137
+ try {
138
+ const result = store.db.prepare(
139
+ `UPDATE worker_leases SET acquired_at = ?, expires_at = ?
140
+ WHERE worker_name = ? AND lease_token = ?`,
141
+ ).run(nowIso(now), futureIso(now, ttlMs), workerName, token);
142
+ return result.changes > 0;
143
+ } catch (err) {
144
+ console.error(
145
+ `[worker-lease] renew error for ${workerName}: ${(err as Error).message}`,
146
+ );
147
+ return false;
148
+ }
149
+ }
150
+
117
151
  /**
118
152
  * Run `fn` under an exclusive lease on `workerName`. If the lease cannot
119
153
  * be acquired, returns `{acquired: false}` without invoking `fn`. The