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, heartbeat, item_type_for, item_type_for_key,
42 open_queue_db, prune_dead_entity_orphans, prune_dead_orphans, record_item_failure,
43 record_item_failure_typed, reset_stale_processing_claims, 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(
546 long,
547 value_name = "KEY",
548 env = "OPENROUTER_API_KEY",
549 hide_env_values = true
550 )]
551 pub openrouter_api_key: Option<String>,
552
553 #[arg(long, value_name = "SECONDS", default_value_t = 600)]
560 pub openrouter_timeout: u64,
561
562 #[arg(long, value_name = "URL")]
564 pub openrouter_base_url: Option<String>,
565
566 #[arg(long, value_name = "USD")]
569 pub max_cost_usd: Option<f64>,
570
571 #[arg(long)]
574 pub resume: bool,
575
576 #[arg(long)]
578 pub retry_failed: bool,
579
580 #[arg(long)]
584 pub reset_stale_claims: bool,
585
586 #[arg(long, value_name = "SECONDS", default_value_t = 1800)]
589 pub stale_claim_secs: u64,
590
591 #[arg(long)]
595 pub until_empty: bool,
596
597 #[arg(long, value_name = "SECONDS")]
600 pub max_runtime: Option<u64>,
601
602 #[arg(long, value_name = "N", default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=20))]
614 pub max_attempts: u32,
615
616 #[arg(long)]
630 pub status: bool,
631
632 #[arg(long)]
637 pub list_dead: bool,
638
639 #[arg(long)]
646 pub requeue_dead: bool,
647
648 #[arg(long)]
656 pub prune_dead_orphans: bool,
657
658 #[arg(long, conflicts_with = "prune_dead_orphans")]
666 pub prune_dead_entity_orphans: bool,
667
668 #[arg(long)]
674 pub ignore_backoff: bool,
675
676 #[arg(long)]
683 pub body_extract_graph_only: bool,
684
685 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=16))]
688 pub rest_concurrency: Option<u32>,
689
690 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
693 pub min_output_chars: usize,
694
695 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
697 pub max_output_chars: usize,
698
699 #[arg(long, default_value_t = true)]
701 pub preserve_check: bool,
702
703 #[arg(long, value_name = "PATH")]
705 pub prompt_template: Option<PathBuf>,
706
707 #[arg(long, default_value_t = 1, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=32))]
711 pub llm_parallelism: u32,
712
713 #[arg(long)]
716 pub json: bool,
717
718 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
720 pub db: Option<String>,
721
722 #[arg(long, value_name = "SECONDS")]
725 pub wait_job_singleton: Option<u64>,
726
727 #[arg(long, default_value_t = false)]
731 pub force_job_singleton: bool,
732
733 #[arg(long, value_name = "NAMES", value_delimiter = ',')]
743 pub names: Vec<String>,
744
745 #[arg(long, value_name = "PATH")]
749 pub names_file: Option<PathBuf>,
750
751 #[arg(long, default_value_t = false)]
755 pub preflight_check: bool,
756
757 #[arg(long, value_enum)]
761 pub fallback_mode: Option<EnrichMode>,
762
763 #[arg(long, value_name = "SECONDS", default_value_t = 300)]
766 pub rate_limit_buffer: u64,
767
768 #[arg(long, default_value_t = true)]
772 pub max_load_check: bool,
773
774 #[arg(long, value_name = "N", default_value_t = 5)]
777 pub circuit_breaker_threshold: u32,
778
779 #[arg(long, value_name = "FLOAT", default_value_t = 0.7)]
786 pub preserve_threshold: f64,
787
788 #[arg(long, default_value_t = true)]
793 pub codex_model_validate: bool,
794
795 #[arg(long, value_name = "MODEL")]
800 pub codex_model_fallback: Option<String>,
801}
802
803impl EnrichArgs {
804 fn operation(&self) -> EnrichOperation {
812 self.operation
813 .clone()
814 .unwrap_or(EnrichOperation::MemoryBindings)
815 }
816
817 fn mode(&self) -> EnrichMode {
822 self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
823 }
824}
825
826#[derive(Debug, Serialize)]
835struct PhaseEvent<'a> {
836 phase: &'a str,
837 #[serde(skip_serializing_if = "Option::is_none")]
838 binary_path: Option<&'a str>,
839 #[serde(skip_serializing_if = "Option::is_none")]
840 version: Option<&'a str>,
841 #[serde(skip_serializing_if = "Option::is_none")]
842 items_total: Option<usize>,
843 #[serde(skip_serializing_if = "Option::is_none")]
844 items_pending: Option<usize>,
845 #[serde(skip_serializing_if = "Option::is_none")]
847 llm_parallelism: Option<u32>,
848}
849
850#[derive(Debug, Serialize)]
855struct ConcurrencyEvent {
856 phase: &'static str,
857 scan_parallelism: u32,
858 drain_parallelism: u32,
859}
860
861#[derive(Debug, Serialize)]
862struct ItemEvent<'a> {
863 item: &'a str,
865 status: &'a str,
866 #[serde(skip_serializing_if = "Option::is_none")]
867 memory_id: Option<i64>,
868 #[serde(skip_serializing_if = "Option::is_none")]
869 entity_id: Option<i64>,
870 #[serde(skip_serializing_if = "Option::is_none")]
871 entities: Option<usize>,
872 #[serde(skip_serializing_if = "Option::is_none")]
873 rels: Option<usize>,
874 #[serde(skip_serializing_if = "Option::is_none")]
875 chars_before: Option<usize>,
876 #[serde(skip_serializing_if = "Option::is_none")]
877 chars_after: Option<usize>,
878 #[serde(skip_serializing_if = "Option::is_none")]
879 cost_usd: Option<f64>,
880 #[serde(skip_serializing_if = "Option::is_none")]
881 elapsed_ms: Option<u64>,
882 #[serde(skip_serializing_if = "Option::is_none")]
883 error: Option<String>,
884 index: usize,
885 total: usize,
886}
887
888#[derive(Debug, Serialize)]
889struct EnrichSummary {
890 summary: bool,
891 operation: String,
892 items_total: usize,
893 completed: usize,
894 failed: usize,
895 skipped: usize,
896 cost_usd: f64,
897 elapsed_ms: u64,
898 #[serde(skip_serializing_if = "Option::is_none")]
903 backend_invoked: Option<&'static str>,
904 waiting: i64,
908 dead: i64,
911}
912
913use crate::output::emit_json_line as emit_json;
914
915enum PreflightOutcome {
929 Healthy,
931 RateLimited {
935 reason: String,
936 suggestion: &'static str,
937 },
938 Error(AppError),
940}
941
942fn run_preflight_probe(args: &EnrichArgs) -> PreflightOutcome {
950 let timeout = std::time::Duration::from_secs(args.rate_limit_buffer.max(60));
951
952 match args.mode() {
953 EnrichMode::ClaudeCode => {
954 let bin = match find_claude_binary(args.claude_binary.as_deref()) {
955 Ok(b) => b,
956 Err(e) => return PreflightOutcome::Error(e),
957 };
958 let mcp_config_path = match crate::spawn::preflight::write_empty_mcp_config_tempfile() {
963 Ok(p) => p,
964 Err(e) => {
965 return PreflightOutcome::Error(AppError::Io(e));
966 }
967 };
968 let mut cmd = std::process::Command::new(&bin);
969 crate::spawn::env_whitelist::apply_env_whitelist(
970 &mut cmd,
971 crate::spawn::env_whitelist::is_strict_env_clear(),
972 );
973 if let Err(e) = crate::spawn::apply_cwd_isolation(&mut cmd) {
974 return PreflightOutcome::Error(e);
975 }
976 cmd.arg("-p")
977 .arg("ping")
978 .arg("--max-turns")
979 .arg("1")
980 .arg("--strict-mcp-config")
981 .arg("--mcp-config")
982 .arg(mcp_config_path.as_os_str())
983 .arg("--dangerously-skip-permissions")
984 .arg("--settings")
985 .arg("{\"hooks\":{}}")
986 .arg("--output-format")
987 .arg("json")
988 .stdin(std::process::Stdio::null())
989 .stdout(std::process::Stdio::piped())
990 .stderr(std::process::Stdio::piped());
991
992 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
993 Ok(c) => c,
994 Err(e) => {
995 return PreflightOutcome::Error(AppError::Io(e));
996 }
997 };
998 let output = match wait_with_timeout(child, timeout) {
999 Ok(out) => out,
1000 Err(e) => return PreflightOutcome::Error(e),
1001 };
1002 if !output.status.success() {
1003 let stderr = String::from_utf8_lossy(&output.stderr);
1004 if stderr.contains("hit your session limit")
1005 || stderr.contains("rate_limit")
1006 || stderr.contains("429")
1007 {
1008 return PreflightOutcome::RateLimited {
1009 reason: stderr.trim().to_string(),
1010 suggestion:
1011 "wait for the OAuth window to reset or use --fallback-mode codex",
1012 };
1013 }
1014 return PreflightOutcome::Error(AppError::Validation(format!(
1015 "preflight probe failed: {stderr}",
1016 stderr = stderr.trim()
1017 )));
1018 }
1019 PreflightOutcome::Healthy
1020 }
1021 EnrichMode::Codex => {
1022 let bin = match find_codex_binary(args.codex_binary.as_deref()) {
1023 Ok(b) => b,
1024 Err(e) => return PreflightOutcome::Error(e),
1025 };
1026 super::codex_spawn::validate_codex_model(args.codex_model.as_deref())
1027 .map_err(PreflightOutcome::Error)
1028 .ok();
1029 let schema = "{}";
1030 let schema_path = match super::codex_spawn::trusted_schema_path() {
1031 Ok(p) => p,
1032 Err(e) => return PreflightOutcome::Error(e),
1033 };
1034 let spawn_args = super::codex_spawn::CodexSpawnArgs {
1035 binary: &bin,
1036 prompt: "ping",
1037 json_schema: schema,
1038 input_text: "",
1039 model: args.codex_model.as_deref(),
1040 timeout_secs: args.rate_limit_buffer.max(60),
1041 schema_path: schema_path.clone(),
1042 };
1043 let mut cmd = match super::codex_spawn::build_codex_command(&spawn_args) {
1044 Ok(c) => c,
1045 Err(e) => return PreflightOutcome::Error(e),
1046 };
1047 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
1048 Ok(c) => c,
1049 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1050 };
1051 let output = match wait_with_timeout(child, timeout) {
1052 Ok(out) => out,
1053 Err(e) => return PreflightOutcome::Error(e),
1054 };
1055 let _ = std::fs::remove_file(&schema_path);
1056 if !output.status.success() {
1057 let stderr = String::from_utf8_lossy(&output.stderr);
1058 if stderr.contains("rate_limit")
1059 || stderr.contains("429")
1060 || stderr.contains("Too Many Requests")
1061 {
1062 return PreflightOutcome::RateLimited {
1063 reason: stderr.trim().to_string(),
1064 suggestion: "wait for the rate-limit window to reset",
1065 };
1066 }
1067 return PreflightOutcome::Error(AppError::Validation(format!(
1068 "preflight probe failed: {stderr}",
1069 stderr = stderr.trim()
1070 )));
1071 }
1072 PreflightOutcome::Healthy
1073 }
1074 EnrichMode::Opencode => {
1075 let bin = match super::opencode_runner::find_opencode_binary_with_override(
1076 args.opencode_binary.as_deref(),
1077 ) {
1078 Ok(b) => b,
1079 Err(e) => return PreflightOutcome::Error(e),
1080 };
1081 let model =
1082 super::opencode_runner::resolve_opencode_model(args.opencode_model.as_deref());
1083 let mut cmd =
1084 match super::opencode_runner::build_opencode_command_sync(&bin, &model, "ping", "")
1085 {
1086 Ok(c) => c,
1087 Err(e) => return PreflightOutcome::Error(e),
1088 };
1089 let child = match super::opencode_runner::spawn_opencode(&mut cmd) {
1090 Ok(c) => c,
1091 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1092 };
1093 let output = match wait_with_timeout(child, timeout) {
1094 Ok(out) => out,
1095 Err(e) => return PreflightOutcome::Error(e),
1096 };
1097 if !output.status.success() {
1098 let stderr = String::from_utf8_lossy(&output.stderr);
1099 if stderr.contains("rate_limit")
1100 || stderr.contains("429")
1101 || stderr.contains("Too Many Requests")
1102 {
1103 return PreflightOutcome::RateLimited {
1104 reason: stderr.trim().to_string(),
1105 suggestion: "wait for the rate-limit window to reset",
1106 };
1107 }
1108 return PreflightOutcome::Error(AppError::Validation(format!(
1109 "preflight probe failed: {stderr}",
1110 stderr = stderr.trim()
1111 )));
1112 }
1113 PreflightOutcome::Healthy
1114 }
1115 EnrichMode::OpenRouter => {
1116 match crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref()) {
1120 Some(_) => PreflightOutcome::Healthy,
1121 None => PreflightOutcome::Error(AppError::Validation(
1122 "OPENROUTER_API_KEY not found for --mode openrouter preflight".into(),
1123 )),
1124 }
1125 }
1126 }
1127}
1128
1129fn wait_with_timeout(
1131 mut child: std::process::Child,
1132 timeout: std::time::Duration,
1133) -> Result<std::process::Output, AppError> {
1134 use wait_timeout::ChildExt;
1135 let start = std::time::Instant::now();
1136 let Some(exit) = child.wait_timeout(timeout).map_err(AppError::Io)? else {
1137 let _ = child.kill();
1138 let _ = child.wait();
1139 return Err(AppError::Validation(format!(
1140 "preflight probe timed out after {}s",
1141 start.elapsed().as_secs()
1142 )));
1143 };
1144 let mut stdout = Vec::new();
1145 if let Some(mut out) = child.stdout.take() {
1146 std::io::Read::read_to_end(&mut out, &mut stdout).map_err(AppError::Io)?;
1147 }
1148 let mut stderr = Vec::new();
1149 if let Some(mut err) = child.stderr.take() {
1150 std::io::Read::read_to_end(&mut err, &mut stderr).map_err(AppError::Io)?;
1151 }
1152 Ok(std::process::Output {
1153 status: exit,
1154 stdout,
1155 stderr,
1156 })
1157}
1158
1159fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1175 value == default
1176}
1177
1178fn validate_mode_conditional_flags_enrich(args: &EnrichArgs) -> Result<(), AppError> {
1193 const DEFAULT_TIMEOUT: u64 = 300;
1194
1195 let mut conflicts: Vec<String> = Vec::new();
1196
1197 match args.mode() {
1198 EnrichMode::ClaudeCode => {
1199 if args.codex_binary.is_some() {
1200 conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1201 }
1202 if args.codex_model.is_some() {
1203 conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1204 }
1205 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1206 conflicts.push(format!(
1207 "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1208 args.codex_timeout
1209 ));
1210 }
1211 }
1212 EnrichMode::Codex => {
1213 if args.claude_binary.is_some() {
1214 conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1215 }
1216 if args.claude_model.is_some() {
1217 conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1218 }
1219 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1220 conflicts.push(format!(
1221 "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1222 args.claude_timeout
1223 ));
1224 }
1225 if args.max_cost_usd.is_some() {
1226 conflicts.push(
1227 "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription, not the call)"
1228 .to_string(),
1229 );
1230 }
1231 }
1232 EnrichMode::Opencode => {
1233 if args.claude_binary.is_some() {
1234 conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1235 }
1236 if args.claude_model.is_some() {
1237 conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1238 }
1239 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1240 conflicts.push(format!(
1241 "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1242 args.claude_timeout
1243 ));
1244 }
1245 if args.max_cost_usd.is_some() {
1246 conflicts.push(
1247 "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription, not the call)"
1248 .to_string(),
1249 );
1250 }
1251 }
1252 EnrichMode::OpenRouter => {
1253 if args.claude_binary.is_some() {
1254 conflicts.push("--claude-binary is ignored when --mode=openrouter".to_string());
1255 }
1256 if args.claude_model.is_some() {
1257 conflicts.push("--claude-model is ignored when --mode=openrouter".to_string());
1258 }
1259 if args.codex_binary.is_some() {
1260 conflicts.push("--codex-binary is ignored when --mode=openrouter".to_string());
1261 }
1262 if args.codex_model.is_some() {
1263 conflicts.push("--codex-model is ignored when --mode=openrouter".to_string());
1264 }
1265 if args.opencode_binary.is_some() {
1266 conflicts.push("--opencode-binary is ignored when --mode=openrouter".to_string());
1267 }
1268 if args.opencode_model.is_some() {
1269 conflicts.push("--opencode-model is ignored when --mode=openrouter".to_string());
1270 }
1271 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1272 conflicts.push(format!(
1273 "--claude-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1274 args.claude_timeout
1275 ));
1276 }
1277 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1278 conflicts.push(format!(
1279 "--codex-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1280 args.codex_timeout
1281 ));
1282 }
1283 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1284 conflicts.push(format!(
1285 "--opencode-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1286 args.opencode_timeout
1287 ));
1288 }
1289 }
1290 }
1291
1292 if !conflicts.is_empty() {
1293 return Err(AppError::Validation(format!(
1294 "G20: mode-conditional flag conflicts detected for --mode={}:\n - {}",
1295 args.mode(),
1296 conflicts.join("\n - ")
1297 )));
1298 }
1299
1300 Ok(())
1301}
1302
1303pub fn run(
1307 args: &EnrichArgs,
1308 llm_backend: crate::cli::LlmBackendChoice,
1309 embedding_backend: crate::cli::EmbeddingBackendChoice,
1310) -> Result<(), AppError> {
1311 validate_mode_conditional_flags_enrich(args)?;
1314
1315 if args.target != ReEmbedTarget::Memories
1318 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1319 {
1320 let target_label = match args.target {
1321 ReEmbedTarget::Memories => "memories",
1322 ReEmbedTarget::Entities => "entities",
1323 ReEmbedTarget::Chunks => "chunks",
1324 ReEmbedTarget::All => "all",
1325 };
1326 return Err(AppError::Validation(format!(
1327 "--target {target_label} only applies to --operation re-embed"
1328 )));
1329 }
1330
1331 if args.list_dead
1340 || args.requeue_dead
1341 || args.prune_dead_orphans
1342 || args.prune_dead_entity_orphans
1343 {
1344 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1345 let op_label = format!("{:?}", args.operation());
1346 let paths = AppPaths::resolve(args.db.as_deref())?;
1347 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1348 let queue_conn = open_queue_db(&queue_path)?;
1349 if args.prune_dead_entity_orphans {
1354 let pruned = prune_dead_entity_orphans(&queue_conn, &op_label)?;
1355 let dead_total: i64 = queue_conn
1356 .query_row(
1357 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1358 AND item_type='entity' \
1359 AND (operation = ?1 OR operation IS NULL)",
1360 rusqlite::params![op_label],
1361 |r| r.get(0),
1362 )
1363 .unwrap_or(0);
1364 emit_json(&DeadSummary {
1365 summary: true,
1366 operation: op_label,
1367 namespace,
1368 action: "prune-dead-entity-orphans",
1369 dead_total,
1370 requeued: 0,
1371 pruned,
1372 });
1373 return Ok(());
1374 }
1375 if args.prune_dead_orphans {
1378 ensure_db_ready(&paths)?;
1379 let main_conn = open_rw(&paths.db)?;
1380 let pruned = prune_dead_orphans(&queue_conn, &main_conn, &op_label, &namespace)?;
1381 let dead_total: i64 = queue_conn
1382 .query_row(
1383 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1384 AND (operation = ?1 OR operation IS NULL)",
1385 rusqlite::params![op_label],
1386 |r| r.get(0),
1387 )
1388 .unwrap_or(0);
1389 emit_json(&DeadSummary {
1390 summary: true,
1391 operation: op_label,
1392 namespace,
1393 action: "prune-dead-orphans",
1394 dead_total,
1395 requeued: 0,
1396 pruned,
1397 });
1398 return Ok(());
1399 }
1400 if args.list_dead {
1401 let mut stmt = queue_conn.prepare(
1402 "SELECT item_key, item_type, attempt, error_class, error, \
1403 finish_reason, input_tokens, output_tokens FROM queue \
1404 WHERE status='dead' AND (operation = ?1 OR operation IS NULL) ORDER BY id",
1405 )?;
1406 let rows = stmt
1407 .query_map(rusqlite::params![op_label], |r| {
1408 Ok(DeadItem {
1409 dead_item: true,
1410 item_key: r.get(0)?,
1411 item_type: r.get(1)?,
1412 attempt: r.get(2)?,
1413 error_class: r.get(3)?,
1414 error: r.get(4)?,
1415 finish_reason: r.get(5)?,
1416 input_tokens: r.get(6)?,
1417 output_tokens: r.get(7)?,
1418 })
1419 })?
1420 .collect::<Result<Vec<_>, _>>()?;
1421 let dead_total = rows.len() as i64;
1422 for item in &rows {
1423 emit_json(item);
1424 }
1425 emit_json(&DeadSummary {
1426 summary: true,
1427 operation: op_label,
1428 namespace,
1429 action: "list-dead",
1430 dead_total,
1431 requeued: 0,
1432 pruned: 0,
1433 });
1434 return Ok(());
1435 }
1436 let dead_total: i64 = queue_conn
1438 .query_row(
1439 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1440 AND (operation = ?1 OR operation IS NULL)",
1441 rusqlite::params![op_label],
1442 |r| r.get(0),
1443 )
1444 .unwrap_or(0);
1445 let requeued = queue_conn
1446 .execute(
1447 "UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
1448 error=NULL, error_class=NULL \
1449 WHERE status='dead' AND (operation = ?1 OR operation IS NULL)",
1450 rusqlite::params![op_label],
1451 )
1452 .map_err(|e| AppError::Validation(format!("requeue-dead failed: {e}")))?
1453 as i64;
1454 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1455 emit_json(&DeadSummary {
1456 summary: true,
1457 operation: op_label,
1458 namespace,
1459 action: "requeue-dead",
1460 dead_total,
1461 requeued,
1462 pruned: 0,
1463 });
1464 return Ok(());
1465 }
1466
1467 if args.status {
1468 let paths = AppPaths::resolve(args.db.as_deref())?;
1469 ensure_db_ready(&paths)?;
1470 let conn = open_rw(&paths.db)?;
1471 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1472 let unbound_backlog = scan_unbound_memories(&conn, &namespace, None, &[])?.len();
1473 let scan_backlog =
1476 count_operation_backlog(&conn, &args.operation(), &namespace, args.target)?;
1477 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1478 let queue_conn = open_queue_db(&queue_path)?;
1479 let op_label = format!("{:?}", args.operation());
1480 let count_status = |st: &str, op: &str| -> i64 {
1484 queue_conn
1485 .query_row(
1486 "SELECT COUNT(*) FROM queue WHERE status=?1 \
1487 AND (operation = ?2 OR operation IS NULL)",
1488 rusqlite::params![st, op],
1489 |r| r.get(0),
1490 )
1491 .unwrap_or(0)
1492 };
1493 let eligible_now: i64 = queue_conn
1494 .query_row(
1495 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1496 AND (operation = ?1 OR operation IS NULL) \
1497 AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))",
1498 rusqlite::params![op_label],
1499 |r| r.get(0),
1500 )
1501 .unwrap_or(0);
1502 let waiting: i64 = queue_conn
1503 .query_row(
1504 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1505 AND (operation = ?1 OR operation IS NULL) \
1506 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
1507 rusqlite::params![op_label],
1508 |r| r.get(0),
1509 )
1510 .unwrap_or(0);
1511 let waiting_items = {
1513 let mut stmt = queue_conn.prepare(
1514 "SELECT item_key, attempt, next_retry_at, error_class FROM queue \
1515 WHERE status='pending' AND (operation = ?1 OR operation IS NULL) \
1516 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now') \
1517 ORDER BY next_retry_at",
1518 )?;
1519 let items: Vec<WaitingItem> = stmt
1520 .query_map(rusqlite::params![op_label], |r| {
1521 Ok(WaitingItem {
1522 item_key: r.get(0)?,
1523 attempt: r.get(1)?,
1524 next_retry_at: r.get(2)?,
1525 error_class: r.get(3)?,
1526 })
1527 })?
1528 .collect::<Result<Vec<_>, _>>()?;
1529 items
1530 };
1531 let queue_pending = count_status("pending", &op_label);
1532 let queue_processing = count_status("processing", &op_label);
1533 let queue_done = count_status("done", &op_label);
1534 let queue_failed = count_status("failed", &op_label);
1535 let queue_skipped = count_status("skipped", &op_label);
1536 let queue_dead = count_status("dead", &op_label);
1537 let state = if eligible_now > 0 {
1539 "draining"
1540 } else if waiting > 0 {
1541 "cooldown"
1542 } else if queue_pending == 0 && scan_backlog > 0 {
1543 "pending-scan"
1544 } else {
1545 "empty"
1546 };
1547 emit_json(&EnrichStatus {
1548 status_report: true,
1549 operation: op_label,
1550 namespace,
1551 unbound_backlog,
1552 scan_backlog,
1553 queue_pending,
1554 queue_processing,
1555 queue_done,
1556 queue_failed,
1557 queue_skipped,
1558 queue_dead,
1559 eligible_now,
1560 waiting,
1561 state,
1562 waiting_items,
1563 });
1564 return Ok(());
1565 }
1566
1567 if args.reset_stale_claims {
1572 let paths = AppPaths::resolve(args.db.as_deref())?;
1573 ensure_db_ready(&paths)?;
1574 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1575 let queue_conn = open_queue_db(&queue_path)?;
1576 let reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
1577 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1578 tracing::info!(
1579 target: "enrich",
1580 reset,
1581 max_age_secs = args.stale_claim_secs,
1582 "reset stale processing claims"
1583 );
1584 emit_json(&serde_json::json!({
1585 "reset_stale_claims": true,
1586 "reset": reset,
1587 "max_age_secs": args.stale_claim_secs,
1588 }));
1589 return Ok(());
1590 }
1591
1592 if args.mode() == EnrichMode::OpenRouter {
1597 let model = args.openrouter_model.as_deref().ok_or_else(|| {
1598 AppError::Validation(
1599 "--mode openrouter requires --openrouter-model (no default model is allowed)"
1600 .into(),
1601 )
1602 })?;
1603 let resolved =
1604 crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref())
1605 .ok_or_else(|| {
1606 AppError::Validation(
1607 "OPENROUTER_API_KEY not found; set the env var, store it via \
1608 `config add-key --provider openrouter`, or pass --openrouter-api-key"
1609 .into(),
1610 )
1611 })?;
1612 crate::embedder::get_openrouter_chat_client(
1613 resolved.value,
1614 model,
1615 args.openrouter_timeout,
1616 )?;
1617 }
1618
1619 let started = Instant::now();
1620
1621 let paths = AppPaths::resolve(args.db.as_deref())?;
1622 ensure_db_ready(&paths)?;
1623 let conn = open_rw(&paths.db)?;
1624 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1625
1626 let wait_secs = args.wait_job_singleton;
1632 let force_flag = args.force_job_singleton;
1633 let _singleton = crate::lock::acquire_job_singleton(
1634 crate::lock::JobType::Enrich,
1635 &namespace,
1636 &paths.db,
1637 wait_secs,
1638 force_flag,
1639 )?;
1640
1641 let provider_binary = if matches!(args.operation(), EnrichOperation::ReEmbed) {
1643 None
1644 } else {
1645 Some(match args.mode() {
1646 EnrichMode::ClaudeCode => {
1647 let bin = find_claude_binary(args.claude_binary.as_deref())?;
1648 let version = super::claude_runner::validate_claude_version(&bin)?;
1649 tracing::info!(target: "enrich", binary = %bin.display(), version = %version, "Claude Code binary validated");
1650 emit_json(&PhaseEvent {
1651 phase: "validate",
1652 binary_path: bin.to_str(),
1653 version: Some(&version),
1654 items_total: None,
1655 items_pending: None,
1656 llm_parallelism: None,
1657 });
1658 bin
1659 }
1660 EnrichMode::Codex => {
1661 let bin = find_codex_binary(args.codex_binary.as_deref())?;
1662 emit_json(&PhaseEvent {
1663 phase: "validate",
1664 binary_path: bin.to_str(),
1665 version: None,
1666 items_total: None,
1667 items_pending: None,
1668 llm_parallelism: None,
1669 });
1670 bin
1671 }
1672 EnrichMode::Opencode => {
1673 let bin = super::opencode_runner::find_opencode_binary_with_override(
1674 args.opencode_binary.as_deref(),
1675 )?;
1676 emit_json(&PhaseEvent {
1677 phase: "validate",
1678 binary_path: bin.to_str(),
1679 version: None,
1680 items_total: None,
1681 items_pending: None,
1682 llm_parallelism: None,
1683 });
1684 bin
1685 }
1686 EnrichMode::OpenRouter => {
1687 emit_json(&PhaseEvent {
1692 phase: "validate",
1693 binary_path: None,
1694 version: None,
1695 items_total: None,
1696 items_pending: None,
1697 llm_parallelism: None,
1698 });
1699 PathBuf::new()
1700 }
1701 })
1702 };
1703
1704 if args.max_load_check && !args.dry_run && crate::system_load::is_system_saturated() {
1708 let load = crate::system_load::load_average_one();
1709 let n = crate::system_load::ncpus();
1710 return Err(AppError::Validation(format!(
1711 "system load average {load:.2} exceeds 2x ncpus ({n}); \
1712 pass --no-max-load-check to override (not recommended)"
1713 )));
1714 }
1715
1716 if args.preflight_check
1723 && !args.dry_run
1724 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1725 {
1726 let preflight_result = run_preflight_probe(args);
1727 match preflight_result {
1728 PreflightOutcome::Healthy => {
1729 tracing::info!(target: "enrich", mode = ?args.mode(), "preflight probe healthy");
1730 }
1731 PreflightOutcome::RateLimited { reason, suggestion } => {
1732 if let Some(fallback) = args.fallback_mode.clone() {
1733 if fallback != args.mode() {
1734 return Err(AppError::Validation(format!(
1744 "preflight detected rate limit on {mode:?}: {reason}; \
1745 re-invoke with `--mode {fallback:?}` to use the fallback provider",
1746 mode = args.mode()
1747 )));
1748 }
1749 return Err(AppError::Validation(format!(
1750 "preflight detected rate limit on {mode:?}: {reason}; \
1751 --fallback-mode matches --mode, no recovery possible",
1752 mode = args.mode()
1753 )));
1754 }
1755 return Err(AppError::Validation(format!(
1756 "preflight detected rate limit on {mode:?}: {reason}; \
1757 {suggestion}; pass --fallback-mode codex to recover",
1758 mode = args.mode()
1759 )));
1760 }
1761 PreflightOutcome::Error(e) => {
1762 return Err(e);
1763 }
1764 }
1765 }
1766
1767 let mut scan_result = scan_operation(&conn, &namespace, args)?;
1769 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
1778 let q_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1779 if let Ok(q) = open_queue_db(&q_path) {
1780 if let Ok(vetoed) = skipped_item_keys(&q, &format!("{:?}", args.operation())) {
1781 scan_result.retain(|k| !vetoed.contains(k));
1782 }
1783 }
1784 }
1785 let total = scan_result.len();
1786
1787 emit_json(&PhaseEvent {
1788 phase: "scan",
1789 binary_path: None,
1790 version: None,
1791 items_total: Some(total),
1792 items_pending: Some(total),
1793 llm_parallelism: Some(args.llm_parallelism),
1794 });
1795
1796 if args.dry_run {
1798 for (idx, key) in scan_result.iter().enumerate() {
1799 emit_json(&ItemEvent {
1800 item: key,
1801 status: "preview",
1802 memory_id: None,
1803 entity_id: None,
1804 entities: None,
1805 rels: None,
1806 chars_before: None,
1807 chars_after: None,
1808 cost_usd: None,
1809 elapsed_ms: None,
1810 error: None,
1811 index: idx,
1812 total,
1813 });
1814 }
1815 emit_json(&EnrichSummary {
1816 summary: true,
1817 operation: format!("{:?}", args.operation()),
1818 items_total: total,
1819 completed: 0,
1820 failed: 0,
1821 skipped: 0,
1822 cost_usd: 0.0,
1823 elapsed_ms: started.elapsed().as_millis() as u64,
1824 backend_invoked: take_enrich_backend(),
1825 waiting: 0,
1826 dead: 0,
1827 });
1828 return Ok(());
1829 }
1830
1831 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1837 let mut queue_conn = open_queue_db(&queue_path)?;
1838
1839 {
1844 let stale_reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
1845 if stale_reset > 0 {
1846 tracing::info!(
1847 target: "enrich",
1848 count = stale_reset,
1849 max_age_secs = args.stale_claim_secs,
1850 "reset stale processing claims (older than threshold)"
1851 );
1852 }
1853 }
1854
1855 if args.resume {
1856 let reset = queue_conn
1857 .execute(
1858 "UPDATE queue SET status='pending' WHERE status='processing'",
1859 [],
1860 )
1861 .map_err(|e| AppError::Validation(format!("queue resume failed: {e}")))?;
1862 if reset > 0 {
1863 tracing::info!(target: "enrich", count = reset, "reset stuck processing items to pending");
1864 }
1865 }
1866
1867 if args.retry_failed {
1868 let count = queue_conn
1869 .execute(
1870 "UPDATE queue SET status='pending', attempt=0 WHERE status='failed'",
1871 [],
1872 )
1873 .map_err(|e| AppError::Validation(format!("queue retry-failed reset failed: {e}")))?;
1874 tracing::info!(target: "enrich", count, "retrying failed items");
1875 }
1876
1877 if !args.resume && !args.retry_failed && !args.until_empty {
1878 queue_conn
1879 .execute("DELETE FROM queue", [])
1880 .map_err(|e| AppError::Validation(format!("queue clear failed: {e}")))?;
1881 }
1882
1883 let op_label = format!("{:?}", args.operation());
1889 let item_type = item_type_for(&args.operation());
1890 {
1891 let tx = queue_conn.transaction()?;
1892 let tx_conn: &Connection = &tx;
1895 for key in scan_result.iter() {
1896 let it = item_type_for_key(key, item_type);
1900 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
1901 }
1902 tx.commit()?;
1903 }
1904
1905 let parallelism = if args.mode() == EnrichMode::OpenRouter {
1908 let rest = args.rest_concurrency.unwrap_or(8).clamp(1, 16) as usize;
1909 tracing::info!(
1910 target: "enrich",
1911 concurrency = rest,
1912 source = "rest_concurrency",
1913 "OpenRouter REST concurrency (clamp 1..=16)"
1914 );
1915 rest
1916 } else {
1917 let p = args.llm_parallelism.clamp(1, 32) as usize;
1918 tracing::info!(
1919 target: "enrich",
1920 concurrency = p,
1921 source = "llm_parallelism",
1922 "LLM subprocess parallelism (clamp 1..=32)"
1923 );
1924 p
1925 };
1926 if parallelism > 1 {
1927 tracing::info!(
1928 target: "enrich",
1929 llm_parallelism = parallelism,
1930 "parallel LLM processing with bounded thread pool"
1931 );
1932 }
1933 if parallelism > 4 {
1937 match args.mode() {
1938 EnrichMode::ClaudeCode => {
1939 tracing::warn!(
1940 target: "enrich",
1941 llm_parallelism = parallelism,
1942 recommended_max = 4,
1943 mode = "claude-code",
1944 "llm_parallelism above 4 multiplies Claude Code subprocess fan-out; \
1945 consider combining with SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR \
1946 to cut MCP children (G28-A)"
1947 );
1948 }
1949 EnrichMode::Codex if parallelism > 16 => {
1950 tracing::warn!(
1951 target: "enrich",
1952 llm_parallelism = parallelism,
1953 recommended_max = 16,
1954 mode = "codex",
1955 "llm_parallelism above 16 risks OAuth rate-limit on Codex; \
1956 consider --llm-parallelism 8 for safer concurrency"
1957 );
1958 }
1959 EnrichMode::Codex => {
1960 }
1964 EnrichMode::Opencode if parallelism > 16 => {
1965 tracing::warn!(
1966 target: "enrich",
1967 llm_parallelism = parallelism,
1968 recommended_max = 16,
1969 mode = "opencode",
1970 "llm_parallelism above 16 risks OAuth rate-limit on OpenCode; \
1971 consider --llm-parallelism 8 for safer concurrency"
1972 );
1973 }
1974 EnrichMode::Opencode => {
1975 }
1977 EnrichMode::OpenRouter => {
1978 }
1981 }
1982 }
1983
1984 let mut completed = 0usize;
1985 let mut failed = 0usize;
1986 let mut skipped = 0usize;
1987 let mut cost_total = 0.0f64;
1988 let mut oauth_detected = false;
1989 let mut backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
1990 let rate_limit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
1991 let enrich_started = std::time::Instant::now();
1992
1993 let provider_timeout = match args.mode() {
1994 EnrichMode::ClaudeCode => args.claude_timeout,
1995 EnrichMode::Codex => args.codex_timeout,
1996 EnrichMode::Opencode => args.opencode_timeout,
1997 EnrichMode::OpenRouter => args.openrouter_timeout,
1998 };
1999
2000 let provider_model: Option<&str> = match args.mode() {
2001 EnrichMode::ClaudeCode => args.claude_model.as_deref(),
2002 EnrichMode::Codex => args.codex_model.as_deref(),
2003 EnrichMode::Opencode => args.opencode_model.as_deref(),
2004 EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
2005 };
2006
2007 let backoff_clause: &str = if args.ignore_backoff {
2011 ""
2012 } else {
2013 "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
2014 };
2015
2016 emit_json(&ConcurrencyEvent {
2019 phase: "concurrency",
2020 scan_parallelism: 1,
2021 drain_parallelism: parallelism as u32,
2022 });
2023
2024 let until_deadline = std::time::Instant::now()
2028 + std::time::Duration::from_secs(args.max_runtime.unwrap_or(3600));
2029 loop {
2030 if args.until_empty {
2031 let mut rescan = scan_operation(&conn, &namespace, args)?;
2035 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
2040 if let Ok(vetoed) = skipped_item_keys(&queue_conn, &op_label) {
2041 rescan.retain(|k| !vetoed.contains(k));
2042 }
2043 }
2044 {
2046 let tx = queue_conn.transaction()?;
2047 let tx_conn: &Connection = &tx;
2048 for key in &rescan {
2049 let it = item_type_for_key(key, item_type);
2050 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
2051 }
2052 tx.commit()?;
2053 }
2054 }
2055 let completed_before = completed;
2056
2057 if parallelism > 1 {
2061 let stdout_mu = parking_lot::Mutex::new(());
2062 let budget = args.max_cost_usd;
2063 let operation = args.operation().clone();
2064 let mode = args.mode().clone();
2065 let min_oc = args.min_output_chars;
2066 let max_oc = args.max_output_chars;
2067 let prompt_tpl = args.prompt_template.as_deref().map(|p| p.to_path_buf());
2068
2069 struct WorkerResult {
2070 completed: usize,
2071 failed: usize,
2072 skipped: usize,
2073 cost: f64,
2074 oauth: bool,
2075 db_busy: bool,
2080 }
2081
2082 let results: Vec<WorkerResult> = std::thread::scope(|s| {
2083 let handles: Vec<_> = (0..parallelism)
2084 .map(|worker_id| {
2085 let stdout_mu = &stdout_mu;
2086 let paths = &paths;
2087 let queue_path = &queue_path;
2088 let namespace = &namespace;
2089 let provider_binary = provider_binary.as_deref();
2090 let operation = &operation;
2091 let mode = &mode;
2092 let prompt_tpl = prompt_tpl.as_deref();
2093 s.spawn(move || {
2094 let w_conn = match open_rw(&paths.db) {
2095 Ok(c) => c,
2096 Err(e) => {
2097 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open DB");
2098 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2099 }
2100 };
2101 let w_queue = match open_queue_db(queue_path) {
2102 Ok(c) => c,
2103 Err(e) => {
2104 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open queue DB");
2105 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2106 }
2107 };
2108 let mut w_completed = 0usize;
2109 let mut w_failed = 0usize;
2110 let mut w_skipped = 0usize;
2111 let mut w_cost = 0.0f64;
2112 let mut w_oauth = false;
2113 let mut w_db_busy = false;
2114 let mut w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2115 let w_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
2116 let mut w_breaker = crate::retry::CircuitBreaker::new(
2122 args.circuit_breaker_threshold.max(1),
2123 std::time::Duration::from_secs(60),
2124 );
2125
2126 loop {
2127 if crate::shutdown_requested() {
2128 tracing::info!(target: "enrich", "shutdown requested, worker stopping");
2129 break;
2130 }
2131 if let Some(b) = budget {
2132 if !w_oauth && w_cost >= b {
2133 break;
2134 }
2135 }
2136 let pending = match crate::storage::utils::with_busy_retry(|| {
2155 dequeue_next_pending(&w_queue, backoff_clause)
2156 }) {
2157 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2158 Ok(DequeueOutcome::Empty) => None,
2159 Err(AppError::DbBusy(msg)) => {
2160 tracing::error!(target: "enrich", worker = worker_id, error = %msg, "SQLITE_BUSY exhausted bounded retries, worker aborting");
2161 w_db_busy = true;
2162 None
2163 }
2164 Err(e) => {
2165 tracing::error!(target: "enrich", worker = worker_id, error = %e, "dequeue failed");
2166 None
2167 }
2168 };
2169 let (queue_id, item_key, _item_type, attempt_current) = match pending {
2170 Some(p) => p,
2171 None => break,
2172 };
2173 let _ = heartbeat(&w_queue, queue_id);
2178 let item_started = Instant::now();
2179 let current_index = w_completed + w_failed + w_skipped;
2180
2181 let provider_bin = provider_binary.unwrap_or_else(|| std::path::Path::new(""));
2187 let call_result = match operation {
2188 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => call_memory_bindings(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2189 EnrichOperation::EntityDescriptions => call_entity_description(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2190 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),
2191 EnrichOperation::ReEmbed => call_reembed(&w_conn, namespace, &item_key, paths, llm_backend, embedding_backend),
2192 EnrichOperation::WeightCalibrate => call_weight_calibrate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2193 EnrichOperation::RelationReclassify => call_relation_reclassify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2194 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => call_entity_connect(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2195 EnrichOperation::EntityTypeValidate => call_entity_type_validate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2196 EnrichOperation::DescriptionEnrich => call_description_enrich(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2197 EnrichOperation::DomainClassify => call_domain_classify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2198 EnrichOperation::GraphAudit => call_graph_audit(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2199 EnrichOperation::DeepResearchSynth => call_deep_research_synth(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2200 EnrichOperation::BodyExtract => call_body_extract(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode, args.body_extract_graph_only),
2201 };
2202 let openrouter_diag = take_last_openrouter_failure();
2208
2209 match call_result {
2210 Ok(EnrichItemResult::Done { cost, is_oauth, memory_id, entity_id, entities, rels, chars_before, chars_after }) => {
2211 if is_oauth { w_oauth = true; }
2212 w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2213 let _ = w_queue.execute(
2214 "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",
2215 rusqlite::params![memory_id, entity_id, entities as i64, rels as i64, cost, item_started.elapsed().as_millis() as i64, queue_id],
2216 );
2217 w_completed += 1;
2218 if !is_oauth { w_cost += cost; }
2219 let _ = w_breaker
2221 .record(crate::retry::AttemptOutcome::Success);
2222 let _guard = stdout_mu.lock();
2223 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 });
2224 }
2225 Ok(EnrichItemResult::Skipped { reason }) => {
2226 w_skipped += 1;
2227 let _ = w_queue.execute("UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2", rusqlite::params![reason, queue_id]);
2228 let _guard = stdout_mu.lock();
2229 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 });
2230 }
2231 Ok(EnrichItemResult::PreservationFailed { score, threshold, chars_before, chars_after }) => {
2232 w_skipped += 1;
2238 let reason = format!(
2239 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2240 );
2241 let _ = w_queue.execute(
2242 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2243 rusqlite::params![reason, queue_id],
2244 );
2245 let _guard = stdout_mu.lock();
2246 emit_json(&ItemEvent {
2247 item: &item_key,
2248 status: "preservation_failed",
2249 memory_id: None,
2250 entity_id: None,
2251 entities: None,
2252 rels: None,
2253 chars_before: Some(chars_before),
2254 chars_after: Some(chars_after),
2255 cost_usd: None,
2256 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2257 error: Some(reason),
2258 index: current_index,
2259 total,
2260 });
2261 }
2262 Err(e) => {
2263 let err_str = format!("{e}");
2264 if matches!(e, AppError::RateLimited { .. }) {
2265 if crate::retry::is_kill_switch_active() {
2266 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2267 } else if std::time::Instant::now() >= w_deadline {
2268 tracing::error!(target: "enrich", "rate-limit retry deadline (1h) exhausted in worker");
2269 } else {
2270 let half = w_backoff / 2;
2271 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2272 let actual_wait = half + jitter;
2273 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited in worker, backing off");
2274 let _ = w_queue.execute("UPDATE queue SET status='pending' WHERE id=?1", rusqlite::params![queue_id]);
2275 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2276 w_backoff = (w_backoff * 2).min(900);
2277 continue;
2278 }
2279 }
2280 w_failed += 1;
2281 let outcome = match openrouter_diag {
2288 Some(diag) => record_item_failure_typed(
2289 &w_queue,
2290 queue_id,
2291 attempt_current,
2292 args.max_attempts,
2293 diag.retry_class,
2294 &err_str,
2295 diag.finish_reason.as_deref(),
2296 diag.prompt_tokens,
2297 diag.completion_tokens,
2298 ),
2299 None => record_item_failure(&w_queue, queue_id, attempt_current, args.max_attempts, &e),
2300 };
2301 let _guard = stdout_mu.lock();
2302 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 });
2303 let breaker_opened = w_breaker.record(outcome);
2306 if breaker_opened {
2307 tracing::error!(target: "enrich",
2308 consecutive_failures = w_breaker.consecutive_failures(),
2309 "circuit breaker opened — aborting worker"
2310 );
2311 break;
2312 }
2313 }
2314 }
2315 }
2316 WorkerResult { completed: w_completed, failed: w_failed, skipped: w_skipped, cost: w_cost, oauth: w_oauth, db_busy: w_db_busy }
2317 })
2318 })
2319 .collect();
2320 handles
2321 .into_iter()
2322 .map(|h| {
2323 h.join().unwrap_or(WorkerResult {
2324 completed: 0,
2325 failed: 0,
2326 skipped: 0,
2327 cost: 0.0,
2328 oauth: false,
2329 db_busy: false,
2330 })
2331 })
2332 .collect()
2333 });
2334
2335 if results.iter().any(|r| r.db_busy) {
2341 return Err(AppError::DbBusy(
2342 "SQLITE_BUSY exhausted bounded retries while dequeuing (parallel worker)"
2343 .into(),
2344 ));
2345 }
2346
2347 for r in &results {
2348 completed += r.completed;
2349 failed += r.failed;
2350 skipped += r.skipped;
2351 cost_total += r.cost;
2352 if r.oauth && !oauth_detected {
2353 oauth_detected = true;
2354 }
2355 }
2356 } else {
2357 loop {
2359 if crate::shutdown_requested() {
2360 tracing::info!(target: "enrich", "shutdown requested, stopping enrichment");
2361 break;
2362 }
2363
2364 if let Some(budget) = args.max_cost_usd {
2366 if !oauth_detected && cost_total >= budget {
2367 tracing::warn!(target: "enrich", spent = cost_total, budget, "budget exceeded, stopping");
2368 break;
2369 }
2370 }
2371
2372 let pending = match crate::storage::utils::with_busy_retry(|| {
2388 dequeue_next_pending(&queue_conn, backoff_clause)
2389 }) {
2390 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2391 Ok(DequeueOutcome::Empty) => None,
2392 Err(e @ AppError::DbBusy(_)) => {
2393 tracing::error!(target: "enrich", error = %e, "SQLITE_BUSY exhausted bounded retries, aborting drain loop");
2394 return Err(e);
2395 }
2396 Err(e) => {
2397 tracing::error!(target: "enrich", error = %e, "dequeue failed");
2398 None
2399 }
2400 };
2401
2402 let (queue_id, item_key, item_type, attempt_current) = match pending {
2403 Some(p) => p,
2404 None => break,
2405 };
2406
2407 let _ = heartbeat(&queue_conn, queue_id);
2410
2411 let item_started = Instant::now();
2412 let current_index = completed + failed + skipped;
2413
2414 let provider_bin = provider_binary
2417 .as_deref()
2418 .unwrap_or_else(|| std::path::Path::new(""));
2419 let call_result = match args.operation() {
2420 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => {
2421 call_memory_bindings(
2422 &conn,
2423 &namespace,
2424 &item_key,
2425 provider_bin,
2426 provider_model,
2427 provider_timeout,
2428 &args.mode(),
2429 )
2430 }
2431 EnrichOperation::EntityDescriptions => call_entity_description(
2432 &conn,
2433 &namespace,
2434 &item_key,
2435 provider_bin,
2436 provider_model,
2437 provider_timeout,
2438 &args.mode(),
2439 ),
2440 EnrichOperation::BodyEnrich => call_body_enrich(
2441 &conn,
2442 &namespace,
2443 &item_key,
2444 provider_bin,
2445 provider_model,
2446 provider_timeout,
2447 &args.mode(),
2448 args.min_output_chars,
2449 args.max_output_chars,
2450 args.prompt_template.as_deref(),
2451 args.preserve_threshold,
2452 &paths,
2453 llm_backend,
2454 embedding_backend,
2455 ),
2456 EnrichOperation::ReEmbed => call_reembed(
2457 &conn,
2458 &namespace,
2459 &item_key,
2460 &paths,
2461 llm_backend,
2462 embedding_backend,
2463 ),
2464 EnrichOperation::WeightCalibrate => call_weight_calibrate(
2465 &conn,
2466 &namespace,
2467 &item_key,
2468 provider_bin,
2469 provider_model,
2470 provider_timeout,
2471 &args.mode(),
2472 ),
2473 EnrichOperation::RelationReclassify => call_relation_reclassify(
2474 &conn,
2475 &namespace,
2476 &item_key,
2477 provider_bin,
2478 provider_model,
2479 provider_timeout,
2480 &args.mode(),
2481 ),
2482 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => {
2483 call_entity_connect(
2484 &conn,
2485 &namespace,
2486 &item_key,
2487 provider_bin,
2488 provider_model,
2489 provider_timeout,
2490 &args.mode(),
2491 )
2492 }
2493 EnrichOperation::EntityTypeValidate => call_entity_type_validate(
2494 &conn,
2495 &namespace,
2496 &item_key,
2497 provider_bin,
2498 provider_model,
2499 provider_timeout,
2500 &args.mode(),
2501 ),
2502 EnrichOperation::DescriptionEnrich => call_description_enrich(
2503 &conn,
2504 &namespace,
2505 &item_key,
2506 provider_bin,
2507 provider_model,
2508 provider_timeout,
2509 &args.mode(),
2510 ),
2511 EnrichOperation::DomainClassify => call_domain_classify(
2512 &conn,
2513 &namespace,
2514 &item_key,
2515 provider_bin,
2516 provider_model,
2517 provider_timeout,
2518 &args.mode(),
2519 ),
2520 EnrichOperation::GraphAudit => call_graph_audit(
2521 &conn,
2522 &namespace,
2523 &item_key,
2524 provider_bin,
2525 provider_model,
2526 provider_timeout,
2527 &args.mode(),
2528 ),
2529 EnrichOperation::DeepResearchSynth => call_deep_research_synth(
2530 &conn,
2531 &namespace,
2532 &item_key,
2533 provider_bin,
2534 provider_model,
2535 provider_timeout,
2536 &args.mode(),
2537 ),
2538 EnrichOperation::BodyExtract => call_body_extract(
2539 &conn,
2540 &namespace,
2541 &item_key,
2542 provider_bin,
2543 provider_model,
2544 provider_timeout,
2545 &args.mode(),
2546 args.body_extract_graph_only,
2547 ),
2548 };
2549 let openrouter_diag = take_last_openrouter_failure();
2552
2553 match call_result {
2554 Ok(EnrichItemResult::Done {
2555 memory_id,
2556 entity_id,
2557 entities,
2558 rels,
2559 chars_before,
2560 chars_after,
2561 cost,
2562 is_oauth,
2563 }) => {
2564 if is_oauth && !oauth_detected {
2565 oauth_detected = true;
2566 tracing::info!(target: "enrich", "OAuth subscription detected — cost_usd omitted from output");
2567 }
2568 backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
2569
2570 let persist_err: Option<String> = match args.operation() {
2572 EnrichOperation::MemoryBindings => {
2573 None
2575 }
2576 EnrichOperation::EntityDescriptions => {
2577 None
2579 }
2580 EnrichOperation::BodyEnrich => {
2581 None
2583 }
2584 _ => {
2585 None
2587 }
2588 };
2589
2590 if let Err(e) = queue_conn.execute(
2591 "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",
2592 rusqlite::params![
2593 memory_id,
2594 entity_id,
2595 entities as i64,
2596 rels as i64,
2597 cost,
2598 item_started.elapsed().as_millis() as i64,
2599 queue_id
2600 ],
2601 ) {
2602 tracing::warn!(target: "enrich", error = %e, "queue done update failed");
2603 }
2604
2605 if persist_err.is_none() {
2606 completed += 1;
2607 if !is_oauth {
2608 cost_total += cost;
2609 }
2610 emit_json(&ItemEvent {
2611 item: &item_key,
2612 status: "done",
2613 memory_id,
2614 entity_id,
2615 entities: Some(entities),
2616 rels: Some(rels),
2617 chars_before,
2618 chars_after,
2619 cost_usd: if is_oauth { None } else { Some(cost) },
2620 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2621 error: None,
2622 index: current_index,
2623 total,
2624 });
2625 } else {
2626 failed += 1;
2627 emit_json(&ItemEvent {
2628 item: &item_key,
2629 status: "failed",
2630 memory_id: None,
2631 entity_id: None,
2632 entities: None,
2633 rels: None,
2634 chars_before: None,
2635 chars_after: None,
2636 cost_usd: None,
2637 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2638 error: persist_err,
2639 index: current_index,
2640 total,
2641 });
2642 }
2643 }
2644 Ok(EnrichItemResult::Skipped { reason }) => {
2645 skipped += 1;
2646 if let Err(e) = queue_conn.execute(
2647 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2648 rusqlite::params![reason, queue_id],
2649 ) {
2650 tracing::warn!(target: "enrich", error = %e, "queue skipped update failed");
2651 }
2652 emit_json(&ItemEvent {
2653 item: &item_key,
2654 status: "skipped",
2655 memory_id: None,
2656 entity_id: None,
2657 entities: None,
2658 rels: None,
2659 chars_before: None,
2660 chars_after: None,
2661 cost_usd: None,
2662 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2663 error: None,
2664 index: current_index,
2665 total,
2666 });
2667 }
2668 Ok(EnrichItemResult::PreservationFailed {
2669 score,
2670 threshold,
2671 chars_before,
2672 chars_after,
2673 }) => {
2674 skipped += 1;
2681 let reason = format!(
2682 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2683 );
2684 if let Err(qe) = queue_conn.execute(
2685 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2686 rusqlite::params![reason, queue_id],
2687 ) {
2688 tracing::warn!(target: "enrich", error = %qe, "queue preservation_failed update failed");
2689 }
2690 emit_json(&ItemEvent {
2691 item: &item_key,
2692 status: "preservation_failed",
2693 memory_id: None,
2694 entity_id: None,
2695 entities: None,
2696 rels: None,
2697 chars_before: Some(chars_before),
2698 chars_after: Some(chars_after),
2699 cost_usd: None,
2700 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2701 error: Some(reason),
2702 index: current_index,
2703 total,
2704 });
2705 }
2706 Err(e) => {
2707 let err_str = format!("{e}");
2708 if matches!(e, AppError::RateLimited { .. }) {
2709 if crate::retry::is_kill_switch_active() {
2710 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2711 } else if std::time::Instant::now() >= rate_limit_deadline {
2712 tracing::error!(target: "enrich", total_elapsed_secs = enrich_started.elapsed().as_secs(), "rate-limit retry deadline (1h) exhausted");
2713 } else {
2714 let half = backoff_secs / 2;
2715 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2716 let actual_wait = half + jitter;
2717 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited, backing off");
2718 if let Err(qe) = queue_conn.execute(
2719 "UPDATE queue SET status='pending' WHERE id=?1",
2720 rusqlite::params![queue_id],
2721 ) {
2722 tracing::warn!(target: "enrich", error = %qe, "queue pending update failed");
2723 }
2724 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2725 backoff_secs = (backoff_secs * 2).min(900);
2726 continue;
2727 }
2728 }
2729
2730 failed += 1;
2731 let _outcome = match openrouter_diag {
2736 Some(diag) => record_item_failure_typed(
2737 &queue_conn,
2738 queue_id,
2739 attempt_current,
2740 args.max_attempts,
2741 diag.retry_class,
2742 &err_str,
2743 diag.finish_reason.as_deref(),
2744 diag.prompt_tokens,
2745 diag.completion_tokens,
2746 ),
2747 None => record_item_failure(
2748 &queue_conn,
2749 queue_id,
2750 attempt_current,
2751 args.max_attempts,
2752 &e,
2753 ),
2754 };
2755 emit_json(&ItemEvent {
2756 item: &item_key,
2757 status: "failed",
2758 memory_id: None,
2759 entity_id: None,
2760 entities: None,
2761 rels: None,
2762 chars_before: None,
2763 chars_after: None,
2764 cost_usd: None,
2765 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2766 error: Some(err_str),
2767 index: current_index,
2768 total,
2769 });
2770 }
2771 }
2772
2773 let _ = item_type; }
2775 } if !args.until_empty {
2778 break;
2779 }
2780 let eligible_remaining: i64 = queue_conn
2781 .query_row(
2782 &format!("SELECT COUNT(*) FROM queue WHERE status='pending' {backoff_clause}"),
2783 [],
2784 |r| r.get(0),
2785 )
2786 .unwrap_or(0);
2787 let progressed = completed > completed_before;
2788 if std::time::Instant::now() >= until_deadline {
2789 tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
2790 break;
2791 }
2792 if !progressed && eligible_remaining == 0 {
2793 tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
2794 break;
2795 }
2796 if eligible_remaining == 0 {
2797 std::thread::sleep(std::time::Duration::from_secs(1));
2799 }
2800 } if crate::shutdown_requested() {
2810 let reset = queue_conn
2811 .execute(
2812 "UPDATE queue SET status='pending', claimed_at=NULL WHERE status='processing'",
2813 [],
2814 )
2815 .unwrap_or(0);
2816 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2817 tracing::info!(
2818 target: "enrich",
2819 reset,
2820 "graceful shutdown: WAL checkpointed, processing claims reset"
2821 );
2822 }
2823
2824 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2825 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2826
2827 let waiting_final: i64 = queue_conn
2831 .query_row(
2832 "SELECT COUNT(*) FROM queue WHERE status='pending' \
2833 AND (operation = ?1 OR operation IS NULL) \
2834 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
2835 rusqlite::params![op_label],
2836 |r| r.get(0),
2837 )
2838 .unwrap_or(0);
2839 let dead_final: i64 = queue_conn
2840 .query_row(
2841 "SELECT COUNT(*) FROM queue WHERE status='dead' \
2842 AND (operation = ?1 OR operation IS NULL)",
2843 rusqlite::params![op_label],
2844 |r| r.get(0),
2845 )
2846 .unwrap_or(0);
2847
2848 emit_json(&EnrichSummary {
2849 summary: true,
2850 operation: format!("{:?}", args.operation()),
2851 items_total: total,
2852 completed,
2853 failed,
2854 skipped,
2855 cost_usd: cost_total,
2856 elapsed_ms: started.elapsed().as_millis() as u64,
2857 backend_invoked: take_enrich_backend(),
2858 waiting: waiting_final,
2859 dead: dead_final,
2860 });
2861
2862 if failed == 0 {
2863 let dead: i64 = queue_conn
2866 .query_row("SELECT COUNT(*) FROM queue WHERE status='dead'", [], |r| {
2867 r.get(0)
2868 })
2869 .unwrap_or(0);
2870 let skipped_remaining: i64 = queue_conn
2876 .query_row(
2877 "SELECT COUNT(*) FROM queue WHERE status='skipped'",
2878 [],
2879 |r| r.get(0),
2880 )
2881 .unwrap_or(0);
2882 if dead == 0 && skipped_remaining == 0 {
2883 let _ = std::fs::remove_file(&queue_path);
2884 }
2885 }
2886
2887 Ok(())
2888}
2889
2890#[cfg(test)]
2897mod tests {
2898 use super::*;
2899
2900 #[test]
2901 fn bindings_schema_is_valid_json() {
2902 let _: serde_json::Value =
2903 serde_json::from_str(BINDINGS_SCHEMA).expect("BINDINGS_SCHEMA must be valid JSON");
2904 }
2905
2906 #[test]
2907 fn entity_description_schema_is_valid_json() {
2908 let _: serde_json::Value = serde_json::from_str(ENTITY_DESCRIPTION_SCHEMA)
2909 .expect("ENTITY_DESCRIPTION_SCHEMA must be valid JSON");
2910 }
2911
2912 #[test]
2913 fn body_enrich_schema_is_valid_json() {
2914 let _: serde_json::Value = serde_json::from_str(BODY_ENRICH_SCHEMA)
2915 .expect("BODY_ENRICH_SCHEMA must be valid JSON");
2916 }
2917}