1mod extraction;
26mod postprocess;
27mod queue;
28mod scan;
29use extraction::{
30 call_body_enrich, call_body_extract, call_deep_research_synth, call_description_enrich,
31 call_domain_classify, call_entity_connect, call_entity_description, call_entity_type_validate,
32 call_graph_audit, call_memory_bindings, call_reembed, call_relation_reclassify,
33 call_weight_calibrate, find_codex_binary, take_last_openrouter_failure, EnrichItemResult,
34};
35use postprocess::{
36 persist_enriched_body, persist_entity_description, persist_memory_bindings,
37 reembed_memory_vector, take_enrich_backend,
38};
39pub use queue::{cleanup_queue_entry, DeadItem, DeadSummary, EnrichStatus, WaitingItem};
40use queue::{
41 dequeue_next_pending, enqueue_candidate, item_type_for, item_type_for_key, open_queue_db,
42 prune_dead_entity_orphans, prune_dead_orphans, record_item_failure, record_item_failure_typed,
43 skipped_item_keys, DequeueOutcome,
44};
45use scan::{
46 count_operation_backlog, scan_isolated_entity_pairs, scan_operation, scan_unbound_memories,
47};
48
49use crate::commands::ingest_claude::find_claude_binary;
50use crate::constants::MAX_MEMORY_BODY_LEN;
51use crate::entity_type::EntityType;
52use crate::errors::AppError;
53use crate::paths::AppPaths;
54use crate::storage::connection::{ensure_db_ready, open_rw};
55use crate::storage::entities::{self, NewEntity, NewRelationship};
56use crate::storage::memories;
57
58use rusqlite::Connection;
59use serde::{Deserialize, Serialize};
60use std::io::Write;
61use std::path::{Path, PathBuf};
62use std::time::Instant;
63
64const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
69const DEFAULT_BODY_ENRICH_MIN_CHARS: usize = 500;
70const DEFAULT_BODY_ENRICH_MAX_CHARS: usize = 2000;
71
72const BINDINGS_SCHEMA: &str = r#"{
77 "type": "object",
78 "properties": {
79 "entities": {
80 "type": "array",
81 "items": {
82 "type": "object",
83 "properties": {
84 "name": { "type": "string" },
85 "entity_type": {
86 "type": "string",
87 "enum": ["project","tool","person","file","concept","incident","decision","organization","location","date"]
88 }
89 },
90 "required": ["name", "entity_type"],
91 "additionalProperties": false
92 }
93 },
94 "relationships": {
95 "type": "array",
96 "items": {
97 "type": "object",
98 "properties": {
99 "source": { "type": "string" },
100 "target": { "type": "string" },
101 "relation": {
102 "type": "string",
103 "enum": ["applies-to","uses","depends-on","causes","fixes","contradicts","supports","follows","related","replaces","tracked-in"]
104 },
105 "strength": { "type": "number", "minimum": 0, "maximum": 1 }
106 },
107 "required": ["source","target","relation","strength"],
108 "additionalProperties": false
109 }
110 }
111 },
112 "required": ["entities","relationships"],
113 "additionalProperties": false
114}"#;
115
116const ENTITY_DESCRIPTION_SCHEMA: &str = r#"{
117 "type": "object",
118 "properties": {
119 "description": { "type": "string" }
120 },
121 "required": ["description"],
122 "additionalProperties": false
123}"#;
124
125const BODY_ENRICH_SCHEMA: &str = r#"{
126 "type": "object",
127 "properties": {
128 "enriched_body": { "type": "string" }
129 },
130 "required": ["enriched_body"],
131 "additionalProperties": false
132}"#;
133
134const WEIGHT_CALIBRATE_PROMPT: &str = "You are a knowledge graph quality auditor. Evaluate whether this relationship weight is correctly calibrated.\n\n\
136Scale:\n\
137- 0.9 = vital hard dependency (A cannot function without B)\n\
138- 0.7 = important design relationship (A strongly supports/enables B)\n\
139- 0.5 = useful contextual link (A and B share relevant context)\n\
140- 0.3 = weak reference (A mentions B without strong coupling)\n\n\
141Respond with the calibrated weight and brief reasoning.";
142
143const WEIGHT_CALIBRATE_SCHEMA: &str = r#"{
144 "type": "object",
145 "properties": {
146 "calibrated_weight": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
147 "reasoning": { "type": "string" }
148 },
149 "required": ["calibrated_weight", "reasoning"],
150 "additionalProperties": false
151}"#;
152
153const RELATION_RECLASSIFY_PROMPT: &str = "You are a knowledge graph quality auditor. The relationship between these entities uses a generic type. Determine the REAL semantic relationship.\n\n\
155Valid canonical relations (pick exactly one):\n\
156- depends-on: A cannot function without B\n\
157- uses: A utilizes B but could substitute it\n\
158- supports: A reinforces or enables B\n\
159- causes: A triggers or produces B\n\
160- fixes: A resolves a problem in B\n\
161- contradicts: A conflicts with or invalidates B\n\
162- applies-to: A is relevant to or scoped within B\n\
163- follows: A comes after B in sequence\n\
164- replaces: A substitutes B\n\
165- tracked-in: A is monitored in B\n\
166- related: A and B share context (use sparingly)\n\n\
167Respond with the correct relation, strength, and reasoning.";
168
169const RELATION_RECLASSIFY_SCHEMA: &str = r#"{
170 "type": "object",
171 "properties": {
172 "relation": { "type": "string" },
173 "strength": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
174 "reasoning": { "type": "string" }
175 },
176 "required": ["relation", "strength", "reasoning"],
177 "additionalProperties": false
178}"#;
179
180const ENTITY_CONNECT_PROMPT: &str = "You are a knowledge graph quality auditor. Two entities exist in the same graph but have no relationship between them. Determine if a meaningful relationship exists.\n\n\
182Valid canonical relations: depends-on, uses, supports, causes, fixes, contradicts, applies-to, follows, replaces, tracked-in, related.\n\n\
183If NO meaningful relationship exists, set relation to \"none\".\n\
184Respond with the relation (or \"none\"), strength, and reasoning.";
185
186const ENTITY_CONNECT_SCHEMA: &str = r#"{
187 "type": "object",
188 "properties": {
189 "relation": { "type": "string" },
190 "strength": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
191 "reasoning": { "type": "string" }
192 },
193 "required": ["relation", "strength", "reasoning"],
194 "additionalProperties": false
195}"#;
196
197const ENTITY_TYPE_VALIDATE_PROMPT: &str = "You are a knowledge graph quality auditor. Verify whether this entity's type is correct.\n\n\
199Valid entity types: project, tool, person, file, concept, incident, decision, organization, location, date.\n\n\
200If the current type is correct, keep it. If wrong, suggest the correct type.\n\
201Respond with the validated type and reasoning.";
202
203const ENTITY_TYPE_VALIDATE_SCHEMA: &str = r#"{
204 "type": "object",
205 "properties": {
206 "validated_type": { "type": "string" },
207 "was_correct": { "type": "boolean" },
208 "reasoning": { "type": "string" }
209 },
210 "required": ["validated_type", "was_correct", "reasoning"],
211 "additionalProperties": false
212}"#;
213
214const DESCRIPTION_ENRICH_PROMPT: &str = "You are a knowledge graph quality auditor. This memory has a generic or auto-generated description. Write a concise, semantic description (10-20 words) that captures WHAT this memory is about and WHY it matters.\n\n\
216BAD: 'ingested from docs/auth.md'\n\
217GOOD: 'JWT token rotation strategy with 15-min expiry and refresh flow'\n\n\
218Respond with the improved description and reasoning.";
219
220const DESCRIPTION_ENRICH_SCHEMA: &str = r#"{
221 "type": "object",
222 "properties": {
223 "description": { "type": "string" },
224 "reasoning": { "type": "string" }
225 },
226 "required": ["description", "reasoning"],
227 "additionalProperties": false
228}"#;
229
230const DOMAIN_CLASSIFY_PROMPT: &str = "You are a knowledge graph quality auditor. Classify this memory into its primary domain category.\n\n\
232Respond with the domain name (kebab-case, 2-4 words) and reasoning.";
233
234const DOMAIN_CLASSIFY_SCHEMA: &str = r#"{
235 "type": "object",
236 "properties": {
237 "domain": { "type": "string" },
238 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
239 "reasoning": { "type": "string" }
240 },
241 "required": ["domain", "confidence", "reasoning"],
242 "additionalProperties": false
243}"#;
244
245const GRAPH_AUDIT_PROMPT: &str = "You are a knowledge graph quality auditor. Analyze this memory and its entity bindings for quality issues.\n\n\
247Check for: missing entities, wrong entity types, redundant relationships, orphaned entities, generic descriptions, low-signal relationships.\n\n\
248Respond with a list of issues found (or empty if none) and an overall quality score.";
249
250const GRAPH_AUDIT_SCHEMA: &str = r#"{
251 "type": "object",
252 "properties": {
253 "quality_score": { "type": "number", "minimum": 0.0, "maximum": 1.0 },
254 "issues": { "type": "array", "items": { "type": "object", "properties": { "kind": { "type": "string" }, "detail": { "type": "string" } }, "required": ["kind", "detail"] } },
255 "reasoning": { "type": "string" }
256 },
257 "required": ["quality_score", "issues", "reasoning"],
258 "additionalProperties": false
259}"#;
260
261const DEEP_RESEARCH_SYNTH_PROMPT: &str = "You are a knowledge graph synthesizer. Given this memory body, extract key findings and synthesize them into structured entities and relationships.\n\n\
263Entity names: lowercase kebab-case, domain-specific.\n\
264Relations: depends-on, uses, supports, causes, fixes, contradicts, applies-to, follows, related, replaces, tracked-in.\n\n\
265Respond with extracted entities, relationships, and a synthesis summary.";
266
267const DEEP_RESEARCH_SYNTH_SCHEMA: &str = r#"{
268 "type": "object",
269 "properties": {
270 "entities": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "entity_type": { "type": "string" } }, "required": ["name", "entity_type"] } },
271 "relationships": { "type": "array", "items": { "type": "object", "properties": { "source": { "type": "string" }, "target": { "type": "string" }, "relation": { "type": "string" }, "strength": { "type": "number" } }, "required": ["source", "target", "relation", "strength"] } },
272 "summary": { "type": "string" }
273 },
274 "required": ["entities", "relationships", "summary"],
275 "additionalProperties": false
276}"#;
277
278const BODY_EXTRACT_PROMPT: &str = "You are a structured data extractor. Given this memory body (which may be unstructured text, raw notes, or a transcript), extract and restructure the content into a clean, well-organized markdown body.\n\n\
280Preserve all factual content. Remove noise, fix formatting, add section headers where appropriate.\n\
281Respond with the restructured body and a brief summary of changes.";
282
283const BODY_EXTRACT_SCHEMA: &str = r#"{
284 "type": "object",
285 "properties": {
286 "restructured_body": { "type": "string" },
287 "changes_summary": { "type": "string" }
288 },
289 "required": ["restructured_body", "changes_summary"],
290 "additionalProperties": false
291}"#;
292
293const BINDINGS_PROMPT: &str = "You are a knowledge graph entity extractor. Given a memory body, extract:\n\
2981. Domain-specific entities (concepts, tools, people, decisions, projects, files)\n\
2992. Typed relationships between entities with strength scores\n\n\
300Rules:\n\
301- Entity names: lowercase kebab-case, 2+ chars, domain-specific only\n\
302- NEVER extract generic terms, stop words, numbers, UUIDs, or single characters\n\
303- Relationship types MUST be one of: applies-to, uses, depends-on, causes, fixes, contradicts, supports, follows, related, replaces, tracked-in\n\
304- NEVER use 'mentions' as relationship type\n\
305- Strength: 0.9 for hard dependencies, 0.7 for design relationships, 0.5 for contextual links, 0.3 for weak references\n\
306- Prefer fewer high-quality entities over many low-quality ones";
307
308const ENTITY_DESCRIPTION_PROMPT_PREFIX: &str = "You are a knowledge graph annotator. Given an entity name and type, write a concise one-sentence description (10-20 words) that explains what this entity IS and WHY it matters in the context of software/system design.\n\nEntity name: ";
309
310const BODY_ENRICH_PROMPT_PREFIX: &str = "You are a knowledge assistant. Given a short or sparse memory body, expand it into a richer, more complete and useful description. Preserve all existing facts. Add context, implications, and relationships that would be valuable for knowledge retrieval.\n\nConstraints:\n- Output only the enriched body text (no metadata, no headers)\n- Preserve the original meaning exactly\n- Target length is provided in the system context\n\nMemory body to enrich:\n\n";
311
312#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum EnrichOperation {
320 MemoryBindings,
325 AugmentBindings,
330 EntityDescriptions,
332 BodyEnrich,
334 ReEmbed,
336 WeightCalibrate,
338 RelationReclassify,
340 EntityConnect,
342 EntityTypeValidate,
344 DescriptionEnrich,
346 CrossDomainBridges,
348 DomainClassify,
350 GraphAudit,
352 DeepResearchSynth,
354 BodyExtract,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
365#[serde(rename_all = "kebab-case")]
366pub enum ReEmbedTarget {
367 Memories,
369 Entities,
371 Chunks,
373 All,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
379pub enum EnrichMode {
380 ClaudeCode,
382 Codex,
384 #[value(name = "opencode")]
386 Opencode,
387 #[value(name = "openrouter")]
389 OpenRouter,
390}
391
392impl std::fmt::Display for EnrichMode {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 match self {
395 EnrichMode::ClaudeCode => write!(f, "claude-code"),
396 EnrichMode::Codex => write!(f, "codex"),
397 EnrichMode::Opencode => write!(f, "opencode"),
398 EnrichMode::OpenRouter => write!(f, "openrouter"),
399 }
400 }
401}
402
403#[derive(clap::Args)]
405#[command(
406 about = "Enrich graph memories and entities using an LLM provider",
407 after_long_help = "EXAMPLES:\n \
408 # Add missing entity bindings to all unbound memories\n \
409 sqlite-graphrag enrich --operation memory-bindings --mode codex --codex-model gpt-5.4-mini\n\n \
410 # Fill entity descriptions (dry-run preview, no tokens spent)\n \
411 sqlite-graphrag enrich --operation entity-descriptions --dry-run --json\n\n \
412 # Expand short memory bodies (GAP-18)\n \
413 sqlite-graphrag enrich --operation body-enrich --min-output-chars 600\n\n \
414 # Rebuild only missing memory embeddings without rewriting bodies\n \
415 sqlite-graphrag enrich --operation re-embed --limit 100\n\n \
416 # Resume an interrupted body-enrich run\n \
417 sqlite-graphrag enrich --operation body-enrich --resume --json\n\n \
418 # Retry only failed items from a previous run\n \
419 sqlite-graphrag enrich --operation memory-bindings --retry-failed --json\n\n \
420 # Converge the whole backlog (internal scan+drain loop, no bash wrapper)\n \
421 sqlite-graphrag enrich --operation memory-bindings --mode openrouter \\\n \
422 --openrouter-model deepseek/deepseek-v4-flash:nitro --until-empty --max-runtime 600\n\n \
423 # Inspect / resurrect dead-letter items\n \
424 sqlite-graphrag enrich --operation memory-bindings --list-dead\n \
425 sqlite-graphrag enrich --operation memory-bindings --requeue-dead\n\n \
426 # Read-only status (no LLM, no singleton)\n \
427 sqlite-graphrag enrich --operation memory-bindings --status\n\n\
428 OPERATIONS NOTE:\n \
429 memory-bindings LINKS each memory to the EXISTING entities extracted from its\n \
430 body — it does not invent a new graph, it connects what is missing. It scans\n \
431 only UNBOUND memories. To re-run extraction over ALREADY-bound memories and\n \
432 MERGE newly-found entities/relationships additively (without removing links),\n \
433 use --operation augment-bindings with --names/--names-file.\n\n\
434 DEAD-LETTER SIDECAR (.enrich-queue.sqlite):\n \
435 A SQLite sidecar tracks each work item across runs. Schema (table `queue`):\n \
436 item_key (UNIQUE name/id), item_type (memory|entity), operation, memory_id,\n \
437 status (pending|processing|done|skipped|dead), attempt, error, error_class,\n \
438 next_retry_at (backoff cooldown). --until-empty loops scan→drain internally\n \
439 until eligible items are exhausted; transient failures (incl. malformed/non-\n \
440 JSON LLM output, GAP-SG-09) reschedule with backoff until --max-attempts, then\n \
441 land in status='dead'. Use --status to see the queue, --list-dead to inspect\n \
442 the sink, --requeue-dead to retry it, and --ignore-backoff to skip cooldowns.\n \
443 --names/--names-file also remedy a cooldown by targeting a specific subset.\n\n\
444 EXIT CODES:\n \
445 0 success\n \
446 1 validation error (bad args, binary not found)\n \
447 14 I/O error"
448)]
449pub struct EnrichArgs {
450 #[arg(
455 long,
456 short = 'o',
457 value_enum,
458 value_name = "OPERATION",
459 required_unless_present_any = ["status", "list_dead", "requeue_dead", "prune_dead_orphans", "prune_dead_entity_orphans"]
460 )]
461 pub operation: Option<EnrichOperation>,
462
463 #[arg(
467 long,
468 value_enum,
469 required_unless_present_any = ["status", "list_dead", "requeue_dead", "prune_dead_orphans", "prune_dead_entity_orphans"]
470 )]
471 pub mode: Option<EnrichMode>,
472
473 #[arg(long, value_name = "N")]
475 pub limit: Option<usize>,
476
477 #[arg(long, value_enum, value_name = "TARGET", default_value_t = ReEmbedTarget::Memories)]
485 pub target: ReEmbedTarget,
486
487 #[arg(long)]
489 pub dry_run: bool,
490
491 #[arg(long, env = "SQLITE_GRAPHRAG_NAMESPACE")]
493 pub namespace: Option<String>,
494
495 #[arg(long, value_name = "PATH")]
498 pub claude_binary: Option<PathBuf>,
499
500 #[arg(long, value_name = "MODEL")]
502 pub claude_model: Option<String>,
503
504 #[arg(long, value_name = "SECONDS", default_value_t = 300)]
506 pub claude_timeout: u64,
507
508 #[arg(long, value_name = "PATH")]
511 pub codex_binary: Option<PathBuf>,
512
513 #[arg(long, value_name = "MODEL")]
515 pub codex_model: Option<String>,
516
517 #[arg(long, value_name = "SECONDS", default_value_t = 300)]
519 pub codex_timeout: u64,
520
521 #[arg(long, value_name = "PATH", env = "SQLITE_GRAPHRAG_OPENCODE_BINARY")]
524 pub opencode_binary: Option<PathBuf>,
525
526 #[arg(long, value_name = "MODEL", env = "SQLITE_GRAPHRAG_OPENCODE_MODEL")]
528 pub opencode_model: Option<String>,
529
530 #[arg(
532 long,
533 value_name = "SECONDS",
534 env = "SQLITE_GRAPHRAG_OPENCODE_TIMEOUT",
535 default_value_t = 300
536 )]
537 pub opencode_timeout: u64,
538
539 #[arg(long, value_name = "MODEL")]
542 pub openrouter_model: Option<String>,
543
544 #[arg(long, value_name = "KEY", env = "OPENROUTER_API_KEY")]
546 pub openrouter_api_key: Option<String>,
547
548 #[arg(long, value_name = "SECONDS", default_value_t = 600)]
555 pub openrouter_timeout: u64,
556
557 #[arg(long, value_name = "URL")]
559 pub openrouter_base_url: Option<String>,
560
561 #[arg(long, value_name = "USD")]
564 pub max_cost_usd: Option<f64>,
565
566 #[arg(long)]
569 pub resume: bool,
570
571 #[arg(long)]
573 pub retry_failed: bool,
574
575 #[arg(long)]
579 pub until_empty: bool,
580
581 #[arg(long, value_name = "SECONDS")]
584 pub max_runtime: Option<u64>,
585
586 #[arg(long, value_name = "N", default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=20))]
598 pub max_attempts: u32,
599
600 #[arg(long)]
603 pub status: bool,
604
605 #[arg(long)]
610 pub list_dead: bool,
611
612 #[arg(long)]
619 pub requeue_dead: bool,
620
621 #[arg(long)]
629 pub prune_dead_orphans: bool,
630
631 #[arg(long, conflicts_with = "prune_dead_orphans")]
639 pub prune_dead_entity_orphans: bool,
640
641 #[arg(long)]
647 pub ignore_backoff: bool,
648
649 #[arg(long)]
656 pub body_extract_graph_only: bool,
657
658 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=16))]
661 pub rest_concurrency: Option<u32>,
662
663 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
666 pub min_output_chars: usize,
667
668 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
670 pub max_output_chars: usize,
671
672 #[arg(long, default_value_t = true)]
674 pub preserve_check: bool,
675
676 #[arg(long, value_name = "PATH")]
678 pub prompt_template: Option<PathBuf>,
679
680 #[arg(long, default_value_t = 1, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=32))]
684 pub llm_parallelism: u32,
685
686 #[arg(long)]
689 pub json: bool,
690
691 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
693 pub db: Option<String>,
694
695 #[arg(long, value_name = "SECONDS")]
698 pub wait_job_singleton: Option<u64>,
699
700 #[arg(long, default_value_t = false)]
704 pub force_job_singleton: bool,
705
706 #[arg(long, value_name = "NAMES", value_delimiter = ',')]
716 pub names: Vec<String>,
717
718 #[arg(long, value_name = "PATH")]
722 pub names_file: Option<PathBuf>,
723
724 #[arg(long, default_value_t = false)]
728 pub preflight_check: bool,
729
730 #[arg(long, value_enum)]
734 pub fallback_mode: Option<EnrichMode>,
735
736 #[arg(long, value_name = "SECONDS", default_value_t = 300)]
739 pub rate_limit_buffer: u64,
740
741 #[arg(long, default_value_t = true)]
745 pub max_load_check: bool,
746
747 #[arg(long, value_name = "N", default_value_t = 5)]
750 pub circuit_breaker_threshold: u32,
751
752 #[arg(long, value_name = "FLOAT", default_value_t = 0.7)]
759 pub preserve_threshold: f64,
760
761 #[arg(long, default_value_t = true)]
766 pub codex_model_validate: bool,
767
768 #[arg(long, value_name = "MODEL")]
773 pub codex_model_fallback: Option<String>,
774}
775
776impl EnrichArgs {
777 fn operation(&self) -> EnrichOperation {
785 self.operation
786 .clone()
787 .unwrap_or(EnrichOperation::MemoryBindings)
788 }
789
790 fn mode(&self) -> EnrichMode {
795 self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
796 }
797}
798
799#[derive(Debug, Serialize)]
808struct PhaseEvent<'a> {
809 phase: &'a str,
810 #[serde(skip_serializing_if = "Option::is_none")]
811 binary_path: Option<&'a str>,
812 #[serde(skip_serializing_if = "Option::is_none")]
813 version: Option<&'a str>,
814 #[serde(skip_serializing_if = "Option::is_none")]
815 items_total: Option<usize>,
816 #[serde(skip_serializing_if = "Option::is_none")]
817 items_pending: Option<usize>,
818 #[serde(skip_serializing_if = "Option::is_none")]
820 llm_parallelism: Option<u32>,
821}
822
823#[derive(Debug, Serialize)]
828struct ConcurrencyEvent {
829 phase: &'static str,
830 scan_parallelism: u32,
831 drain_parallelism: u32,
832}
833
834#[derive(Debug, Serialize)]
835struct ItemEvent<'a> {
836 item: &'a str,
838 status: &'a str,
839 #[serde(skip_serializing_if = "Option::is_none")]
840 memory_id: Option<i64>,
841 #[serde(skip_serializing_if = "Option::is_none")]
842 entity_id: Option<i64>,
843 #[serde(skip_serializing_if = "Option::is_none")]
844 entities: Option<usize>,
845 #[serde(skip_serializing_if = "Option::is_none")]
846 rels: Option<usize>,
847 #[serde(skip_serializing_if = "Option::is_none")]
848 chars_before: Option<usize>,
849 #[serde(skip_serializing_if = "Option::is_none")]
850 chars_after: Option<usize>,
851 #[serde(skip_serializing_if = "Option::is_none")]
852 cost_usd: Option<f64>,
853 #[serde(skip_serializing_if = "Option::is_none")]
854 elapsed_ms: Option<u64>,
855 #[serde(skip_serializing_if = "Option::is_none")]
856 error: Option<String>,
857 index: usize,
858 total: usize,
859}
860
861#[derive(Debug, Serialize)]
862struct EnrichSummary {
863 summary: bool,
864 operation: String,
865 items_total: usize,
866 completed: usize,
867 failed: usize,
868 skipped: usize,
869 cost_usd: f64,
870 elapsed_ms: u64,
871 #[serde(skip_serializing_if = "Option::is_none")]
876 backend_invoked: Option<&'static str>,
877 waiting: i64,
881 dead: i64,
884}
885
886use crate::output::emit_json_line as emit_json;
887
888enum PreflightOutcome {
902 Healthy,
904 RateLimited {
908 reason: String,
909 suggestion: &'static str,
910 },
911 Error(AppError),
913}
914
915fn run_preflight_probe(args: &EnrichArgs) -> PreflightOutcome {
923 let timeout = std::time::Duration::from_secs(args.rate_limit_buffer.max(60));
924
925 match args.mode() {
926 EnrichMode::ClaudeCode => {
927 let bin = match find_claude_binary(args.claude_binary.as_deref()) {
928 Ok(b) => b,
929 Err(e) => return PreflightOutcome::Error(e),
930 };
931 let mcp_config_path = match crate::spawn::preflight::write_empty_mcp_config_tempfile() {
936 Ok(p) => p,
937 Err(e) => {
938 return PreflightOutcome::Error(AppError::Io(e));
939 }
940 };
941 let mut cmd = std::process::Command::new(&bin);
942 crate::spawn::env_whitelist::apply_env_whitelist(
943 &mut cmd,
944 crate::spawn::env_whitelist::is_strict_env_clear(),
945 );
946 if let Err(e) = crate::spawn::apply_cwd_isolation(&mut cmd) {
947 return PreflightOutcome::Error(e);
948 }
949 cmd.arg("-p")
950 .arg("ping")
951 .arg("--max-turns")
952 .arg("1")
953 .arg("--strict-mcp-config")
954 .arg("--mcp-config")
955 .arg(mcp_config_path.as_os_str())
956 .arg("--dangerously-skip-permissions")
957 .arg("--settings")
958 .arg("{\"hooks\":{}}")
959 .arg("--output-format")
960 .arg("json")
961 .stdin(std::process::Stdio::null())
962 .stdout(std::process::Stdio::piped())
963 .stderr(std::process::Stdio::piped());
964
965 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
966 Ok(c) => c,
967 Err(e) => {
968 return PreflightOutcome::Error(AppError::Io(e));
969 }
970 };
971 let output = match wait_with_timeout(child, timeout) {
972 Ok(out) => out,
973 Err(e) => return PreflightOutcome::Error(e),
974 };
975 if !output.status.success() {
976 let stderr = String::from_utf8_lossy(&output.stderr);
977 if stderr.contains("hit your session limit")
978 || stderr.contains("rate_limit")
979 || stderr.contains("429")
980 {
981 return PreflightOutcome::RateLimited {
982 reason: stderr.trim().to_string(),
983 suggestion:
984 "wait for the OAuth window to reset or use --fallback-mode codex",
985 };
986 }
987 return PreflightOutcome::Error(AppError::Validation(format!(
988 "preflight probe failed: {stderr}",
989 stderr = stderr.trim()
990 )));
991 }
992 PreflightOutcome::Healthy
993 }
994 EnrichMode::Codex => {
995 let bin = match find_codex_binary(args.codex_binary.as_deref()) {
996 Ok(b) => b,
997 Err(e) => return PreflightOutcome::Error(e),
998 };
999 super::codex_spawn::validate_codex_model(args.codex_model.as_deref())
1000 .map_err(PreflightOutcome::Error)
1001 .ok();
1002 let schema = "{}";
1003 let schema_path = match super::codex_spawn::trusted_schema_path() {
1004 Ok(p) => p,
1005 Err(e) => return PreflightOutcome::Error(e),
1006 };
1007 let spawn_args = super::codex_spawn::CodexSpawnArgs {
1008 binary: &bin,
1009 prompt: "ping",
1010 json_schema: schema,
1011 input_text: "",
1012 model: args.codex_model.as_deref(),
1013 timeout_secs: args.rate_limit_buffer.max(60),
1014 schema_path: schema_path.clone(),
1015 };
1016 let mut cmd = match super::codex_spawn::build_codex_command(&spawn_args) {
1017 Ok(c) => c,
1018 Err(e) => return PreflightOutcome::Error(e),
1019 };
1020 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
1021 Ok(c) => c,
1022 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1023 };
1024 let output = match wait_with_timeout(child, timeout) {
1025 Ok(out) => out,
1026 Err(e) => return PreflightOutcome::Error(e),
1027 };
1028 let _ = std::fs::remove_file(&schema_path);
1029 if !output.status.success() {
1030 let stderr = String::from_utf8_lossy(&output.stderr);
1031 if stderr.contains("rate_limit")
1032 || stderr.contains("429")
1033 || stderr.contains("Too Many Requests")
1034 {
1035 return PreflightOutcome::RateLimited {
1036 reason: stderr.trim().to_string(),
1037 suggestion: "wait for the rate-limit window to reset",
1038 };
1039 }
1040 return PreflightOutcome::Error(AppError::Validation(format!(
1041 "preflight probe failed: {stderr}",
1042 stderr = stderr.trim()
1043 )));
1044 }
1045 PreflightOutcome::Healthy
1046 }
1047 EnrichMode::Opencode => {
1048 let bin = match super::opencode_runner::find_opencode_binary_with_override(
1049 args.opencode_binary.as_deref(),
1050 ) {
1051 Ok(b) => b,
1052 Err(e) => return PreflightOutcome::Error(e),
1053 };
1054 let model =
1055 super::opencode_runner::resolve_opencode_model(args.opencode_model.as_deref());
1056 let mut cmd =
1057 match super::opencode_runner::build_opencode_command_sync(&bin, &model, "ping", "")
1058 {
1059 Ok(c) => c,
1060 Err(e) => return PreflightOutcome::Error(e),
1061 };
1062 let child = match super::opencode_runner::spawn_opencode(&mut cmd) {
1063 Ok(c) => c,
1064 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1065 };
1066 let output = match wait_with_timeout(child, timeout) {
1067 Ok(out) => out,
1068 Err(e) => return PreflightOutcome::Error(e),
1069 };
1070 if !output.status.success() {
1071 let stderr = String::from_utf8_lossy(&output.stderr);
1072 if stderr.contains("rate_limit")
1073 || stderr.contains("429")
1074 || stderr.contains("Too Many Requests")
1075 {
1076 return PreflightOutcome::RateLimited {
1077 reason: stderr.trim().to_string(),
1078 suggestion: "wait for the rate-limit window to reset",
1079 };
1080 }
1081 return PreflightOutcome::Error(AppError::Validation(format!(
1082 "preflight probe failed: {stderr}",
1083 stderr = stderr.trim()
1084 )));
1085 }
1086 PreflightOutcome::Healthy
1087 }
1088 EnrichMode::OpenRouter => {
1089 match crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref()) {
1093 Some(_) => PreflightOutcome::Healthy,
1094 None => PreflightOutcome::Error(AppError::Validation(
1095 "OPENROUTER_API_KEY not found for --mode openrouter preflight".into(),
1096 )),
1097 }
1098 }
1099 }
1100}
1101
1102fn wait_with_timeout(
1104 mut child: std::process::Child,
1105 timeout: std::time::Duration,
1106) -> Result<std::process::Output, AppError> {
1107 use wait_timeout::ChildExt;
1108 let start = std::time::Instant::now();
1109 let Some(exit) = child.wait_timeout(timeout).map_err(AppError::Io)? else {
1110 let _ = child.kill();
1111 let _ = child.wait();
1112 return Err(AppError::Validation(format!(
1113 "preflight probe timed out after {}s",
1114 start.elapsed().as_secs()
1115 )));
1116 };
1117 let mut stdout = Vec::new();
1118 if let Some(mut out) = child.stdout.take() {
1119 std::io::Read::read_to_end(&mut out, &mut stdout).map_err(AppError::Io)?;
1120 }
1121 let mut stderr = Vec::new();
1122 if let Some(mut err) = child.stderr.take() {
1123 std::io::Read::read_to_end(&mut err, &mut stderr).map_err(AppError::Io)?;
1124 }
1125 Ok(std::process::Output {
1126 status: exit,
1127 stdout,
1128 stderr,
1129 })
1130}
1131
1132fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1148 value == default
1149}
1150
1151fn validate_mode_conditional_flags_enrich(args: &EnrichArgs) -> Result<(), AppError> {
1166 const DEFAULT_TIMEOUT: u64 = 300;
1167
1168 let mut conflicts: Vec<String> = Vec::new();
1169
1170 match args.mode() {
1171 EnrichMode::ClaudeCode => {
1172 if args.codex_binary.is_some() {
1173 conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1174 }
1175 if args.codex_model.is_some() {
1176 conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1177 }
1178 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1179 conflicts.push(format!(
1180 "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1181 args.codex_timeout
1182 ));
1183 }
1184 }
1185 EnrichMode::Codex => {
1186 if args.claude_binary.is_some() {
1187 conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1188 }
1189 if args.claude_model.is_some() {
1190 conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1191 }
1192 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1193 conflicts.push(format!(
1194 "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1195 args.claude_timeout
1196 ));
1197 }
1198 if args.max_cost_usd.is_some() {
1199 conflicts.push(
1200 "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription, not the call)"
1201 .to_string(),
1202 );
1203 }
1204 }
1205 EnrichMode::Opencode => {
1206 if args.claude_binary.is_some() {
1207 conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1208 }
1209 if args.claude_model.is_some() {
1210 conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1211 }
1212 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1213 conflicts.push(format!(
1214 "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1215 args.claude_timeout
1216 ));
1217 }
1218 if args.max_cost_usd.is_some() {
1219 conflicts.push(
1220 "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription, not the call)"
1221 .to_string(),
1222 );
1223 }
1224 }
1225 EnrichMode::OpenRouter => {
1226 if args.claude_binary.is_some() {
1227 conflicts.push("--claude-binary is ignored when --mode=openrouter".to_string());
1228 }
1229 if args.claude_model.is_some() {
1230 conflicts.push("--claude-model is ignored when --mode=openrouter".to_string());
1231 }
1232 if args.codex_binary.is_some() {
1233 conflicts.push("--codex-binary is ignored when --mode=openrouter".to_string());
1234 }
1235 if args.codex_model.is_some() {
1236 conflicts.push("--codex-model is ignored when --mode=openrouter".to_string());
1237 }
1238 if args.opencode_binary.is_some() {
1239 conflicts.push("--opencode-binary is ignored when --mode=openrouter".to_string());
1240 }
1241 if args.opencode_model.is_some() {
1242 conflicts.push("--opencode-model is ignored when --mode=openrouter".to_string());
1243 }
1244 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1245 conflicts.push(format!(
1246 "--claude-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1247 args.claude_timeout
1248 ));
1249 }
1250 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1251 conflicts.push(format!(
1252 "--codex-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1253 args.codex_timeout
1254 ));
1255 }
1256 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1257 conflicts.push(format!(
1258 "--opencode-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1259 args.opencode_timeout
1260 ));
1261 }
1262 }
1263 }
1264
1265 if !conflicts.is_empty() {
1266 return Err(AppError::Validation(format!(
1267 "G20: mode-conditional flag conflicts detected for --mode={}:\n - {}",
1268 args.mode(),
1269 conflicts.join("\n - ")
1270 )));
1271 }
1272
1273 Ok(())
1274}
1275
1276pub fn run(
1280 args: &EnrichArgs,
1281 llm_backend: crate::cli::LlmBackendChoice,
1282 embedding_backend: crate::cli::EmbeddingBackendChoice,
1283) -> Result<(), AppError> {
1284 validate_mode_conditional_flags_enrich(args)?;
1287
1288 if args.target != ReEmbedTarget::Memories
1291 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1292 {
1293 let target_label = match args.target {
1294 ReEmbedTarget::Memories => "memories",
1295 ReEmbedTarget::Entities => "entities",
1296 ReEmbedTarget::Chunks => "chunks",
1297 ReEmbedTarget::All => "all",
1298 };
1299 return Err(AppError::Validation(format!(
1300 "--target {target_label} only applies to --operation re-embed"
1301 )));
1302 }
1303
1304 if args.list_dead
1313 || args.requeue_dead
1314 || args.prune_dead_orphans
1315 || args.prune_dead_entity_orphans
1316 {
1317 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1318 let op_label = format!("{:?}", args.operation());
1319 let paths = AppPaths::resolve(args.db.as_deref())?;
1320 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1321 let queue_conn = open_queue_db(&queue_path)?;
1322 if args.prune_dead_entity_orphans {
1327 let pruned = prune_dead_entity_orphans(&queue_conn, &op_label)?;
1328 let dead_total: i64 = queue_conn
1329 .query_row(
1330 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1331 AND item_type='entity' \
1332 AND (operation = ?1 OR operation IS NULL)",
1333 rusqlite::params![op_label],
1334 |r| r.get(0),
1335 )
1336 .unwrap_or(0);
1337 emit_json(&DeadSummary {
1338 summary: true,
1339 operation: op_label,
1340 namespace,
1341 action: "prune-dead-entity-orphans",
1342 dead_total,
1343 requeued: 0,
1344 pruned,
1345 });
1346 return Ok(());
1347 }
1348 if args.prune_dead_orphans {
1351 ensure_db_ready(&paths)?;
1352 let main_conn = open_rw(&paths.db)?;
1353 let pruned = prune_dead_orphans(&queue_conn, &main_conn, &op_label, &namespace)?;
1354 let dead_total: i64 = queue_conn
1355 .query_row(
1356 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1357 AND (operation = ?1 OR operation IS NULL)",
1358 rusqlite::params![op_label],
1359 |r| r.get(0),
1360 )
1361 .unwrap_or(0);
1362 emit_json(&DeadSummary {
1363 summary: true,
1364 operation: op_label,
1365 namespace,
1366 action: "prune-dead-orphans",
1367 dead_total,
1368 requeued: 0,
1369 pruned,
1370 });
1371 return Ok(());
1372 }
1373 if args.list_dead {
1374 let mut stmt = queue_conn.prepare(
1375 "SELECT item_key, item_type, attempt, error_class, error, \
1376 finish_reason, input_tokens, output_tokens FROM queue \
1377 WHERE status='dead' AND (operation = ?1 OR operation IS NULL) ORDER BY id",
1378 )?;
1379 let rows = stmt
1380 .query_map(rusqlite::params![op_label], |r| {
1381 Ok(DeadItem {
1382 dead_item: true,
1383 item_key: r.get(0)?,
1384 item_type: r.get(1)?,
1385 attempt: r.get(2)?,
1386 error_class: r.get(3)?,
1387 error: r.get(4)?,
1388 finish_reason: r.get(5)?,
1389 input_tokens: r.get(6)?,
1390 output_tokens: r.get(7)?,
1391 })
1392 })?
1393 .collect::<Result<Vec<_>, _>>()?;
1394 let dead_total = rows.len() as i64;
1395 for item in &rows {
1396 emit_json(item);
1397 }
1398 emit_json(&DeadSummary {
1399 summary: true,
1400 operation: op_label,
1401 namespace,
1402 action: "list-dead",
1403 dead_total,
1404 requeued: 0,
1405 pruned: 0,
1406 });
1407 return Ok(());
1408 }
1409 let dead_total: i64 = queue_conn
1411 .query_row(
1412 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1413 AND (operation = ?1 OR operation IS NULL)",
1414 rusqlite::params![op_label],
1415 |r| r.get(0),
1416 )
1417 .unwrap_or(0);
1418 let requeued = queue_conn
1419 .execute(
1420 "UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
1421 error=NULL, error_class=NULL \
1422 WHERE status='dead' AND (operation = ?1 OR operation IS NULL)",
1423 rusqlite::params![op_label],
1424 )
1425 .map_err(|e| AppError::Validation(format!("requeue-dead failed: {e}")))?
1426 as i64;
1427 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1428 emit_json(&DeadSummary {
1429 summary: true,
1430 operation: op_label,
1431 namespace,
1432 action: "requeue-dead",
1433 dead_total,
1434 requeued,
1435 pruned: 0,
1436 });
1437 return Ok(());
1438 }
1439
1440 if args.status {
1441 let paths = AppPaths::resolve(args.db.as_deref())?;
1442 ensure_db_ready(&paths)?;
1443 let conn = open_rw(&paths.db)?;
1444 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1445 let unbound_backlog = scan_unbound_memories(&conn, &namespace, None, &[])?.len();
1446 let scan_backlog =
1449 count_operation_backlog(&conn, &args.operation(), &namespace, args.target)?;
1450 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1451 let queue_conn = open_queue_db(&queue_path)?;
1452 let op_label = format!("{:?}", args.operation());
1453 let count_status = |st: &str, op: &str| -> i64 {
1457 queue_conn
1458 .query_row(
1459 "SELECT COUNT(*) FROM queue WHERE status=?1 \
1460 AND (operation = ?2 OR operation IS NULL)",
1461 rusqlite::params![st, op],
1462 |r| r.get(0),
1463 )
1464 .unwrap_or(0)
1465 };
1466 let eligible_now: i64 = queue_conn
1467 .query_row(
1468 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1469 AND (operation = ?1 OR operation IS NULL) \
1470 AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))",
1471 rusqlite::params![op_label],
1472 |r| r.get(0),
1473 )
1474 .unwrap_or(0);
1475 let waiting: i64 = queue_conn
1476 .query_row(
1477 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1478 AND (operation = ?1 OR operation IS NULL) \
1479 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
1480 rusqlite::params![op_label],
1481 |r| r.get(0),
1482 )
1483 .unwrap_or(0);
1484 let waiting_items = {
1486 let mut stmt = queue_conn.prepare(
1487 "SELECT item_key, attempt, next_retry_at, error_class FROM queue \
1488 WHERE status='pending' AND (operation = ?1 OR operation IS NULL) \
1489 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now') \
1490 ORDER BY next_retry_at",
1491 )?;
1492 let items: Vec<WaitingItem> = stmt
1493 .query_map(rusqlite::params![op_label], |r| {
1494 Ok(WaitingItem {
1495 item_key: r.get(0)?,
1496 attempt: r.get(1)?,
1497 next_retry_at: r.get(2)?,
1498 error_class: r.get(3)?,
1499 })
1500 })?
1501 .collect::<Result<Vec<_>, _>>()?;
1502 items
1503 };
1504 let queue_pending = count_status("pending", &op_label);
1505 let queue_processing = count_status("processing", &op_label);
1506 let queue_done = count_status("done", &op_label);
1507 let queue_failed = count_status("failed", &op_label);
1508 let queue_skipped = count_status("skipped", &op_label);
1509 let queue_dead = count_status("dead", &op_label);
1510 let state = if eligible_now > 0 {
1512 "draining"
1513 } else if waiting > 0 {
1514 "cooldown"
1515 } else if queue_pending == 0 && scan_backlog > 0 {
1516 "pending-scan"
1517 } else {
1518 "empty"
1519 };
1520 emit_json(&EnrichStatus {
1521 status_report: true,
1522 operation: op_label,
1523 namespace,
1524 unbound_backlog,
1525 scan_backlog,
1526 queue_pending,
1527 queue_processing,
1528 queue_done,
1529 queue_failed,
1530 queue_skipped,
1531 queue_dead,
1532 eligible_now,
1533 waiting,
1534 state,
1535 waiting_items,
1536 });
1537 return Ok(());
1538 }
1539
1540 if args.mode() == EnrichMode::OpenRouter {
1545 let model = args.openrouter_model.as_deref().ok_or_else(|| {
1546 AppError::Validation(
1547 "--mode openrouter requires --openrouter-model (no default model is allowed)"
1548 .into(),
1549 )
1550 })?;
1551 let resolved =
1552 crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref())
1553 .ok_or_else(|| {
1554 AppError::Validation(
1555 "OPENROUTER_API_KEY not found; set the env var, store it via \
1556 `config add-key --provider openrouter`, or pass --openrouter-api-key"
1557 .into(),
1558 )
1559 })?;
1560 crate::embedder::get_openrouter_chat_client(
1561 resolved.value,
1562 model,
1563 args.openrouter_timeout,
1564 )?;
1565 }
1566
1567 let started = Instant::now();
1568
1569 let paths = AppPaths::resolve(args.db.as_deref())?;
1570 ensure_db_ready(&paths)?;
1571 let conn = open_rw(&paths.db)?;
1572 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1573
1574 let wait_secs = args.wait_job_singleton;
1580 let force_flag = args.force_job_singleton;
1581 let _singleton = crate::lock::acquire_job_singleton(
1582 crate::lock::JobType::Enrich,
1583 &namespace,
1584 &paths.db,
1585 wait_secs,
1586 force_flag,
1587 )?;
1588
1589 let provider_binary = if matches!(args.operation(), EnrichOperation::ReEmbed) {
1591 None
1592 } else {
1593 Some(match args.mode() {
1594 EnrichMode::ClaudeCode => {
1595 let bin = find_claude_binary(args.claude_binary.as_deref())?;
1596 let version = super::claude_runner::validate_claude_version(&bin)?;
1597 tracing::info!(target: "enrich", binary = %bin.display(), version = %version, "Claude Code binary validated");
1598 emit_json(&PhaseEvent {
1599 phase: "validate",
1600 binary_path: bin.to_str(),
1601 version: Some(&version),
1602 items_total: None,
1603 items_pending: None,
1604 llm_parallelism: None,
1605 });
1606 bin
1607 }
1608 EnrichMode::Codex => {
1609 let bin = find_codex_binary(args.codex_binary.as_deref())?;
1610 emit_json(&PhaseEvent {
1611 phase: "validate",
1612 binary_path: bin.to_str(),
1613 version: None,
1614 items_total: None,
1615 items_pending: None,
1616 llm_parallelism: None,
1617 });
1618 bin
1619 }
1620 EnrichMode::Opencode => {
1621 let bin = super::opencode_runner::find_opencode_binary_with_override(
1622 args.opencode_binary.as_deref(),
1623 )?;
1624 emit_json(&PhaseEvent {
1625 phase: "validate",
1626 binary_path: bin.to_str(),
1627 version: None,
1628 items_total: None,
1629 items_pending: None,
1630 llm_parallelism: None,
1631 });
1632 bin
1633 }
1634 EnrichMode::OpenRouter => {
1635 emit_json(&PhaseEvent {
1640 phase: "validate",
1641 binary_path: None,
1642 version: None,
1643 items_total: None,
1644 items_pending: None,
1645 llm_parallelism: None,
1646 });
1647 PathBuf::new()
1648 }
1649 })
1650 };
1651
1652 if args.max_load_check && !args.dry_run && crate::system_load::is_system_saturated() {
1656 let load = crate::system_load::load_average_one();
1657 let n = crate::system_load::ncpus();
1658 return Err(AppError::Validation(format!(
1659 "system load average {load:.2} exceeds 2x ncpus ({n}); \
1660 pass --no-max-load-check to override (not recommended)"
1661 )));
1662 }
1663
1664 if args.preflight_check
1671 && !args.dry_run
1672 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1673 {
1674 let preflight_result = run_preflight_probe(args);
1675 match preflight_result {
1676 PreflightOutcome::Healthy => {
1677 tracing::info!(target: "enrich", mode = ?args.mode(), "preflight probe healthy");
1678 }
1679 PreflightOutcome::RateLimited { reason, suggestion } => {
1680 if let Some(fallback) = args.fallback_mode.clone() {
1681 if fallback != args.mode() {
1682 return Err(AppError::Validation(format!(
1692 "preflight detected rate limit on {mode:?}: {reason}; \
1693 re-invoke with `--mode {fallback:?}` to use the fallback provider",
1694 mode = args.mode()
1695 )));
1696 }
1697 return Err(AppError::Validation(format!(
1698 "preflight detected rate limit on {mode:?}: {reason}; \
1699 --fallback-mode matches --mode, no recovery possible",
1700 mode = args.mode()
1701 )));
1702 }
1703 return Err(AppError::Validation(format!(
1704 "preflight detected rate limit on {mode:?}: {reason}; \
1705 {suggestion}; pass --fallback-mode codex to recover",
1706 mode = args.mode()
1707 )));
1708 }
1709 PreflightOutcome::Error(e) => {
1710 return Err(e);
1711 }
1712 }
1713 }
1714
1715 let mut scan_result = scan_operation(&conn, &namespace, args)?;
1717 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
1726 let q_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1727 if let Ok(q) = open_queue_db(&q_path) {
1728 if let Ok(vetoed) = skipped_item_keys(&q, &format!("{:?}", args.operation())) {
1729 scan_result.retain(|k| !vetoed.contains(k));
1730 }
1731 }
1732 }
1733 let total = scan_result.len();
1734
1735 emit_json(&PhaseEvent {
1736 phase: "scan",
1737 binary_path: None,
1738 version: None,
1739 items_total: Some(total),
1740 items_pending: Some(total),
1741 llm_parallelism: Some(args.llm_parallelism),
1742 });
1743
1744 if args.dry_run {
1746 for (idx, key) in scan_result.iter().enumerate() {
1747 emit_json(&ItemEvent {
1748 item: key,
1749 status: "preview",
1750 memory_id: None,
1751 entity_id: None,
1752 entities: None,
1753 rels: None,
1754 chars_before: None,
1755 chars_after: None,
1756 cost_usd: None,
1757 elapsed_ms: None,
1758 error: None,
1759 index: idx,
1760 total,
1761 });
1762 }
1763 emit_json(&EnrichSummary {
1764 summary: true,
1765 operation: format!("{:?}", args.operation()),
1766 items_total: total,
1767 completed: 0,
1768 failed: 0,
1769 skipped: 0,
1770 cost_usd: 0.0,
1771 elapsed_ms: started.elapsed().as_millis() as u64,
1772 backend_invoked: take_enrich_backend(),
1773 waiting: 0,
1774 dead: 0,
1775 });
1776 return Ok(());
1777 }
1778
1779 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1783 let queue_conn = open_queue_db(&queue_path)?;
1784
1785 if args.resume {
1786 let reset = queue_conn
1787 .execute(
1788 "UPDATE queue SET status='pending' WHERE status='processing'",
1789 [],
1790 )
1791 .map_err(|e| AppError::Validation(format!("queue resume failed: {e}")))?;
1792 if reset > 0 {
1793 tracing::info!(target: "enrich", count = reset, "reset stuck processing items to pending");
1794 }
1795 }
1796
1797 if args.retry_failed {
1798 let count = queue_conn
1799 .execute(
1800 "UPDATE queue SET status='pending', attempt=0 WHERE status='failed'",
1801 [],
1802 )
1803 .map_err(|e| AppError::Validation(format!("queue retry-failed reset failed: {e}")))?;
1804 tracing::info!(target: "enrich", count, "retrying failed items");
1805 }
1806
1807 if !args.resume && !args.retry_failed && !args.until_empty {
1808 queue_conn
1809 .execute("DELETE FROM queue", [])
1810 .map_err(|e| AppError::Validation(format!("queue clear failed: {e}")))?;
1811 }
1812
1813 let op_label = format!("{:?}", args.operation());
1815 let item_type = item_type_for(&args.operation());
1816 for key in scan_result.iter() {
1817 let it = item_type_for_key(key, item_type);
1821 enqueue_candidate(&queue_conn, &conn, &namespace, key, it, &op_label);
1822 }
1823
1824 let parallelism = if args.mode() == EnrichMode::OpenRouter {
1827 let rest = args.rest_concurrency.unwrap_or(8).clamp(1, 16) as usize;
1828 tracing::info!(
1829 target: "enrich",
1830 concurrency = rest,
1831 source = "rest_concurrency",
1832 "OpenRouter REST concurrency (clamp 1..=16)"
1833 );
1834 rest
1835 } else {
1836 let p = args.llm_parallelism.clamp(1, 32) as usize;
1837 tracing::info!(
1838 target: "enrich",
1839 concurrency = p,
1840 source = "llm_parallelism",
1841 "LLM subprocess parallelism (clamp 1..=32)"
1842 );
1843 p
1844 };
1845 if parallelism > 1 {
1846 tracing::info!(
1847 target: "enrich",
1848 llm_parallelism = parallelism,
1849 "parallel LLM processing with bounded thread pool"
1850 );
1851 }
1852 if parallelism > 4 {
1856 match args.mode() {
1857 EnrichMode::ClaudeCode => {
1858 tracing::warn!(
1859 target: "enrich",
1860 llm_parallelism = parallelism,
1861 recommended_max = 4,
1862 mode = "claude-code",
1863 "llm_parallelism above 4 multiplies Claude Code subprocess fan-out; \
1864 consider combining with SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR \
1865 to cut MCP children (G28-A)"
1866 );
1867 }
1868 EnrichMode::Codex if parallelism > 16 => {
1869 tracing::warn!(
1870 target: "enrich",
1871 llm_parallelism = parallelism,
1872 recommended_max = 16,
1873 mode = "codex",
1874 "llm_parallelism above 16 risks OAuth rate-limit on Codex; \
1875 consider --llm-parallelism 8 for safer concurrency"
1876 );
1877 }
1878 EnrichMode::Codex => {
1879 }
1883 EnrichMode::Opencode if parallelism > 16 => {
1884 tracing::warn!(
1885 target: "enrich",
1886 llm_parallelism = parallelism,
1887 recommended_max = 16,
1888 mode = "opencode",
1889 "llm_parallelism above 16 risks OAuth rate-limit on OpenCode; \
1890 consider --llm-parallelism 8 for safer concurrency"
1891 );
1892 }
1893 EnrichMode::Opencode => {
1894 }
1896 EnrichMode::OpenRouter => {
1897 }
1900 }
1901 }
1902
1903 let mut completed = 0usize;
1904 let mut failed = 0usize;
1905 let mut skipped = 0usize;
1906 let mut cost_total = 0.0f64;
1907 let mut oauth_detected = false;
1908 let mut backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
1909 let rate_limit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
1910 let enrich_started = std::time::Instant::now();
1911
1912 let provider_timeout = match args.mode() {
1913 EnrichMode::ClaudeCode => args.claude_timeout,
1914 EnrichMode::Codex => args.codex_timeout,
1915 EnrichMode::Opencode => args.opencode_timeout,
1916 EnrichMode::OpenRouter => args.openrouter_timeout,
1917 };
1918
1919 let provider_model: Option<&str> = match args.mode() {
1920 EnrichMode::ClaudeCode => args.claude_model.as_deref(),
1921 EnrichMode::Codex => args.codex_model.as_deref(),
1922 EnrichMode::Opencode => args.opencode_model.as_deref(),
1923 EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
1924 };
1925
1926 let backoff_clause: &str = if args.ignore_backoff {
1930 ""
1931 } else {
1932 "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
1933 };
1934
1935 emit_json(&ConcurrencyEvent {
1938 phase: "concurrency",
1939 scan_parallelism: 1,
1940 drain_parallelism: parallelism as u32,
1941 });
1942
1943 let until_deadline = std::time::Instant::now()
1947 + std::time::Duration::from_secs(args.max_runtime.unwrap_or(3600));
1948 loop {
1949 if args.until_empty {
1950 let mut rescan = scan_operation(&conn, &namespace, args)?;
1954 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
1959 if let Ok(vetoed) = skipped_item_keys(&queue_conn, &op_label) {
1960 rescan.retain(|k| !vetoed.contains(k));
1961 }
1962 }
1963 for key in &rescan {
1964 let it = item_type_for_key(key, item_type);
1965 enqueue_candidate(&queue_conn, &conn, &namespace, key, it, &op_label);
1966 }
1967 }
1968 let completed_before = completed;
1969
1970 if parallelism > 1 {
1974 let stdout_mu = parking_lot::Mutex::new(());
1975 let budget = args.max_cost_usd;
1976 let operation = args.operation().clone();
1977 let mode = args.mode().clone();
1978 let min_oc = args.min_output_chars;
1979 let max_oc = args.max_output_chars;
1980 let prompt_tpl = args.prompt_template.as_deref().map(|p| p.to_path_buf());
1981
1982 struct WorkerResult {
1983 completed: usize,
1984 failed: usize,
1985 skipped: usize,
1986 cost: f64,
1987 oauth: bool,
1988 db_busy: bool,
1993 }
1994
1995 let results: Vec<WorkerResult> = std::thread::scope(|s| {
1996 let handles: Vec<_> = (0..parallelism)
1997 .map(|worker_id| {
1998 let stdout_mu = &stdout_mu;
1999 let paths = &paths;
2000 let queue_path = &queue_path;
2001 let namespace = &namespace;
2002 let provider_binary = provider_binary.as_deref();
2003 let operation = &operation;
2004 let mode = &mode;
2005 let prompt_tpl = prompt_tpl.as_deref();
2006 s.spawn(move || {
2007 let w_conn = match open_rw(&paths.db) {
2008 Ok(c) => c,
2009 Err(e) => {
2010 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open DB");
2011 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2012 }
2013 };
2014 let w_queue = match open_queue_db(queue_path) {
2015 Ok(c) => c,
2016 Err(e) => {
2017 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open queue DB");
2018 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2019 }
2020 };
2021 let mut w_completed = 0usize;
2022 let mut w_failed = 0usize;
2023 let mut w_skipped = 0usize;
2024 let mut w_cost = 0.0f64;
2025 let mut w_oauth = false;
2026 let mut w_db_busy = false;
2027 let mut w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2028 let w_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
2029 let mut w_breaker = crate::retry::CircuitBreaker::new(
2035 args.circuit_breaker_threshold.max(1),
2036 std::time::Duration::from_secs(60),
2037 );
2038
2039 loop {
2040 if crate::shutdown_requested() {
2041 tracing::info!(target: "enrich", "shutdown requested, worker stopping");
2042 break;
2043 }
2044 if let Some(b) = budget {
2045 if !w_oauth && w_cost >= b {
2046 break;
2047 }
2048 }
2049 let pending = match crate::storage::utils::with_busy_retry(|| {
2068 dequeue_next_pending(&w_queue, backoff_clause)
2069 }) {
2070 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2071 Ok(DequeueOutcome::Empty) => None,
2072 Err(AppError::DbBusy(msg)) => {
2073 tracing::error!(target: "enrich", worker = worker_id, error = %msg, "SQLITE_BUSY exhausted bounded retries, worker aborting");
2074 w_db_busy = true;
2075 None
2076 }
2077 Err(e) => {
2078 tracing::error!(target: "enrich", worker = worker_id, error = %e, "dequeue failed");
2079 None
2080 }
2081 };
2082 let (queue_id, item_key, _item_type, attempt_current) = match pending {
2083 Some(p) => p,
2084 None => break,
2085 };
2086 let item_started = Instant::now();
2087 let current_index = w_completed + w_failed + w_skipped;
2088
2089 let provider_bin = provider_binary.unwrap_or_else(|| std::path::Path::new(""));
2095 let call_result = match operation {
2096 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => call_memory_bindings(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2097 EnrichOperation::EntityDescriptions => call_entity_description(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2098 EnrichOperation::BodyEnrich => call_body_enrich(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode, min_oc, max_oc, prompt_tpl, args.preserve_threshold, paths, llm_backend, embedding_backend),
2099 EnrichOperation::ReEmbed => call_reembed(&w_conn, namespace, &item_key, paths, llm_backend, embedding_backend),
2100 EnrichOperation::WeightCalibrate => call_weight_calibrate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2101 EnrichOperation::RelationReclassify => call_relation_reclassify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2102 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => call_entity_connect(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2103 EnrichOperation::EntityTypeValidate => call_entity_type_validate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2104 EnrichOperation::DescriptionEnrich => call_description_enrich(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2105 EnrichOperation::DomainClassify => call_domain_classify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2106 EnrichOperation::GraphAudit => call_graph_audit(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2107 EnrichOperation::DeepResearchSynth => call_deep_research_synth(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2108 EnrichOperation::BodyExtract => call_body_extract(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode, args.body_extract_graph_only),
2109 };
2110 let openrouter_diag = take_last_openrouter_failure();
2116
2117 match call_result {
2118 Ok(EnrichItemResult::Done { cost, is_oauth, memory_id, entity_id, entities, rels, chars_before, chars_after }) => {
2119 if is_oauth { w_oauth = true; }
2120 w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2121 let _ = w_queue.execute(
2122 "UPDATE queue SET status='done', memory_id=?1, entity_id=?2, entities=?3, rels=?4, cost_usd=?5, elapsed_ms=?6, done_at=datetime('now') WHERE id=?7",
2123 rusqlite::params![memory_id, entity_id, entities as i64, rels as i64, cost, item_started.elapsed().as_millis() as i64, queue_id],
2124 );
2125 w_completed += 1;
2126 if !is_oauth { w_cost += cost; }
2127 let _ = w_breaker
2129 .record(crate::retry::AttemptOutcome::Success);
2130 let _guard = stdout_mu.lock();
2131 emit_json(&ItemEvent { item: &item_key, status: "done", memory_id, entity_id, entities: Some(entities), rels: Some(rels), chars_before, chars_after, cost_usd: if is_oauth { None } else { Some(cost) }, elapsed_ms: Some(item_started.elapsed().as_millis() as u64), error: None, index: current_index, total });
2132 }
2133 Ok(EnrichItemResult::Skipped { reason }) => {
2134 w_skipped += 1;
2135 let _ = w_queue.execute("UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2", rusqlite::params![reason, queue_id]);
2136 let _guard = stdout_mu.lock();
2137 emit_json(&ItemEvent { item: &item_key, status: "skipped", memory_id: None, entity_id: None, entities: None, rels: None, chars_before: None, chars_after: None, cost_usd: None, elapsed_ms: Some(item_started.elapsed().as_millis() as u64), error: None, index: current_index, total });
2138 }
2139 Ok(EnrichItemResult::PreservationFailed { score, threshold, chars_before, chars_after }) => {
2140 w_skipped += 1;
2146 let reason = format!(
2147 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2148 );
2149 let _ = w_queue.execute(
2150 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2151 rusqlite::params![reason, queue_id],
2152 );
2153 let _guard = stdout_mu.lock();
2154 emit_json(&ItemEvent {
2155 item: &item_key,
2156 status: "preservation_failed",
2157 memory_id: None,
2158 entity_id: None,
2159 entities: None,
2160 rels: None,
2161 chars_before: Some(chars_before),
2162 chars_after: Some(chars_after),
2163 cost_usd: None,
2164 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2165 error: Some(reason),
2166 index: current_index,
2167 total,
2168 });
2169 }
2170 Err(e) => {
2171 let err_str = format!("{e}");
2172 if matches!(e, AppError::RateLimited { .. }) {
2173 if crate::retry::is_kill_switch_active() {
2174 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2175 } else if std::time::Instant::now() >= w_deadline {
2176 tracing::error!(target: "enrich", "rate-limit retry deadline (1h) exhausted in worker");
2177 } else {
2178 let half = w_backoff / 2;
2179 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2180 let actual_wait = half + jitter;
2181 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited in worker, backing off");
2182 let _ = w_queue.execute("UPDATE queue SET status='pending' WHERE id=?1", rusqlite::params![queue_id]);
2183 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2184 w_backoff = (w_backoff * 2).min(900);
2185 continue;
2186 }
2187 }
2188 w_failed += 1;
2189 let outcome = match openrouter_diag {
2196 Some(diag) => record_item_failure_typed(
2197 &w_queue,
2198 queue_id,
2199 attempt_current,
2200 args.max_attempts,
2201 diag.retry_class,
2202 &err_str,
2203 diag.finish_reason.as_deref(),
2204 diag.prompt_tokens,
2205 diag.completion_tokens,
2206 ),
2207 None => record_item_failure(&w_queue, queue_id, attempt_current, args.max_attempts, &e),
2208 };
2209 let _guard = stdout_mu.lock();
2210 emit_json(&ItemEvent { item: &item_key, status: "failed", memory_id: None, entity_id: None, entities: None, rels: None, chars_before: None, chars_after: None, cost_usd: None, elapsed_ms: Some(item_started.elapsed().as_millis() as u64), error: Some(err_str), index: current_index, total });
2211 let breaker_opened = w_breaker.record(outcome);
2214 if breaker_opened {
2215 tracing::error!(target: "enrich",
2216 consecutive_failures = w_breaker.consecutive_failures(),
2217 "circuit breaker opened — aborting worker"
2218 );
2219 break;
2220 }
2221 }
2222 }
2223 }
2224 WorkerResult { completed: w_completed, failed: w_failed, skipped: w_skipped, cost: w_cost, oauth: w_oauth, db_busy: w_db_busy }
2225 })
2226 })
2227 .collect();
2228 handles
2229 .into_iter()
2230 .map(|h| {
2231 h.join().unwrap_or(WorkerResult {
2232 completed: 0,
2233 failed: 0,
2234 skipped: 0,
2235 cost: 0.0,
2236 oauth: false,
2237 db_busy: false,
2238 })
2239 })
2240 .collect()
2241 });
2242
2243 if results.iter().any(|r| r.db_busy) {
2249 return Err(AppError::DbBusy(
2250 "SQLITE_BUSY exhausted bounded retries while dequeuing (parallel worker)"
2251 .into(),
2252 ));
2253 }
2254
2255 for r in &results {
2256 completed += r.completed;
2257 failed += r.failed;
2258 skipped += r.skipped;
2259 cost_total += r.cost;
2260 if r.oauth && !oauth_detected {
2261 oauth_detected = true;
2262 }
2263 }
2264 } else {
2265 loop {
2267 if crate::shutdown_requested() {
2268 tracing::info!(target: "enrich", "shutdown requested, stopping enrichment");
2269 break;
2270 }
2271
2272 if let Some(budget) = args.max_cost_usd {
2274 if !oauth_detected && cost_total >= budget {
2275 tracing::warn!(target: "enrich", spent = cost_total, budget, "budget exceeded, stopping");
2276 break;
2277 }
2278 }
2279
2280 let pending = match crate::storage::utils::with_busy_retry(|| {
2296 dequeue_next_pending(&queue_conn, backoff_clause)
2297 }) {
2298 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2299 Ok(DequeueOutcome::Empty) => None,
2300 Err(e @ AppError::DbBusy(_)) => {
2301 tracing::error!(target: "enrich", error = %e, "SQLITE_BUSY exhausted bounded retries, aborting drain loop");
2302 return Err(e);
2303 }
2304 Err(e) => {
2305 tracing::error!(target: "enrich", error = %e, "dequeue failed");
2306 None
2307 }
2308 };
2309
2310 let (queue_id, item_key, item_type, attempt_current) = match pending {
2311 Some(p) => p,
2312 None => break,
2313 };
2314
2315 let item_started = Instant::now();
2316 let current_index = completed + failed + skipped;
2317
2318 let provider_bin = provider_binary
2321 .as_deref()
2322 .unwrap_or_else(|| std::path::Path::new(""));
2323 let call_result = match args.operation() {
2324 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => {
2325 call_memory_bindings(
2326 &conn,
2327 &namespace,
2328 &item_key,
2329 provider_bin,
2330 provider_model,
2331 provider_timeout,
2332 &args.mode(),
2333 )
2334 }
2335 EnrichOperation::EntityDescriptions => call_entity_description(
2336 &conn,
2337 &namespace,
2338 &item_key,
2339 provider_bin,
2340 provider_model,
2341 provider_timeout,
2342 &args.mode(),
2343 ),
2344 EnrichOperation::BodyEnrich => call_body_enrich(
2345 &conn,
2346 &namespace,
2347 &item_key,
2348 provider_bin,
2349 provider_model,
2350 provider_timeout,
2351 &args.mode(),
2352 args.min_output_chars,
2353 args.max_output_chars,
2354 args.prompt_template.as_deref(),
2355 args.preserve_threshold,
2356 &paths,
2357 llm_backend,
2358 embedding_backend,
2359 ),
2360 EnrichOperation::ReEmbed => call_reembed(
2361 &conn,
2362 &namespace,
2363 &item_key,
2364 &paths,
2365 llm_backend,
2366 embedding_backend,
2367 ),
2368 EnrichOperation::WeightCalibrate => call_weight_calibrate(
2369 &conn,
2370 &namespace,
2371 &item_key,
2372 provider_bin,
2373 provider_model,
2374 provider_timeout,
2375 &args.mode(),
2376 ),
2377 EnrichOperation::RelationReclassify => call_relation_reclassify(
2378 &conn,
2379 &namespace,
2380 &item_key,
2381 provider_bin,
2382 provider_model,
2383 provider_timeout,
2384 &args.mode(),
2385 ),
2386 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => {
2387 call_entity_connect(
2388 &conn,
2389 &namespace,
2390 &item_key,
2391 provider_bin,
2392 provider_model,
2393 provider_timeout,
2394 &args.mode(),
2395 )
2396 }
2397 EnrichOperation::EntityTypeValidate => call_entity_type_validate(
2398 &conn,
2399 &namespace,
2400 &item_key,
2401 provider_bin,
2402 provider_model,
2403 provider_timeout,
2404 &args.mode(),
2405 ),
2406 EnrichOperation::DescriptionEnrich => call_description_enrich(
2407 &conn,
2408 &namespace,
2409 &item_key,
2410 provider_bin,
2411 provider_model,
2412 provider_timeout,
2413 &args.mode(),
2414 ),
2415 EnrichOperation::DomainClassify => call_domain_classify(
2416 &conn,
2417 &namespace,
2418 &item_key,
2419 provider_bin,
2420 provider_model,
2421 provider_timeout,
2422 &args.mode(),
2423 ),
2424 EnrichOperation::GraphAudit => call_graph_audit(
2425 &conn,
2426 &namespace,
2427 &item_key,
2428 provider_bin,
2429 provider_model,
2430 provider_timeout,
2431 &args.mode(),
2432 ),
2433 EnrichOperation::DeepResearchSynth => call_deep_research_synth(
2434 &conn,
2435 &namespace,
2436 &item_key,
2437 provider_bin,
2438 provider_model,
2439 provider_timeout,
2440 &args.mode(),
2441 ),
2442 EnrichOperation::BodyExtract => call_body_extract(
2443 &conn,
2444 &namespace,
2445 &item_key,
2446 provider_bin,
2447 provider_model,
2448 provider_timeout,
2449 &args.mode(),
2450 args.body_extract_graph_only,
2451 ),
2452 };
2453 let openrouter_diag = take_last_openrouter_failure();
2456
2457 match call_result {
2458 Ok(EnrichItemResult::Done {
2459 memory_id,
2460 entity_id,
2461 entities,
2462 rels,
2463 chars_before,
2464 chars_after,
2465 cost,
2466 is_oauth,
2467 }) => {
2468 if is_oauth && !oauth_detected {
2469 oauth_detected = true;
2470 tracing::info!(target: "enrich", "OAuth subscription detected — cost_usd omitted from output");
2471 }
2472 backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
2473
2474 let persist_err: Option<String> = match args.operation() {
2476 EnrichOperation::MemoryBindings => {
2477 None
2479 }
2480 EnrichOperation::EntityDescriptions => {
2481 None
2483 }
2484 EnrichOperation::BodyEnrich => {
2485 None
2487 }
2488 _ => {
2489 None
2491 }
2492 };
2493
2494 if let Err(e) = queue_conn.execute(
2495 "UPDATE queue SET status='done', memory_id=?1, entity_id=?2, entities=?3, rels=?4, cost_usd=?5, elapsed_ms=?6, done_at=datetime('now') WHERE id=?7",
2496 rusqlite::params![
2497 memory_id,
2498 entity_id,
2499 entities as i64,
2500 rels as i64,
2501 cost,
2502 item_started.elapsed().as_millis() as i64,
2503 queue_id
2504 ],
2505 ) {
2506 tracing::warn!(target: "enrich", error = %e, "queue done update failed");
2507 }
2508
2509 if persist_err.is_none() {
2510 completed += 1;
2511 if !is_oauth {
2512 cost_total += cost;
2513 }
2514 emit_json(&ItemEvent {
2515 item: &item_key,
2516 status: "done",
2517 memory_id,
2518 entity_id,
2519 entities: Some(entities),
2520 rels: Some(rels),
2521 chars_before,
2522 chars_after,
2523 cost_usd: if is_oauth { None } else { Some(cost) },
2524 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2525 error: None,
2526 index: current_index,
2527 total,
2528 });
2529 } else {
2530 failed += 1;
2531 emit_json(&ItemEvent {
2532 item: &item_key,
2533 status: "failed",
2534 memory_id: None,
2535 entity_id: None,
2536 entities: None,
2537 rels: None,
2538 chars_before: None,
2539 chars_after: None,
2540 cost_usd: None,
2541 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2542 error: persist_err,
2543 index: current_index,
2544 total,
2545 });
2546 }
2547 }
2548 Ok(EnrichItemResult::Skipped { reason }) => {
2549 skipped += 1;
2550 if let Err(e) = queue_conn.execute(
2551 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2552 rusqlite::params![reason, queue_id],
2553 ) {
2554 tracing::warn!(target: "enrich", error = %e, "queue skipped update failed");
2555 }
2556 emit_json(&ItemEvent {
2557 item: &item_key,
2558 status: "skipped",
2559 memory_id: None,
2560 entity_id: None,
2561 entities: None,
2562 rels: None,
2563 chars_before: None,
2564 chars_after: None,
2565 cost_usd: None,
2566 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2567 error: None,
2568 index: current_index,
2569 total,
2570 });
2571 }
2572 Ok(EnrichItemResult::PreservationFailed {
2573 score,
2574 threshold,
2575 chars_before,
2576 chars_after,
2577 }) => {
2578 skipped += 1;
2585 let reason = format!(
2586 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2587 );
2588 if let Err(qe) = queue_conn.execute(
2589 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2590 rusqlite::params![reason, queue_id],
2591 ) {
2592 tracing::warn!(target: "enrich", error = %qe, "queue preservation_failed update failed");
2593 }
2594 emit_json(&ItemEvent {
2595 item: &item_key,
2596 status: "preservation_failed",
2597 memory_id: None,
2598 entity_id: None,
2599 entities: None,
2600 rels: None,
2601 chars_before: Some(chars_before),
2602 chars_after: Some(chars_after),
2603 cost_usd: None,
2604 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2605 error: Some(reason),
2606 index: current_index,
2607 total,
2608 });
2609 }
2610 Err(e) => {
2611 let err_str = format!("{e}");
2612 if matches!(e, AppError::RateLimited { .. }) {
2613 if crate::retry::is_kill_switch_active() {
2614 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2615 } else if std::time::Instant::now() >= rate_limit_deadline {
2616 tracing::error!(target: "enrich", total_elapsed_secs = enrich_started.elapsed().as_secs(), "rate-limit retry deadline (1h) exhausted");
2617 } else {
2618 let half = backoff_secs / 2;
2619 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2620 let actual_wait = half + jitter;
2621 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited, backing off");
2622 if let Err(qe) = queue_conn.execute(
2623 "UPDATE queue SET status='pending' WHERE id=?1",
2624 rusqlite::params![queue_id],
2625 ) {
2626 tracing::warn!(target: "enrich", error = %qe, "queue pending update failed");
2627 }
2628 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2629 backoff_secs = (backoff_secs * 2).min(900);
2630 continue;
2631 }
2632 }
2633
2634 failed += 1;
2635 let _outcome = match openrouter_diag {
2640 Some(diag) => record_item_failure_typed(
2641 &queue_conn,
2642 queue_id,
2643 attempt_current,
2644 args.max_attempts,
2645 diag.retry_class,
2646 &err_str,
2647 diag.finish_reason.as_deref(),
2648 diag.prompt_tokens,
2649 diag.completion_tokens,
2650 ),
2651 None => record_item_failure(
2652 &queue_conn,
2653 queue_id,
2654 attempt_current,
2655 args.max_attempts,
2656 &e,
2657 ),
2658 };
2659 emit_json(&ItemEvent {
2660 item: &item_key,
2661 status: "failed",
2662 memory_id: None,
2663 entity_id: None,
2664 entities: None,
2665 rels: None,
2666 chars_before: None,
2667 chars_after: None,
2668 cost_usd: None,
2669 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2670 error: Some(err_str),
2671 index: current_index,
2672 total,
2673 });
2674 }
2675 }
2676
2677 let _ = item_type; }
2679 } if !args.until_empty {
2682 break;
2683 }
2684 let eligible_remaining: i64 = queue_conn
2685 .query_row(
2686 &format!("SELECT COUNT(*) FROM queue WHERE status='pending' {backoff_clause}"),
2687 [],
2688 |r| r.get(0),
2689 )
2690 .unwrap_or(0);
2691 let progressed = completed > completed_before;
2692 if std::time::Instant::now() >= until_deadline {
2693 tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
2694 break;
2695 }
2696 if !progressed && eligible_remaining == 0 {
2697 tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
2698 break;
2699 }
2700 if eligible_remaining == 0 {
2701 std::thread::sleep(std::time::Duration::from_secs(1));
2703 }
2704 } let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2707 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2708
2709 let waiting_final: i64 = queue_conn
2713 .query_row(
2714 "SELECT COUNT(*) FROM queue WHERE status='pending' \
2715 AND (operation = ?1 OR operation IS NULL) \
2716 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
2717 rusqlite::params![op_label],
2718 |r| r.get(0),
2719 )
2720 .unwrap_or(0);
2721 let dead_final: i64 = queue_conn
2722 .query_row(
2723 "SELECT COUNT(*) FROM queue WHERE status='dead' \
2724 AND (operation = ?1 OR operation IS NULL)",
2725 rusqlite::params![op_label],
2726 |r| r.get(0),
2727 )
2728 .unwrap_or(0);
2729
2730 emit_json(&EnrichSummary {
2731 summary: true,
2732 operation: format!("{:?}", args.operation()),
2733 items_total: total,
2734 completed,
2735 failed,
2736 skipped,
2737 cost_usd: cost_total,
2738 elapsed_ms: started.elapsed().as_millis() as u64,
2739 backend_invoked: take_enrich_backend(),
2740 waiting: waiting_final,
2741 dead: dead_final,
2742 });
2743
2744 if failed == 0 {
2745 let dead: i64 = queue_conn
2748 .query_row("SELECT COUNT(*) FROM queue WHERE status='dead'", [], |r| {
2749 r.get(0)
2750 })
2751 .unwrap_or(0);
2752 let skipped_remaining: i64 = queue_conn
2758 .query_row(
2759 "SELECT COUNT(*) FROM queue WHERE status='skipped'",
2760 [],
2761 |r| r.get(0),
2762 )
2763 .unwrap_or(0);
2764 if dead == 0 && skipped_remaining == 0 {
2765 let _ = std::fs::remove_file(&queue_path);
2766 }
2767 }
2768
2769 Ok(())
2770}
2771
2772#[cfg(test)]
2779mod tests {
2780 use super::*;
2781
2782 #[test]
2783 fn bindings_schema_is_valid_json() {
2784 let _: serde_json::Value =
2785 serde_json::from_str(BINDINGS_SCHEMA).expect("BINDINGS_SCHEMA must be valid JSON");
2786 }
2787
2788 #[test]
2789 fn entity_description_schema_is_valid_json() {
2790 let _: serde_json::Value = serde_json::from_str(ENTITY_DESCRIPTION_SCHEMA)
2791 .expect("ENTITY_DESCRIPTION_SCHEMA must be valid JSON");
2792 }
2793
2794 #[test]
2795 fn body_enrich_schema_is_valid_json() {
2796 let _: serde_json::Value = serde_json::from_str(BODY_ENRICH_SCHEMA)
2797 .expect("BODY_ENRICH_SCHEMA must be valid JSON");
2798 }
2799}