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(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 reset_stale_claims: bool,
580
581 #[arg(long, value_name = "SECONDS", default_value_t = 1800)]
584 pub stale_claim_secs: u64,
585
586 #[arg(long)]
590 pub until_empty: bool,
591
592 #[arg(long, value_name = "SECONDS")]
595 pub max_runtime: Option<u64>,
596
597 #[arg(long, value_name = "N", default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=20))]
609 pub max_attempts: u32,
610
611 #[arg(long)]
625 pub status: bool,
626
627 #[arg(long)]
632 pub list_dead: bool,
633
634 #[arg(long)]
641 pub requeue_dead: bool,
642
643 #[arg(long)]
651 pub prune_dead_orphans: bool,
652
653 #[arg(long, conflicts_with = "prune_dead_orphans")]
661 pub prune_dead_entity_orphans: bool,
662
663 #[arg(long)]
669 pub ignore_backoff: bool,
670
671 #[arg(long)]
678 pub body_extract_graph_only: bool,
679
680 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=16))]
683 pub rest_concurrency: Option<u32>,
684
685 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
688 pub min_output_chars: usize,
689
690 #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
692 pub max_output_chars: usize,
693
694 #[arg(long, default_value_t = true)]
696 pub preserve_check: bool,
697
698 #[arg(long, value_name = "PATH")]
700 pub prompt_template: Option<PathBuf>,
701
702 #[arg(long, default_value_t = 1, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=32))]
706 pub llm_parallelism: u32,
707
708 #[arg(long)]
711 pub json: bool,
712
713 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
715 pub db: Option<String>,
716
717 #[arg(long, value_name = "SECONDS")]
720 pub wait_job_singleton: Option<u64>,
721
722 #[arg(long, default_value_t = false)]
726 pub force_job_singleton: bool,
727
728 #[arg(long, value_name = "NAMES", value_delimiter = ',')]
738 pub names: Vec<String>,
739
740 #[arg(long, value_name = "PATH")]
744 pub names_file: Option<PathBuf>,
745
746 #[arg(long, default_value_t = false)]
750 pub preflight_check: bool,
751
752 #[arg(long, value_enum)]
756 pub fallback_mode: Option<EnrichMode>,
757
758 #[arg(long, value_name = "SECONDS", default_value_t = 300)]
761 pub rate_limit_buffer: u64,
762
763 #[arg(long, default_value_t = true)]
767 pub max_load_check: bool,
768
769 #[arg(long, value_name = "N", default_value_t = 5)]
772 pub circuit_breaker_threshold: u32,
773
774 #[arg(long, value_name = "FLOAT", default_value_t = 0.7)]
781 pub preserve_threshold: f64,
782
783 #[arg(long, default_value_t = true)]
788 pub codex_model_validate: bool,
789
790 #[arg(long, value_name = "MODEL")]
795 pub codex_model_fallback: Option<String>,
796}
797
798impl EnrichArgs {
799 fn operation(&self) -> EnrichOperation {
807 self.operation
808 .clone()
809 .unwrap_or(EnrichOperation::MemoryBindings)
810 }
811
812 fn mode(&self) -> EnrichMode {
817 self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
818 }
819}
820
821#[derive(Debug, Serialize)]
830struct PhaseEvent<'a> {
831 phase: &'a str,
832 #[serde(skip_serializing_if = "Option::is_none")]
833 binary_path: Option<&'a str>,
834 #[serde(skip_serializing_if = "Option::is_none")]
835 version: Option<&'a str>,
836 #[serde(skip_serializing_if = "Option::is_none")]
837 items_total: Option<usize>,
838 #[serde(skip_serializing_if = "Option::is_none")]
839 items_pending: Option<usize>,
840 #[serde(skip_serializing_if = "Option::is_none")]
842 llm_parallelism: Option<u32>,
843}
844
845#[derive(Debug, Serialize)]
850struct ConcurrencyEvent {
851 phase: &'static str,
852 scan_parallelism: u32,
853 drain_parallelism: u32,
854}
855
856#[derive(Debug, Serialize)]
857struct ItemEvent<'a> {
858 item: &'a str,
860 status: &'a str,
861 #[serde(skip_serializing_if = "Option::is_none")]
862 memory_id: Option<i64>,
863 #[serde(skip_serializing_if = "Option::is_none")]
864 entity_id: Option<i64>,
865 #[serde(skip_serializing_if = "Option::is_none")]
866 entities: Option<usize>,
867 #[serde(skip_serializing_if = "Option::is_none")]
868 rels: Option<usize>,
869 #[serde(skip_serializing_if = "Option::is_none")]
870 chars_before: Option<usize>,
871 #[serde(skip_serializing_if = "Option::is_none")]
872 chars_after: Option<usize>,
873 #[serde(skip_serializing_if = "Option::is_none")]
874 cost_usd: Option<f64>,
875 #[serde(skip_serializing_if = "Option::is_none")]
876 elapsed_ms: Option<u64>,
877 #[serde(skip_serializing_if = "Option::is_none")]
878 error: Option<String>,
879 index: usize,
880 total: usize,
881}
882
883#[derive(Debug, Serialize)]
884struct EnrichSummary {
885 summary: bool,
886 operation: String,
887 items_total: usize,
888 completed: usize,
889 failed: usize,
890 skipped: usize,
891 cost_usd: f64,
892 elapsed_ms: u64,
893 #[serde(skip_serializing_if = "Option::is_none")]
898 backend_invoked: Option<&'static str>,
899 waiting: i64,
903 dead: i64,
906}
907
908use crate::output::emit_json_line as emit_json;
909
910enum PreflightOutcome {
924 Healthy,
926 RateLimited {
930 reason: String,
931 suggestion: &'static str,
932 },
933 Error(AppError),
935}
936
937fn run_preflight_probe(args: &EnrichArgs) -> PreflightOutcome {
945 let timeout = std::time::Duration::from_secs(args.rate_limit_buffer.max(60));
946
947 match args.mode() {
948 EnrichMode::ClaudeCode => {
949 let bin = match find_claude_binary(args.claude_binary.as_deref()) {
950 Ok(b) => b,
951 Err(e) => return PreflightOutcome::Error(e),
952 };
953 let mcp_config_path = match crate::spawn::preflight::write_empty_mcp_config_tempfile() {
958 Ok(p) => p,
959 Err(e) => {
960 return PreflightOutcome::Error(AppError::Io(e));
961 }
962 };
963 let mut cmd = std::process::Command::new(&bin);
964 crate::spawn::env_whitelist::apply_env_whitelist(
965 &mut cmd,
966 crate::spawn::env_whitelist::is_strict_env_clear(),
967 );
968 if let Err(e) = crate::spawn::apply_cwd_isolation(&mut cmd) {
969 return PreflightOutcome::Error(e);
970 }
971 cmd.arg("-p")
972 .arg("ping")
973 .arg("--max-turns")
974 .arg("1")
975 .arg("--strict-mcp-config")
976 .arg("--mcp-config")
977 .arg(mcp_config_path.as_os_str())
978 .arg("--dangerously-skip-permissions")
979 .arg("--settings")
980 .arg("{\"hooks\":{}}")
981 .arg("--output-format")
982 .arg("json")
983 .stdin(std::process::Stdio::null())
984 .stdout(std::process::Stdio::piped())
985 .stderr(std::process::Stdio::piped());
986
987 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
988 Ok(c) => c,
989 Err(e) => {
990 return PreflightOutcome::Error(AppError::Io(e));
991 }
992 };
993 let output = match wait_with_timeout(child, timeout) {
994 Ok(out) => out,
995 Err(e) => return PreflightOutcome::Error(e),
996 };
997 if !output.status.success() {
998 let stderr = String::from_utf8_lossy(&output.stderr);
999 if stderr.contains("hit your session limit")
1000 || stderr.contains("rate_limit")
1001 || stderr.contains("429")
1002 {
1003 return PreflightOutcome::RateLimited {
1004 reason: stderr.trim().to_string(),
1005 suggestion:
1006 "wait for the OAuth window to reset or use --fallback-mode codex",
1007 };
1008 }
1009 return PreflightOutcome::Error(AppError::Validation(format!(
1010 "preflight probe failed: {stderr}",
1011 stderr = stderr.trim()
1012 )));
1013 }
1014 PreflightOutcome::Healthy
1015 }
1016 EnrichMode::Codex => {
1017 let bin = match find_codex_binary(args.codex_binary.as_deref()) {
1018 Ok(b) => b,
1019 Err(e) => return PreflightOutcome::Error(e),
1020 };
1021 super::codex_spawn::validate_codex_model(args.codex_model.as_deref())
1022 .map_err(PreflightOutcome::Error)
1023 .ok();
1024 let schema = "{}";
1025 let schema_path = match super::codex_spawn::trusted_schema_path() {
1026 Ok(p) => p,
1027 Err(e) => return PreflightOutcome::Error(e),
1028 };
1029 let spawn_args = super::codex_spawn::CodexSpawnArgs {
1030 binary: &bin,
1031 prompt: "ping",
1032 json_schema: schema,
1033 input_text: "",
1034 model: args.codex_model.as_deref(),
1035 timeout_secs: args.rate_limit_buffer.max(60),
1036 schema_path: schema_path.clone(),
1037 };
1038 let mut cmd = match super::codex_spawn::build_codex_command(&spawn_args) {
1039 Ok(c) => c,
1040 Err(e) => return PreflightOutcome::Error(e),
1041 };
1042 let child = match super::claude_runner::spawn_with_memory_limit(&mut cmd) {
1043 Ok(c) => c,
1044 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1045 };
1046 let output = match wait_with_timeout(child, timeout) {
1047 Ok(out) => out,
1048 Err(e) => return PreflightOutcome::Error(e),
1049 };
1050 let _ = std::fs::remove_file(&schema_path);
1051 if !output.status.success() {
1052 let stderr = String::from_utf8_lossy(&output.stderr);
1053 if stderr.contains("rate_limit")
1054 || stderr.contains("429")
1055 || stderr.contains("Too Many Requests")
1056 {
1057 return PreflightOutcome::RateLimited {
1058 reason: stderr.trim().to_string(),
1059 suggestion: "wait for the rate-limit window to reset",
1060 };
1061 }
1062 return PreflightOutcome::Error(AppError::Validation(format!(
1063 "preflight probe failed: {stderr}",
1064 stderr = stderr.trim()
1065 )));
1066 }
1067 PreflightOutcome::Healthy
1068 }
1069 EnrichMode::Opencode => {
1070 let bin = match super::opencode_runner::find_opencode_binary_with_override(
1071 args.opencode_binary.as_deref(),
1072 ) {
1073 Ok(b) => b,
1074 Err(e) => return PreflightOutcome::Error(e),
1075 };
1076 let model =
1077 super::opencode_runner::resolve_opencode_model(args.opencode_model.as_deref());
1078 let mut cmd =
1079 match super::opencode_runner::build_opencode_command_sync(&bin, &model, "ping", "")
1080 {
1081 Ok(c) => c,
1082 Err(e) => return PreflightOutcome::Error(e),
1083 };
1084 let child = match super::opencode_runner::spawn_opencode(&mut cmd) {
1085 Ok(c) => c,
1086 Err(e) => return PreflightOutcome::Error(AppError::Io(e)),
1087 };
1088 let output = match wait_with_timeout(child, timeout) {
1089 Ok(out) => out,
1090 Err(e) => return PreflightOutcome::Error(e),
1091 };
1092 if !output.status.success() {
1093 let stderr = String::from_utf8_lossy(&output.stderr);
1094 if stderr.contains("rate_limit")
1095 || stderr.contains("429")
1096 || stderr.contains("Too Many Requests")
1097 {
1098 return PreflightOutcome::RateLimited {
1099 reason: stderr.trim().to_string(),
1100 suggestion: "wait for the rate-limit window to reset",
1101 };
1102 }
1103 return PreflightOutcome::Error(AppError::Validation(format!(
1104 "preflight probe failed: {stderr}",
1105 stderr = stderr.trim()
1106 )));
1107 }
1108 PreflightOutcome::Healthy
1109 }
1110 EnrichMode::OpenRouter => {
1111 match crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref()) {
1115 Some(_) => PreflightOutcome::Healthy,
1116 None => PreflightOutcome::Error(AppError::Validation(
1117 "OPENROUTER_API_KEY not found for --mode openrouter preflight".into(),
1118 )),
1119 }
1120 }
1121 }
1122}
1123
1124fn wait_with_timeout(
1126 mut child: std::process::Child,
1127 timeout: std::time::Duration,
1128) -> Result<std::process::Output, AppError> {
1129 use wait_timeout::ChildExt;
1130 let start = std::time::Instant::now();
1131 let Some(exit) = child.wait_timeout(timeout).map_err(AppError::Io)? else {
1132 let _ = child.kill();
1133 let _ = child.wait();
1134 return Err(AppError::Validation(format!(
1135 "preflight probe timed out after {}s",
1136 start.elapsed().as_secs()
1137 )));
1138 };
1139 let mut stdout = Vec::new();
1140 if let Some(mut out) = child.stdout.take() {
1141 std::io::Read::read_to_end(&mut out, &mut stdout).map_err(AppError::Io)?;
1142 }
1143 let mut stderr = Vec::new();
1144 if let Some(mut err) = child.stderr.take() {
1145 std::io::Read::read_to_end(&mut err, &mut stderr).map_err(AppError::Io)?;
1146 }
1147 Ok(std::process::Output {
1148 status: exit,
1149 stdout,
1150 stderr,
1151 })
1152}
1153
1154fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1170 value == default
1171}
1172
1173fn validate_mode_conditional_flags_enrich(args: &EnrichArgs) -> Result<(), AppError> {
1188 const DEFAULT_TIMEOUT: u64 = 300;
1189
1190 let mut conflicts: Vec<String> = Vec::new();
1191
1192 match args.mode() {
1193 EnrichMode::ClaudeCode => {
1194 if args.codex_binary.is_some() {
1195 conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1196 }
1197 if args.codex_model.is_some() {
1198 conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1199 }
1200 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1201 conflicts.push(format!(
1202 "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1203 args.codex_timeout
1204 ));
1205 }
1206 }
1207 EnrichMode::Codex => {
1208 if args.claude_binary.is_some() {
1209 conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1210 }
1211 if args.claude_model.is_some() {
1212 conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1213 }
1214 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1215 conflicts.push(format!(
1216 "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1217 args.claude_timeout
1218 ));
1219 }
1220 if args.max_cost_usd.is_some() {
1221 conflicts.push(
1222 "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription, not the call)"
1223 .to_string(),
1224 );
1225 }
1226 }
1227 EnrichMode::Opencode => {
1228 if args.claude_binary.is_some() {
1229 conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1230 }
1231 if args.claude_model.is_some() {
1232 conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1233 }
1234 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1235 conflicts.push(format!(
1236 "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1237 args.claude_timeout
1238 ));
1239 }
1240 if args.max_cost_usd.is_some() {
1241 conflicts.push(
1242 "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription, not the call)"
1243 .to_string(),
1244 );
1245 }
1246 }
1247 EnrichMode::OpenRouter => {
1248 if args.claude_binary.is_some() {
1249 conflicts.push("--claude-binary is ignored when --mode=openrouter".to_string());
1250 }
1251 if args.claude_model.is_some() {
1252 conflicts.push("--claude-model is ignored when --mode=openrouter".to_string());
1253 }
1254 if args.codex_binary.is_some() {
1255 conflicts.push("--codex-binary is ignored when --mode=openrouter".to_string());
1256 }
1257 if args.codex_model.is_some() {
1258 conflicts.push("--codex-model is ignored when --mode=openrouter".to_string());
1259 }
1260 if args.opencode_binary.is_some() {
1261 conflicts.push("--opencode-binary is ignored when --mode=openrouter".to_string());
1262 }
1263 if args.opencode_model.is_some() {
1264 conflicts.push("--opencode-model is ignored when --mode=openrouter".to_string());
1265 }
1266 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1267 conflicts.push(format!(
1268 "--claude-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1269 args.claude_timeout
1270 ));
1271 }
1272 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1273 conflicts.push(format!(
1274 "--codex-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1275 args.codex_timeout
1276 ));
1277 }
1278 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1279 conflicts.push(format!(
1280 "--opencode-timeout={} is ignored when --mode=openrouter (remove the flag to use the default 300s)",
1281 args.opencode_timeout
1282 ));
1283 }
1284 }
1285 }
1286
1287 if !conflicts.is_empty() {
1288 return Err(AppError::Validation(format!(
1289 "G20: mode-conditional flag conflicts detected for --mode={}:\n - {}",
1290 args.mode(),
1291 conflicts.join("\n - ")
1292 )));
1293 }
1294
1295 Ok(())
1296}
1297
1298pub fn run(
1302 args: &EnrichArgs,
1303 llm_backend: crate::cli::LlmBackendChoice,
1304 embedding_backend: crate::cli::EmbeddingBackendChoice,
1305) -> Result<(), AppError> {
1306 validate_mode_conditional_flags_enrich(args)?;
1309
1310 if args.target != ReEmbedTarget::Memories
1313 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1314 {
1315 let target_label = match args.target {
1316 ReEmbedTarget::Memories => "memories",
1317 ReEmbedTarget::Entities => "entities",
1318 ReEmbedTarget::Chunks => "chunks",
1319 ReEmbedTarget::All => "all",
1320 };
1321 return Err(AppError::Validation(format!(
1322 "--target {target_label} only applies to --operation re-embed"
1323 )));
1324 }
1325
1326 if args.list_dead
1335 || args.requeue_dead
1336 || args.prune_dead_orphans
1337 || args.prune_dead_entity_orphans
1338 {
1339 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1340 let op_label = format!("{:?}", args.operation());
1341 let paths = AppPaths::resolve(args.db.as_deref())?;
1342 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1343 let queue_conn = open_queue_db(&queue_path)?;
1344 if args.prune_dead_entity_orphans {
1349 let pruned = prune_dead_entity_orphans(&queue_conn, &op_label)?;
1350 let dead_total: i64 = queue_conn
1351 .query_row(
1352 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1353 AND item_type='entity' \
1354 AND (operation = ?1 OR operation IS NULL)",
1355 rusqlite::params![op_label],
1356 |r| r.get(0),
1357 )
1358 .unwrap_or(0);
1359 emit_json(&DeadSummary {
1360 summary: true,
1361 operation: op_label,
1362 namespace,
1363 action: "prune-dead-entity-orphans",
1364 dead_total,
1365 requeued: 0,
1366 pruned,
1367 });
1368 return Ok(());
1369 }
1370 if args.prune_dead_orphans {
1373 ensure_db_ready(&paths)?;
1374 let main_conn = open_rw(&paths.db)?;
1375 let pruned = prune_dead_orphans(&queue_conn, &main_conn, &op_label, &namespace)?;
1376 let dead_total: i64 = queue_conn
1377 .query_row(
1378 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1379 AND (operation = ?1 OR operation IS NULL)",
1380 rusqlite::params![op_label],
1381 |r| r.get(0),
1382 )
1383 .unwrap_or(0);
1384 emit_json(&DeadSummary {
1385 summary: true,
1386 operation: op_label,
1387 namespace,
1388 action: "prune-dead-orphans",
1389 dead_total,
1390 requeued: 0,
1391 pruned,
1392 });
1393 return Ok(());
1394 }
1395 if args.list_dead {
1396 let mut stmt = queue_conn.prepare(
1397 "SELECT item_key, item_type, attempt, error_class, error, \
1398 finish_reason, input_tokens, output_tokens FROM queue \
1399 WHERE status='dead' AND (operation = ?1 OR operation IS NULL) ORDER BY id",
1400 )?;
1401 let rows = stmt
1402 .query_map(rusqlite::params![op_label], |r| {
1403 Ok(DeadItem {
1404 dead_item: true,
1405 item_key: r.get(0)?,
1406 item_type: r.get(1)?,
1407 attempt: r.get(2)?,
1408 error_class: r.get(3)?,
1409 error: r.get(4)?,
1410 finish_reason: r.get(5)?,
1411 input_tokens: r.get(6)?,
1412 output_tokens: r.get(7)?,
1413 })
1414 })?
1415 .collect::<Result<Vec<_>, _>>()?;
1416 let dead_total = rows.len() as i64;
1417 for item in &rows {
1418 emit_json(item);
1419 }
1420 emit_json(&DeadSummary {
1421 summary: true,
1422 operation: op_label,
1423 namespace,
1424 action: "list-dead",
1425 dead_total,
1426 requeued: 0,
1427 pruned: 0,
1428 });
1429 return Ok(());
1430 }
1431 let dead_total: i64 = queue_conn
1433 .query_row(
1434 "SELECT COUNT(*) FROM queue WHERE status='dead' \
1435 AND (operation = ?1 OR operation IS NULL)",
1436 rusqlite::params![op_label],
1437 |r| r.get(0),
1438 )
1439 .unwrap_or(0);
1440 let requeued = queue_conn
1441 .execute(
1442 "UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
1443 error=NULL, error_class=NULL \
1444 WHERE status='dead' AND (operation = ?1 OR operation IS NULL)",
1445 rusqlite::params![op_label],
1446 )
1447 .map_err(|e| AppError::Validation(format!("requeue-dead failed: {e}")))?
1448 as i64;
1449 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1450 emit_json(&DeadSummary {
1451 summary: true,
1452 operation: op_label,
1453 namespace,
1454 action: "requeue-dead",
1455 dead_total,
1456 requeued,
1457 pruned: 0,
1458 });
1459 return Ok(());
1460 }
1461
1462 if args.status {
1463 let paths = AppPaths::resolve(args.db.as_deref())?;
1464 ensure_db_ready(&paths)?;
1465 let conn = open_rw(&paths.db)?;
1466 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1467 let unbound_backlog = scan_unbound_memories(&conn, &namespace, None, &[])?.len();
1468 let scan_backlog =
1471 count_operation_backlog(&conn, &args.operation(), &namespace, args.target)?;
1472 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1473 let queue_conn = open_queue_db(&queue_path)?;
1474 let op_label = format!("{:?}", args.operation());
1475 let count_status = |st: &str, op: &str| -> i64 {
1479 queue_conn
1480 .query_row(
1481 "SELECT COUNT(*) FROM queue WHERE status=?1 \
1482 AND (operation = ?2 OR operation IS NULL)",
1483 rusqlite::params![st, op],
1484 |r| r.get(0),
1485 )
1486 .unwrap_or(0)
1487 };
1488 let eligible_now: i64 = queue_conn
1489 .query_row(
1490 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1491 AND (operation = ?1 OR operation IS NULL) \
1492 AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))",
1493 rusqlite::params![op_label],
1494 |r| r.get(0),
1495 )
1496 .unwrap_or(0);
1497 let waiting: i64 = queue_conn
1498 .query_row(
1499 "SELECT COUNT(*) FROM queue WHERE status='pending' \
1500 AND (operation = ?1 OR operation IS NULL) \
1501 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
1502 rusqlite::params![op_label],
1503 |r| r.get(0),
1504 )
1505 .unwrap_or(0);
1506 let waiting_items = {
1508 let mut stmt = queue_conn.prepare(
1509 "SELECT item_key, attempt, next_retry_at, error_class FROM queue \
1510 WHERE status='pending' AND (operation = ?1 OR operation IS NULL) \
1511 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now') \
1512 ORDER BY next_retry_at",
1513 )?;
1514 let items: Vec<WaitingItem> = stmt
1515 .query_map(rusqlite::params![op_label], |r| {
1516 Ok(WaitingItem {
1517 item_key: r.get(0)?,
1518 attempt: r.get(1)?,
1519 next_retry_at: r.get(2)?,
1520 error_class: r.get(3)?,
1521 })
1522 })?
1523 .collect::<Result<Vec<_>, _>>()?;
1524 items
1525 };
1526 let queue_pending = count_status("pending", &op_label);
1527 let queue_processing = count_status("processing", &op_label);
1528 let queue_done = count_status("done", &op_label);
1529 let queue_failed = count_status("failed", &op_label);
1530 let queue_skipped = count_status("skipped", &op_label);
1531 let queue_dead = count_status("dead", &op_label);
1532 let state = if eligible_now > 0 {
1534 "draining"
1535 } else if waiting > 0 {
1536 "cooldown"
1537 } else if queue_pending == 0 && scan_backlog > 0 {
1538 "pending-scan"
1539 } else {
1540 "empty"
1541 };
1542 emit_json(&EnrichStatus {
1543 status_report: true,
1544 operation: op_label,
1545 namespace,
1546 unbound_backlog,
1547 scan_backlog,
1548 queue_pending,
1549 queue_processing,
1550 queue_done,
1551 queue_failed,
1552 queue_skipped,
1553 queue_dead,
1554 eligible_now,
1555 waiting,
1556 state,
1557 waiting_items,
1558 });
1559 return Ok(());
1560 }
1561
1562 if args.reset_stale_claims {
1567 let paths = AppPaths::resolve(args.db.as_deref())?;
1568 ensure_db_ready(&paths)?;
1569 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1570 let queue_conn = open_queue_db(&queue_path)?;
1571 let reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
1572 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1573 tracing::info!(
1574 target: "enrich",
1575 reset,
1576 max_age_secs = args.stale_claim_secs,
1577 "reset stale processing claims"
1578 );
1579 emit_json(&serde_json::json!({
1580 "reset_stale_claims": true,
1581 "reset": reset,
1582 "max_age_secs": args.stale_claim_secs,
1583 }));
1584 return Ok(());
1585 }
1586
1587 if args.mode() == EnrichMode::OpenRouter {
1592 let model = args.openrouter_model.as_deref().ok_or_else(|| {
1593 AppError::Validation(
1594 "--mode openrouter requires --openrouter-model (no default model is allowed)"
1595 .into(),
1596 )
1597 })?;
1598 let resolved =
1599 crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref())
1600 .ok_or_else(|| {
1601 AppError::Validation(
1602 "OPENROUTER_API_KEY not found; set the env var, store it via \
1603 `config add-key --provider openrouter`, or pass --openrouter-api-key"
1604 .into(),
1605 )
1606 })?;
1607 crate::embedder::get_openrouter_chat_client(
1608 resolved.value,
1609 model,
1610 args.openrouter_timeout,
1611 )?;
1612 }
1613
1614 let started = Instant::now();
1615
1616 let paths = AppPaths::resolve(args.db.as_deref())?;
1617 ensure_db_ready(&paths)?;
1618 let conn = open_rw(&paths.db)?;
1619 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1620
1621 let wait_secs = args.wait_job_singleton;
1627 let force_flag = args.force_job_singleton;
1628 let _singleton = crate::lock::acquire_job_singleton(
1629 crate::lock::JobType::Enrich,
1630 &namespace,
1631 &paths.db,
1632 wait_secs,
1633 force_flag,
1634 )?;
1635
1636 let provider_binary = if matches!(args.operation(), EnrichOperation::ReEmbed) {
1638 None
1639 } else {
1640 Some(match args.mode() {
1641 EnrichMode::ClaudeCode => {
1642 let bin = find_claude_binary(args.claude_binary.as_deref())?;
1643 let version = super::claude_runner::validate_claude_version(&bin)?;
1644 tracing::info!(target: "enrich", binary = %bin.display(), version = %version, "Claude Code binary validated");
1645 emit_json(&PhaseEvent {
1646 phase: "validate",
1647 binary_path: bin.to_str(),
1648 version: Some(&version),
1649 items_total: None,
1650 items_pending: None,
1651 llm_parallelism: None,
1652 });
1653 bin
1654 }
1655 EnrichMode::Codex => {
1656 let bin = find_codex_binary(args.codex_binary.as_deref())?;
1657 emit_json(&PhaseEvent {
1658 phase: "validate",
1659 binary_path: bin.to_str(),
1660 version: None,
1661 items_total: None,
1662 items_pending: None,
1663 llm_parallelism: None,
1664 });
1665 bin
1666 }
1667 EnrichMode::Opencode => {
1668 let bin = super::opencode_runner::find_opencode_binary_with_override(
1669 args.opencode_binary.as_deref(),
1670 )?;
1671 emit_json(&PhaseEvent {
1672 phase: "validate",
1673 binary_path: bin.to_str(),
1674 version: None,
1675 items_total: None,
1676 items_pending: None,
1677 llm_parallelism: None,
1678 });
1679 bin
1680 }
1681 EnrichMode::OpenRouter => {
1682 emit_json(&PhaseEvent {
1687 phase: "validate",
1688 binary_path: None,
1689 version: None,
1690 items_total: None,
1691 items_pending: None,
1692 llm_parallelism: None,
1693 });
1694 PathBuf::new()
1695 }
1696 })
1697 };
1698
1699 if args.max_load_check && !args.dry_run && crate::system_load::is_system_saturated() {
1703 let load = crate::system_load::load_average_one();
1704 let n = crate::system_load::ncpus();
1705 return Err(AppError::Validation(format!(
1706 "system load average {load:.2} exceeds 2x ncpus ({n}); \
1707 pass --no-max-load-check to override (not recommended)"
1708 )));
1709 }
1710
1711 if args.preflight_check
1718 && !args.dry_run
1719 && !matches!(args.operation(), EnrichOperation::ReEmbed)
1720 {
1721 let preflight_result = run_preflight_probe(args);
1722 match preflight_result {
1723 PreflightOutcome::Healthy => {
1724 tracing::info!(target: "enrich", mode = ?args.mode(), "preflight probe healthy");
1725 }
1726 PreflightOutcome::RateLimited { reason, suggestion } => {
1727 if let Some(fallback) = args.fallback_mode.clone() {
1728 if fallback != args.mode() {
1729 return Err(AppError::Validation(format!(
1739 "preflight detected rate limit on {mode:?}: {reason}; \
1740 re-invoke with `--mode {fallback:?}` to use the fallback provider",
1741 mode = args.mode()
1742 )));
1743 }
1744 return Err(AppError::Validation(format!(
1745 "preflight detected rate limit on {mode:?}: {reason}; \
1746 --fallback-mode matches --mode, no recovery possible",
1747 mode = args.mode()
1748 )));
1749 }
1750 return Err(AppError::Validation(format!(
1751 "preflight detected rate limit on {mode:?}: {reason}; \
1752 {suggestion}; pass --fallback-mode codex to recover",
1753 mode = args.mode()
1754 )));
1755 }
1756 PreflightOutcome::Error(e) => {
1757 return Err(e);
1758 }
1759 }
1760 }
1761
1762 let mut scan_result = scan_operation(&conn, &namespace, args)?;
1764 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
1773 let q_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1774 if let Ok(q) = open_queue_db(&q_path) {
1775 if let Ok(vetoed) = skipped_item_keys(&q, &format!("{:?}", args.operation())) {
1776 scan_result.retain(|k| !vetoed.contains(k));
1777 }
1778 }
1779 }
1780 let total = scan_result.len();
1781
1782 emit_json(&PhaseEvent {
1783 phase: "scan",
1784 binary_path: None,
1785 version: None,
1786 items_total: Some(total),
1787 items_pending: Some(total),
1788 llm_parallelism: Some(args.llm_parallelism),
1789 });
1790
1791 if args.dry_run {
1793 for (idx, key) in scan_result.iter().enumerate() {
1794 emit_json(&ItemEvent {
1795 item: key,
1796 status: "preview",
1797 memory_id: None,
1798 entity_id: None,
1799 entities: None,
1800 rels: None,
1801 chars_before: None,
1802 chars_after: None,
1803 cost_usd: None,
1804 elapsed_ms: None,
1805 error: None,
1806 index: idx,
1807 total,
1808 });
1809 }
1810 emit_json(&EnrichSummary {
1811 summary: true,
1812 operation: format!("{:?}", args.operation()),
1813 items_total: total,
1814 completed: 0,
1815 failed: 0,
1816 skipped: 0,
1817 cost_usd: 0.0,
1818 elapsed_ms: started.elapsed().as_millis() as u64,
1819 backend_invoked: take_enrich_backend(),
1820 waiting: 0,
1821 dead: 0,
1822 });
1823 return Ok(());
1824 }
1825
1826 let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1832 let mut queue_conn = open_queue_db(&queue_path)?;
1833
1834 {
1839 let stale_reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
1840 if stale_reset > 0 {
1841 tracing::info!(
1842 target: "enrich",
1843 count = stale_reset,
1844 max_age_secs = args.stale_claim_secs,
1845 "reset stale processing claims (older than threshold)"
1846 );
1847 }
1848 }
1849
1850 if args.resume {
1851 let reset = queue_conn
1852 .execute(
1853 "UPDATE queue SET status='pending' WHERE status='processing'",
1854 [],
1855 )
1856 .map_err(|e| AppError::Validation(format!("queue resume failed: {e}")))?;
1857 if reset > 0 {
1858 tracing::info!(target: "enrich", count = reset, "reset stuck processing items to pending");
1859 }
1860 }
1861
1862 if args.retry_failed {
1863 let count = queue_conn
1864 .execute(
1865 "UPDATE queue SET status='pending', attempt=0 WHERE status='failed'",
1866 [],
1867 )
1868 .map_err(|e| AppError::Validation(format!("queue retry-failed reset failed: {e}")))?;
1869 tracing::info!(target: "enrich", count, "retrying failed items");
1870 }
1871
1872 if !args.resume && !args.retry_failed && !args.until_empty {
1873 queue_conn
1874 .execute("DELETE FROM queue", [])
1875 .map_err(|e| AppError::Validation(format!("queue clear failed: {e}")))?;
1876 }
1877
1878 let op_label = format!("{:?}", args.operation());
1884 let item_type = item_type_for(&args.operation());
1885 {
1886 let tx = queue_conn.transaction()?;
1887 let tx_conn: &Connection = &tx;
1890 for key in scan_result.iter() {
1891 let it = item_type_for_key(key, item_type);
1895 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
1896 }
1897 tx.commit()?;
1898 }
1899
1900 let parallelism = if args.mode() == EnrichMode::OpenRouter {
1903 let rest = args.rest_concurrency.unwrap_or(8).clamp(1, 16) as usize;
1904 tracing::info!(
1905 target: "enrich",
1906 concurrency = rest,
1907 source = "rest_concurrency",
1908 "OpenRouter REST concurrency (clamp 1..=16)"
1909 );
1910 rest
1911 } else {
1912 let p = args.llm_parallelism.clamp(1, 32) as usize;
1913 tracing::info!(
1914 target: "enrich",
1915 concurrency = p,
1916 source = "llm_parallelism",
1917 "LLM subprocess parallelism (clamp 1..=32)"
1918 );
1919 p
1920 };
1921 if parallelism > 1 {
1922 tracing::info!(
1923 target: "enrich",
1924 llm_parallelism = parallelism,
1925 "parallel LLM processing with bounded thread pool"
1926 );
1927 }
1928 if parallelism > 4 {
1932 match args.mode() {
1933 EnrichMode::ClaudeCode => {
1934 tracing::warn!(
1935 target: "enrich",
1936 llm_parallelism = parallelism,
1937 recommended_max = 4,
1938 mode = "claude-code",
1939 "llm_parallelism above 4 multiplies Claude Code subprocess fan-out; \
1940 consider combining with SQLITE_GRAPHRAG_CLAUDE_EMPTY_CONFIG_DIR \
1941 to cut MCP children (G28-A)"
1942 );
1943 }
1944 EnrichMode::Codex if parallelism > 16 => {
1945 tracing::warn!(
1946 target: "enrich",
1947 llm_parallelism = parallelism,
1948 recommended_max = 16,
1949 mode = "codex",
1950 "llm_parallelism above 16 risks OAuth rate-limit on Codex; \
1951 consider --llm-parallelism 8 for safer concurrency"
1952 );
1953 }
1954 EnrichMode::Codex => {
1955 }
1959 EnrichMode::Opencode if parallelism > 16 => {
1960 tracing::warn!(
1961 target: "enrich",
1962 llm_parallelism = parallelism,
1963 recommended_max = 16,
1964 mode = "opencode",
1965 "llm_parallelism above 16 risks OAuth rate-limit on OpenCode; \
1966 consider --llm-parallelism 8 for safer concurrency"
1967 );
1968 }
1969 EnrichMode::Opencode => {
1970 }
1972 EnrichMode::OpenRouter => {
1973 }
1976 }
1977 }
1978
1979 let mut completed = 0usize;
1980 let mut failed = 0usize;
1981 let mut skipped = 0usize;
1982 let mut cost_total = 0.0f64;
1983 let mut oauth_detected = false;
1984 let mut backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
1985 let rate_limit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
1986 let enrich_started = std::time::Instant::now();
1987
1988 let provider_timeout = match args.mode() {
1989 EnrichMode::ClaudeCode => args.claude_timeout,
1990 EnrichMode::Codex => args.codex_timeout,
1991 EnrichMode::Opencode => args.opencode_timeout,
1992 EnrichMode::OpenRouter => args.openrouter_timeout,
1993 };
1994
1995 let provider_model: Option<&str> = match args.mode() {
1996 EnrichMode::ClaudeCode => args.claude_model.as_deref(),
1997 EnrichMode::Codex => args.codex_model.as_deref(),
1998 EnrichMode::Opencode => args.opencode_model.as_deref(),
1999 EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
2000 };
2001
2002 let backoff_clause: &str = if args.ignore_backoff {
2006 ""
2007 } else {
2008 "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
2009 };
2010
2011 emit_json(&ConcurrencyEvent {
2014 phase: "concurrency",
2015 scan_parallelism: 1,
2016 drain_parallelism: parallelism as u32,
2017 });
2018
2019 let until_deadline = std::time::Instant::now()
2023 + std::time::Duration::from_secs(args.max_runtime.unwrap_or(3600));
2024 loop {
2025 if args.until_empty {
2026 let mut rescan = scan_operation(&conn, &namespace, args)?;
2030 if matches!(args.operation(), EnrichOperation::BodyEnrich) {
2035 if let Ok(vetoed) = skipped_item_keys(&queue_conn, &op_label) {
2036 rescan.retain(|k| !vetoed.contains(k));
2037 }
2038 }
2039 {
2041 let tx = queue_conn.transaction()?;
2042 let tx_conn: &Connection = &tx;
2043 for key in &rescan {
2044 let it = item_type_for_key(key, item_type);
2045 enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
2046 }
2047 tx.commit()?;
2048 }
2049 }
2050 let completed_before = completed;
2051
2052 if parallelism > 1 {
2056 let stdout_mu = parking_lot::Mutex::new(());
2057 let budget = args.max_cost_usd;
2058 let operation = args.operation().clone();
2059 let mode = args.mode().clone();
2060 let min_oc = args.min_output_chars;
2061 let max_oc = args.max_output_chars;
2062 let prompt_tpl = args.prompt_template.as_deref().map(|p| p.to_path_buf());
2063
2064 struct WorkerResult {
2065 completed: usize,
2066 failed: usize,
2067 skipped: usize,
2068 cost: f64,
2069 oauth: bool,
2070 db_busy: bool,
2075 }
2076
2077 let results: Vec<WorkerResult> = std::thread::scope(|s| {
2078 let handles: Vec<_> = (0..parallelism)
2079 .map(|worker_id| {
2080 let stdout_mu = &stdout_mu;
2081 let paths = &paths;
2082 let queue_path = &queue_path;
2083 let namespace = &namespace;
2084 let provider_binary = provider_binary.as_deref();
2085 let operation = &operation;
2086 let mode = &mode;
2087 let prompt_tpl = prompt_tpl.as_deref();
2088 s.spawn(move || {
2089 let w_conn = match open_rw(&paths.db) {
2090 Ok(c) => c,
2091 Err(e) => {
2092 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open DB");
2093 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2094 }
2095 };
2096 let w_queue = match open_queue_db(queue_path) {
2097 Ok(c) => c,
2098 Err(e) => {
2099 tracing::error!(target: "enrich", worker = worker_id, error = %e, "worker failed to open queue DB");
2100 return WorkerResult { completed: 0, failed: 0, skipped: 0, cost: 0.0, oauth: false, db_busy: false };
2101 }
2102 };
2103 let mut w_completed = 0usize;
2104 let mut w_failed = 0usize;
2105 let mut w_skipped = 0usize;
2106 let mut w_cost = 0.0f64;
2107 let mut w_oauth = false;
2108 let mut w_db_busy = false;
2109 let mut w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2110 let w_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
2111 let mut w_breaker = crate::retry::CircuitBreaker::new(
2117 args.circuit_breaker_threshold.max(1),
2118 std::time::Duration::from_secs(60),
2119 );
2120
2121 loop {
2122 if crate::shutdown_requested() {
2123 tracing::info!(target: "enrich", "shutdown requested, worker stopping");
2124 break;
2125 }
2126 if let Some(b) = budget {
2127 if !w_oauth && w_cost >= b {
2128 break;
2129 }
2130 }
2131 let pending = match crate::storage::utils::with_busy_retry(|| {
2150 dequeue_next_pending(&w_queue, backoff_clause)
2151 }) {
2152 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2153 Ok(DequeueOutcome::Empty) => None,
2154 Err(AppError::DbBusy(msg)) => {
2155 tracing::error!(target: "enrich", worker = worker_id, error = %msg, "SQLITE_BUSY exhausted bounded retries, worker aborting");
2156 w_db_busy = true;
2157 None
2158 }
2159 Err(e) => {
2160 tracing::error!(target: "enrich", worker = worker_id, error = %e, "dequeue failed");
2161 None
2162 }
2163 };
2164 let (queue_id, item_key, _item_type, attempt_current) = match pending {
2165 Some(p) => p,
2166 None => break,
2167 };
2168 let _ = heartbeat(&w_queue, queue_id);
2173 let item_started = Instant::now();
2174 let current_index = w_completed + w_failed + w_skipped;
2175
2176 let provider_bin = provider_binary.unwrap_or_else(|| std::path::Path::new(""));
2182 let call_result = match operation {
2183 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => call_memory_bindings(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2184 EnrichOperation::EntityDescriptions => call_entity_description(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2185 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),
2186 EnrichOperation::ReEmbed => call_reembed(&w_conn, namespace, &item_key, paths, llm_backend, embedding_backend),
2187 EnrichOperation::WeightCalibrate => call_weight_calibrate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2188 EnrichOperation::RelationReclassify => call_relation_reclassify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2189 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => call_entity_connect(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2190 EnrichOperation::EntityTypeValidate => call_entity_type_validate(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2191 EnrichOperation::DescriptionEnrich => call_description_enrich(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2192 EnrichOperation::DomainClassify => call_domain_classify(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2193 EnrichOperation::GraphAudit => call_graph_audit(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2194 EnrichOperation::DeepResearchSynth => call_deep_research_synth(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode),
2195 EnrichOperation::BodyExtract => call_body_extract(&w_conn, namespace, &item_key, provider_bin, provider_model, provider_timeout, mode, args.body_extract_graph_only),
2196 };
2197 let openrouter_diag = take_last_openrouter_failure();
2203
2204 match call_result {
2205 Ok(EnrichItemResult::Done { cost, is_oauth, memory_id, entity_id, entities, rels, chars_before, chars_after }) => {
2206 if is_oauth { w_oauth = true; }
2207 w_backoff = DEFAULT_RATE_LIMIT_WAIT;
2208 let _ = w_queue.execute(
2209 "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",
2210 rusqlite::params![memory_id, entity_id, entities as i64, rels as i64, cost, item_started.elapsed().as_millis() as i64, queue_id],
2211 );
2212 w_completed += 1;
2213 if !is_oauth { w_cost += cost; }
2214 let _ = w_breaker
2216 .record(crate::retry::AttemptOutcome::Success);
2217 let _guard = stdout_mu.lock();
2218 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 });
2219 }
2220 Ok(EnrichItemResult::Skipped { reason }) => {
2221 w_skipped += 1;
2222 let _ = w_queue.execute("UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2", rusqlite::params![reason, queue_id]);
2223 let _guard = stdout_mu.lock();
2224 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 });
2225 }
2226 Ok(EnrichItemResult::PreservationFailed { score, threshold, chars_before, chars_after }) => {
2227 w_skipped += 1;
2233 let reason = format!(
2234 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2235 );
2236 let _ = w_queue.execute(
2237 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2238 rusqlite::params![reason, queue_id],
2239 );
2240 let _guard = stdout_mu.lock();
2241 emit_json(&ItemEvent {
2242 item: &item_key,
2243 status: "preservation_failed",
2244 memory_id: None,
2245 entity_id: None,
2246 entities: None,
2247 rels: None,
2248 chars_before: Some(chars_before),
2249 chars_after: Some(chars_after),
2250 cost_usd: None,
2251 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2252 error: Some(reason),
2253 index: current_index,
2254 total,
2255 });
2256 }
2257 Err(e) => {
2258 let err_str = format!("{e}");
2259 if matches!(e, AppError::RateLimited { .. }) {
2260 if crate::retry::is_kill_switch_active() {
2261 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2262 } else if std::time::Instant::now() >= w_deadline {
2263 tracing::error!(target: "enrich", "rate-limit retry deadline (1h) exhausted in worker");
2264 } else {
2265 let half = w_backoff / 2;
2266 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2267 let actual_wait = half + jitter;
2268 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited in worker, backing off");
2269 let _ = w_queue.execute("UPDATE queue SET status='pending' WHERE id=?1", rusqlite::params![queue_id]);
2270 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2271 w_backoff = (w_backoff * 2).min(900);
2272 continue;
2273 }
2274 }
2275 w_failed += 1;
2276 let outcome = match openrouter_diag {
2283 Some(diag) => record_item_failure_typed(
2284 &w_queue,
2285 queue_id,
2286 attempt_current,
2287 args.max_attempts,
2288 diag.retry_class,
2289 &err_str,
2290 diag.finish_reason.as_deref(),
2291 diag.prompt_tokens,
2292 diag.completion_tokens,
2293 ),
2294 None => record_item_failure(&w_queue, queue_id, attempt_current, args.max_attempts, &e),
2295 };
2296 let _guard = stdout_mu.lock();
2297 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 });
2298 let breaker_opened = w_breaker.record(outcome);
2301 if breaker_opened {
2302 tracing::error!(target: "enrich",
2303 consecutive_failures = w_breaker.consecutive_failures(),
2304 "circuit breaker opened — aborting worker"
2305 );
2306 break;
2307 }
2308 }
2309 }
2310 }
2311 WorkerResult { completed: w_completed, failed: w_failed, skipped: w_skipped, cost: w_cost, oauth: w_oauth, db_busy: w_db_busy }
2312 })
2313 })
2314 .collect();
2315 handles
2316 .into_iter()
2317 .map(|h| {
2318 h.join().unwrap_or(WorkerResult {
2319 completed: 0,
2320 failed: 0,
2321 skipped: 0,
2322 cost: 0.0,
2323 oauth: false,
2324 db_busy: false,
2325 })
2326 })
2327 .collect()
2328 });
2329
2330 if results.iter().any(|r| r.db_busy) {
2336 return Err(AppError::DbBusy(
2337 "SQLITE_BUSY exhausted bounded retries while dequeuing (parallel worker)"
2338 .into(),
2339 ));
2340 }
2341
2342 for r in &results {
2343 completed += r.completed;
2344 failed += r.failed;
2345 skipped += r.skipped;
2346 cost_total += r.cost;
2347 if r.oauth && !oauth_detected {
2348 oauth_detected = true;
2349 }
2350 }
2351 } else {
2352 loop {
2354 if crate::shutdown_requested() {
2355 tracing::info!(target: "enrich", "shutdown requested, stopping enrichment");
2356 break;
2357 }
2358
2359 if let Some(budget) = args.max_cost_usd {
2361 if !oauth_detected && cost_total >= budget {
2362 tracing::warn!(target: "enrich", spent = cost_total, budget, "budget exceeded, stopping");
2363 break;
2364 }
2365 }
2366
2367 let pending = match crate::storage::utils::with_busy_retry(|| {
2383 dequeue_next_pending(&queue_conn, backoff_clause)
2384 }) {
2385 Ok(DequeueOutcome::Claimed(p)) => Some(p),
2386 Ok(DequeueOutcome::Empty) => None,
2387 Err(e @ AppError::DbBusy(_)) => {
2388 tracing::error!(target: "enrich", error = %e, "SQLITE_BUSY exhausted bounded retries, aborting drain loop");
2389 return Err(e);
2390 }
2391 Err(e) => {
2392 tracing::error!(target: "enrich", error = %e, "dequeue failed");
2393 None
2394 }
2395 };
2396
2397 let (queue_id, item_key, item_type, attempt_current) = match pending {
2398 Some(p) => p,
2399 None => break,
2400 };
2401
2402 let _ = heartbeat(&queue_conn, queue_id);
2405
2406 let item_started = Instant::now();
2407 let current_index = completed + failed + skipped;
2408
2409 let provider_bin = provider_binary
2412 .as_deref()
2413 .unwrap_or_else(|| std::path::Path::new(""));
2414 let call_result = match args.operation() {
2415 EnrichOperation::MemoryBindings | EnrichOperation::AugmentBindings => {
2416 call_memory_bindings(
2417 &conn,
2418 &namespace,
2419 &item_key,
2420 provider_bin,
2421 provider_model,
2422 provider_timeout,
2423 &args.mode(),
2424 )
2425 }
2426 EnrichOperation::EntityDescriptions => call_entity_description(
2427 &conn,
2428 &namespace,
2429 &item_key,
2430 provider_bin,
2431 provider_model,
2432 provider_timeout,
2433 &args.mode(),
2434 ),
2435 EnrichOperation::BodyEnrich => call_body_enrich(
2436 &conn,
2437 &namespace,
2438 &item_key,
2439 provider_bin,
2440 provider_model,
2441 provider_timeout,
2442 &args.mode(),
2443 args.min_output_chars,
2444 args.max_output_chars,
2445 args.prompt_template.as_deref(),
2446 args.preserve_threshold,
2447 &paths,
2448 llm_backend,
2449 embedding_backend,
2450 ),
2451 EnrichOperation::ReEmbed => call_reembed(
2452 &conn,
2453 &namespace,
2454 &item_key,
2455 &paths,
2456 llm_backend,
2457 embedding_backend,
2458 ),
2459 EnrichOperation::WeightCalibrate => call_weight_calibrate(
2460 &conn,
2461 &namespace,
2462 &item_key,
2463 provider_bin,
2464 provider_model,
2465 provider_timeout,
2466 &args.mode(),
2467 ),
2468 EnrichOperation::RelationReclassify => call_relation_reclassify(
2469 &conn,
2470 &namespace,
2471 &item_key,
2472 provider_bin,
2473 provider_model,
2474 provider_timeout,
2475 &args.mode(),
2476 ),
2477 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => {
2478 call_entity_connect(
2479 &conn,
2480 &namespace,
2481 &item_key,
2482 provider_bin,
2483 provider_model,
2484 provider_timeout,
2485 &args.mode(),
2486 )
2487 }
2488 EnrichOperation::EntityTypeValidate => call_entity_type_validate(
2489 &conn,
2490 &namespace,
2491 &item_key,
2492 provider_bin,
2493 provider_model,
2494 provider_timeout,
2495 &args.mode(),
2496 ),
2497 EnrichOperation::DescriptionEnrich => call_description_enrich(
2498 &conn,
2499 &namespace,
2500 &item_key,
2501 provider_bin,
2502 provider_model,
2503 provider_timeout,
2504 &args.mode(),
2505 ),
2506 EnrichOperation::DomainClassify => call_domain_classify(
2507 &conn,
2508 &namespace,
2509 &item_key,
2510 provider_bin,
2511 provider_model,
2512 provider_timeout,
2513 &args.mode(),
2514 ),
2515 EnrichOperation::GraphAudit => call_graph_audit(
2516 &conn,
2517 &namespace,
2518 &item_key,
2519 provider_bin,
2520 provider_model,
2521 provider_timeout,
2522 &args.mode(),
2523 ),
2524 EnrichOperation::DeepResearchSynth => call_deep_research_synth(
2525 &conn,
2526 &namespace,
2527 &item_key,
2528 provider_bin,
2529 provider_model,
2530 provider_timeout,
2531 &args.mode(),
2532 ),
2533 EnrichOperation::BodyExtract => call_body_extract(
2534 &conn,
2535 &namespace,
2536 &item_key,
2537 provider_bin,
2538 provider_model,
2539 provider_timeout,
2540 &args.mode(),
2541 args.body_extract_graph_only,
2542 ),
2543 };
2544 let openrouter_diag = take_last_openrouter_failure();
2547
2548 match call_result {
2549 Ok(EnrichItemResult::Done {
2550 memory_id,
2551 entity_id,
2552 entities,
2553 rels,
2554 chars_before,
2555 chars_after,
2556 cost,
2557 is_oauth,
2558 }) => {
2559 if is_oauth && !oauth_detected {
2560 oauth_detected = true;
2561 tracing::info!(target: "enrich", "OAuth subscription detected — cost_usd omitted from output");
2562 }
2563 backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
2564
2565 let persist_err: Option<String> = match args.operation() {
2567 EnrichOperation::MemoryBindings => {
2568 None
2570 }
2571 EnrichOperation::EntityDescriptions => {
2572 None
2574 }
2575 EnrichOperation::BodyEnrich => {
2576 None
2578 }
2579 _ => {
2580 None
2582 }
2583 };
2584
2585 if let Err(e) = queue_conn.execute(
2586 "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",
2587 rusqlite::params![
2588 memory_id,
2589 entity_id,
2590 entities as i64,
2591 rels as i64,
2592 cost,
2593 item_started.elapsed().as_millis() as i64,
2594 queue_id
2595 ],
2596 ) {
2597 tracing::warn!(target: "enrich", error = %e, "queue done update failed");
2598 }
2599
2600 if persist_err.is_none() {
2601 completed += 1;
2602 if !is_oauth {
2603 cost_total += cost;
2604 }
2605 emit_json(&ItemEvent {
2606 item: &item_key,
2607 status: "done",
2608 memory_id,
2609 entity_id,
2610 entities: Some(entities),
2611 rels: Some(rels),
2612 chars_before,
2613 chars_after,
2614 cost_usd: if is_oauth { None } else { Some(cost) },
2615 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2616 error: None,
2617 index: current_index,
2618 total,
2619 });
2620 } else {
2621 failed += 1;
2622 emit_json(&ItemEvent {
2623 item: &item_key,
2624 status: "failed",
2625 memory_id: None,
2626 entity_id: None,
2627 entities: None,
2628 rels: None,
2629 chars_before: None,
2630 chars_after: None,
2631 cost_usd: None,
2632 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2633 error: persist_err,
2634 index: current_index,
2635 total,
2636 });
2637 }
2638 }
2639 Ok(EnrichItemResult::Skipped { reason }) => {
2640 skipped += 1;
2641 if let Err(e) = queue_conn.execute(
2642 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2643 rusqlite::params![reason, queue_id],
2644 ) {
2645 tracing::warn!(target: "enrich", error = %e, "queue skipped update failed");
2646 }
2647 emit_json(&ItemEvent {
2648 item: &item_key,
2649 status: "skipped",
2650 memory_id: None,
2651 entity_id: None,
2652 entities: None,
2653 rels: None,
2654 chars_before: None,
2655 chars_after: None,
2656 cost_usd: None,
2657 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2658 error: None,
2659 index: current_index,
2660 total,
2661 });
2662 }
2663 Ok(EnrichItemResult::PreservationFailed {
2664 score,
2665 threshold,
2666 chars_before,
2667 chars_after,
2668 }) => {
2669 skipped += 1;
2676 let reason = format!(
2677 "preservation_failed: jaccard={score:.3} threshold={threshold:.3} (orig={chars_before} chars, new={chars_after} chars)"
2678 );
2679 if let Err(qe) = queue_conn.execute(
2680 "UPDATE queue SET status='skipped', error=?1, done_at=datetime('now') WHERE id=?2",
2681 rusqlite::params![reason, queue_id],
2682 ) {
2683 tracing::warn!(target: "enrich", error = %qe, "queue preservation_failed update failed");
2684 }
2685 emit_json(&ItemEvent {
2686 item: &item_key,
2687 status: "preservation_failed",
2688 memory_id: None,
2689 entity_id: None,
2690 entities: None,
2691 rels: None,
2692 chars_before: Some(chars_before),
2693 chars_after: Some(chars_after),
2694 cost_usd: None,
2695 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2696 error: Some(reason),
2697 index: current_index,
2698 total,
2699 });
2700 }
2701 Err(e) => {
2702 let err_str = format!("{e}");
2703 if matches!(e, AppError::RateLimited { .. }) {
2704 if crate::retry::is_kill_switch_active() {
2705 tracing::warn!(target: "enrich", "SQLITE_GRAPHRAG_DISABLE_RETRY=1, skipping rate-limit retry");
2706 } else if std::time::Instant::now() >= rate_limit_deadline {
2707 tracing::error!(target: "enrich", total_elapsed_secs = enrich_started.elapsed().as_secs(), "rate-limit retry deadline (1h) exhausted");
2708 } else {
2709 let half = backoff_secs / 2;
2710 let jitter = if half == 0 { 0 } else { fastrand::u64(0..half) };
2711 let actual_wait = half + jitter;
2712 tracing::warn!(target: "enrich", delay_secs = actual_wait, error_kind = "rate_limited", "rate limited, backing off");
2713 if let Err(qe) = queue_conn.execute(
2714 "UPDATE queue SET status='pending' WHERE id=?1",
2715 rusqlite::params![queue_id],
2716 ) {
2717 tracing::warn!(target: "enrich", error = %qe, "queue pending update failed");
2718 }
2719 std::thread::sleep(std::time::Duration::from_secs(actual_wait));
2720 backoff_secs = (backoff_secs * 2).min(900);
2721 continue;
2722 }
2723 }
2724
2725 failed += 1;
2726 let _outcome = match openrouter_diag {
2731 Some(diag) => record_item_failure_typed(
2732 &queue_conn,
2733 queue_id,
2734 attempt_current,
2735 args.max_attempts,
2736 diag.retry_class,
2737 &err_str,
2738 diag.finish_reason.as_deref(),
2739 diag.prompt_tokens,
2740 diag.completion_tokens,
2741 ),
2742 None => record_item_failure(
2743 &queue_conn,
2744 queue_id,
2745 attempt_current,
2746 args.max_attempts,
2747 &e,
2748 ),
2749 };
2750 emit_json(&ItemEvent {
2751 item: &item_key,
2752 status: "failed",
2753 memory_id: None,
2754 entity_id: None,
2755 entities: None,
2756 rels: None,
2757 chars_before: None,
2758 chars_after: None,
2759 cost_usd: None,
2760 elapsed_ms: Some(item_started.elapsed().as_millis() as u64),
2761 error: Some(err_str),
2762 index: current_index,
2763 total,
2764 });
2765 }
2766 }
2767
2768 let _ = item_type; }
2770 } if !args.until_empty {
2773 break;
2774 }
2775 let eligible_remaining: i64 = queue_conn
2776 .query_row(
2777 &format!("SELECT COUNT(*) FROM queue WHERE status='pending' {backoff_clause}"),
2778 [],
2779 |r| r.get(0),
2780 )
2781 .unwrap_or(0);
2782 let progressed = completed > completed_before;
2783 if std::time::Instant::now() >= until_deadline {
2784 tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
2785 break;
2786 }
2787 if !progressed && eligible_remaining == 0 {
2788 tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
2789 break;
2790 }
2791 if eligible_remaining == 0 {
2792 std::thread::sleep(std::time::Duration::from_secs(1));
2794 }
2795 } if crate::shutdown_requested() {
2805 let reset = queue_conn
2806 .execute(
2807 "UPDATE queue SET status='pending', claimed_at=NULL WHERE status='processing'",
2808 [],
2809 )
2810 .unwrap_or(0);
2811 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2812 tracing::info!(
2813 target: "enrich",
2814 reset,
2815 "graceful shutdown: WAL checkpointed, processing claims reset"
2816 );
2817 }
2818
2819 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2820 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2821
2822 let waiting_final: i64 = queue_conn
2826 .query_row(
2827 "SELECT COUNT(*) FROM queue WHERE status='pending' \
2828 AND (operation = ?1 OR operation IS NULL) \
2829 AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
2830 rusqlite::params![op_label],
2831 |r| r.get(0),
2832 )
2833 .unwrap_or(0);
2834 let dead_final: i64 = queue_conn
2835 .query_row(
2836 "SELECT COUNT(*) FROM queue WHERE status='dead' \
2837 AND (operation = ?1 OR operation IS NULL)",
2838 rusqlite::params![op_label],
2839 |r| r.get(0),
2840 )
2841 .unwrap_or(0);
2842
2843 emit_json(&EnrichSummary {
2844 summary: true,
2845 operation: format!("{:?}", args.operation()),
2846 items_total: total,
2847 completed,
2848 failed,
2849 skipped,
2850 cost_usd: cost_total,
2851 elapsed_ms: started.elapsed().as_millis() as u64,
2852 backend_invoked: take_enrich_backend(),
2853 waiting: waiting_final,
2854 dead: dead_final,
2855 });
2856
2857 if failed == 0 {
2858 let dead: i64 = queue_conn
2861 .query_row("SELECT COUNT(*) FROM queue WHERE status='dead'", [], |r| {
2862 r.get(0)
2863 })
2864 .unwrap_or(0);
2865 let skipped_remaining: i64 = queue_conn
2871 .query_row(
2872 "SELECT COUNT(*) FROM queue WHERE status='skipped'",
2873 [],
2874 |r| r.get(0),
2875 )
2876 .unwrap_or(0);
2877 if dead == 0 && skipped_remaining == 0 {
2878 let _ = std::fs::remove_file(&queue_path);
2879 }
2880 }
2881
2882 Ok(())
2883}
2884
2885#[cfg(test)]
2892mod tests {
2893 use super::*;
2894
2895 #[test]
2896 fn bindings_schema_is_valid_json() {
2897 let _: serde_json::Value =
2898 serde_json::from_str(BINDINGS_SCHEMA).expect("BINDINGS_SCHEMA must be valid JSON");
2899 }
2900
2901 #[test]
2902 fn entity_description_schema_is_valid_json() {
2903 let _: serde_json::Value = serde_json::from_str(ENTITY_DESCRIPTION_SCHEMA)
2904 .expect("ENTITY_DESCRIPTION_SCHEMA must be valid JSON");
2905 }
2906
2907 #[test]
2908 fn body_enrich_schema_is_valid_json() {
2909 let _: serde_json::Value = serde_json::from_str(BODY_ENRICH_SCHEMA)
2910 .expect("BODY_ENRICH_SCHEMA must be valid JSON");
2911 }
2912}