Skip to main content

sqlite_graphrag/commands/enrich/
mod.rs

1// v1.0.97: modularised into queue.rs, scan.rs, postprocess.rs, extraction.rs.
2// See ADR-0056 (closes the ADR-0046 "Known Tech Debt (v1.0.89+)" item).
3
4//! Handler for the `enrich` CLI subcommand (GAP-14 + GAP-18).
5//!
6//! Enriches the knowledge graph by running LLM-powered analysis over memories
7//! and entities that are missing key structural data. Operations are:
8//!
9//! - `memory-bindings`: memories without `memory_entities` rows get entity extraction
10//! - `entity-descriptions`: entities with NULL/empty descriptions get LLM descriptions
11//! - `body-enrich`: memories with short bodies get expanded by the LLM (GAP-18)
12//! - `re-embed`: memories without a vector row get re-embedded without rewriting body
13//!
14//! Architecture mirrors `ingest_claude.rs`: SCAN → JUDGE (LLM) → PERSIST, with a
15//! SQLite queue DB derived next to `--db` (GAP-SG-64) for resume/retry support.
16// Workload: Subprocess I/O-bound (claude/codex API calls with network wait)
17//!
18//! # DRY note
19//!
20//! v1.0.97: `claude_runner.rs` now hosts the shared Claude invocation helpers
21//! (`run_claude`, `parse_claude_output`, `spawn_with_memory_limit`). The queue
22//! DB schema in `ingest_claude.rs` still duplicates `open_queue_db` here — a
23//! future pass can unify them.
24
25mod 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
64// ---------------------------------------------------------------------------
65// Constants
66// ---------------------------------------------------------------------------
67
68const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
69const DEFAULT_BODY_ENRICH_MIN_CHARS: usize = 500;
70const DEFAULT_BODY_ENRICH_MAX_CHARS: usize = 2000;
71
72// ---------------------------------------------------------------------------
73// JSON schema used for memory-bindings and body-enrich extraction
74// ---------------------------------------------------------------------------
75
76const 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
134// G27 P1: weight-calibrate
135const 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
153// G27 P1: relation-reclassify
154const 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
180// G27 P2: entity-connect — suggest relationships between isolated entities
181const 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
197// G27 P2: entity-type-validate — verify entity type assignments
198const 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
214// G27 P2: description-enrich — improve generic memory descriptions
215const 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
230// G27 P2: domain-classify — classify memory into domain category
231const 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
245// G27 P2: graph-audit — audit graph for quality issues
246const 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
261// G27 P2: deep-research-synth — synthesize research findings into graph
262const 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
278// G27 P2: body-extract — extract structured content from unstructured text
279const 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
293// ---------------------------------------------------------------------------
294// Prompts
295// ---------------------------------------------------------------------------
296
297const 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// ---------------------------------------------------------------------------
313// CLI args
314// ---------------------------------------------------------------------------
315
316/// Operation to perform in the `enrich` command.
317#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum EnrichOperation {
320    /// Add missing entity/relationship bindings to memories (fully implemented).
321    /// memory-bindings LINKS each memory to the EXISTING entities extracted from
322    /// its body — it does not invent a new graph, it only connects what is missing. Scans
323    /// only UNBOUND memories (those with zero `memory_entities`).
324    MemoryBindings,
325    /// GAP-SG-24/26: additive augmentation — re-run binding extraction over
326    /// memories that are ALREADY bound, filtered by `--names`/`--names-file`, to
327    /// merge newly-discovered entities/relationships WITHOUT removing existing
328    /// links. Requires a name filter (refuses to re-scan the whole namespace).
329    AugmentBindings,
330    /// Fill NULL/empty entity descriptions with LLM-generated summaries (fully implemented).
331    EntityDescriptions,
332    /// Expand short memory bodies into richer content (fully implemented, GAP-18).
333    BodyEnrich,
334    /// Rebuild missing memory embeddings without rewriting the memory body.
335    ReEmbed,
336    /// Calibrate relationship weights using LLM analysis (scan only).
337    WeightCalibrate,
338    /// Reclassify relationship types using LLM judgment (scan only).
339    RelationReclassify,
340    /// Connect isolated entities by suggesting new relationships (scan only).
341    EntityConnect,
342    /// Validate entity type assignments using LLM judgment (scan only).
343    EntityTypeValidate,
344    /// Enrich memory descriptions that are generic/auto-generated (scan only).
345    DescriptionEnrich,
346    /// Identify cross-domain bridges between disconnected subgraphs (scan only).
347    CrossDomainBridges,
348    /// Classify memories into domain categories (scan only).
349    DomainClassify,
350    /// Audit the graph for quality issues (scan only).
351    GraphAudit,
352    /// Synthesize deep-research findings into graph memories (scan only).
353    DeepResearchSynth,
354    /// Extract structured body from unstructured text (scan only).
355    BodyExtract,
356}
357
358/// v1.1.1 (P2): which embedding table the `re-embed` operation backfills.
359///
360/// `memories` is the historical behaviour (and the default, so existing
361/// invocations are unchanged); `entities` and `chunks` close the retroactive
362/// coverage gap for `entity_embeddings` / `chunk_embeddings`; `all` runs the
363/// three scans in one invocation.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
365#[serde(rename_all = "kebab-case")]
366pub enum ReEmbedTarget {
367    /// Memories without a live vector in `memory_embeddings` (default).
368    Memories,
369    /// Entities without a live vector in `entity_embeddings`.
370    Entities,
371    /// Chunks without a live vector in `chunk_embeddings`.
372    Chunks,
373    /// All three targets in a single run.
374    All,
375}
376
377/// LLM provider for enrichment.
378#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
379pub enum EnrichMode {
380    /// Use locally installed Claude Code CLI (OAuth-first).
381    ClaudeCode,
382    /// Use locally installed OpenAI Codex CLI.
383    Codex,
384    /// Use locally installed OpenCode CLI.
385    #[value(name = "opencode")]
386    Opencode,
387    /// Use the OpenRouter chat-completions REST API (no local CLI; v1.0.95).
388    #[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/// Arguments for the `enrich` subcommand.
404#[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    /// Enrichment operation to run. Required for write operations; optional for
451    /// the read-only queue inspectors (`--status` / `--list-dead` /
452    /// `--requeue-dead`), where it defaults to `memory-bindings` when omitted
453    /// (GAP-SG-31).
454    #[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    /// LLM provider to use. Required for write operations; not needed for the
464    /// read-only queue inspectors (`--status` / `--list-dead` /
465    /// `--requeue-dead`), which never call the LLM (GAP-SG-31).
466    #[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    /// Maximum number of items to process in this run. Omit for all.
474    #[arg(long, value_name = "N")]
475    pub limit: Option<usize>,
476
477    /// v1.1.1 (P2): embedding table backfilled by `--operation re-embed`.
478    /// `memories` (default) preserves the historical behaviour; `entities`
479    /// and `chunks` rebuild `entity_embeddings` / `chunk_embeddings`; `all`
480    /// covers the three tables in one run. The scan also selects rows whose
481    /// stored `dim` diverges from the configured `--embedding-dim` (P10),
482    /// so legacy-dimension vectors are regenerated, not only missing ones.
483    /// Ignored (rejected) for every other operation.
484    #[arg(long, value_enum, value_name = "TARGET", default_value_t = ReEmbedTarget::Memories)]
485    pub target: ReEmbedTarget,
486
487    /// Preview items without calling the LLM (zero tokens consumed).
488    #[arg(long)]
489    pub dry_run: bool,
490
491    /// Namespace to operate on. Default: global.
492    #[arg(long, env = "SQLITE_GRAPHRAG_NAMESPACE")]
493    pub namespace: Option<String>,
494
495    // -- Provider flags (Claude) --
496    /// Path to the Claude Code binary. Default: auto-detect from PATH.
497    #[arg(long, value_name = "PATH")]
498    pub claude_binary: Option<PathBuf>,
499
500    /// Claude model to use (e.g. claude-sonnet-4-6).
501    #[arg(long, value_name = "MODEL")]
502    pub claude_model: Option<String>,
503
504    /// Timeout per item in seconds when using Claude Code. Default: 300.
505    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
506    pub claude_timeout: u64,
507
508    // -- Provider flags (Codex) --
509    /// Path to the Codex CLI binary. Default: auto-detect from PATH.
510    #[arg(long, value_name = "PATH")]
511    pub codex_binary: Option<PathBuf>,
512
513    /// Codex model to use (e.g. o4-mini).
514    #[arg(long, value_name = "MODEL")]
515    pub codex_model: Option<String>,
516
517    /// Timeout per item in seconds when using Codex. Default: 300.
518    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
519    pub codex_timeout: u64,
520
521    // -- Provider flags (OpenCode) --
522    /// Path to the OpenCode binary. Default: auto-detect from PATH.
523    #[arg(long, value_name = "PATH", env = "SQLITE_GRAPHRAG_OPENCODE_BINARY")]
524    pub opencode_binary: Option<PathBuf>,
525
526    /// OpenCode model to use.
527    #[arg(long, value_name = "MODEL", env = "SQLITE_GRAPHRAG_OPENCODE_MODEL")]
528    pub opencode_model: Option<String>,
529
530    /// Timeout per item in seconds when using OpenCode. Default: 300.
531    #[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    // -- Provider flags (OpenRouter, v1.0.95) --
540    /// OpenRouter text model to use (REQUIRED with --mode openrouter; no default).
541    #[arg(long, value_name = "MODEL")]
542    pub openrouter_model: Option<String>,
543
544    /// OpenRouter API key. Falls back to OPENROUTER_API_KEY env or stored config.
545    #[arg(long, value_name = "KEY", env = "OPENROUTER_API_KEY")]
546    pub openrouter_api_key: Option<String>,
547
548    /// Timeout per item in seconds when using OpenRouter. Default: 600.
549    ///
550    /// GAP-SG-17: raised from 300 to 600 because dense bodies (close to the
551    /// ~32K-token context ceiling of the configured model) routinely take
552    /// longer than five minutes to generate via `deepseek-v4-flash:nitro`.
553    /// Raise it further for very large corpora; lower it for short snippets.
554    #[arg(long, value_name = "SECONDS", default_value_t = 600)]
555    pub openrouter_timeout: u64,
556
557    /// Optional OpenRouter base URL override (reserved; defaults to the public API).
558    #[arg(long, value_name = "URL")]
559    pub openrouter_base_url: Option<String>,
560
561    // -- Cost controls --
562    /// Abort when cumulative cost exceeds this USD budget (API key only; ignored for OAuth).
563    #[arg(long, value_name = "USD")]
564    pub max_cost_usd: Option<f64>,
565
566    // -- Queue controls --
567    /// Resume a previously interrupted run (skip already-done items).
568    #[arg(long)]
569    pub resume: bool,
570
571    /// Retry only items that failed in a previous run.
572    #[arg(long)]
573    pub retry_failed: bool,
574
575    /// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
576    /// `--stale-claim-secs` (default 1800s = 30 min) back to `pending`, then exit.
577    /// Recover rows orphaned by a kill -9 mid-enrich without a full re-scan.
578    #[arg(long)]
579    pub reset_stale_claims: bool,
580
581    /// v1.1.2 (Bug 4): age threshold (seconds) for `--reset-stale-claims` and
582    /// the run-startup stale sweep. Default 1800 (30 min).
583    #[arg(long, value_name = "SECONDS", default_value_t = 1800)]
584    pub stale_claim_secs: u64,
585
586    /// GAP-ENRICH-BACKLOG-CONVERGE: loop scan→drain internally until the queue
587    /// empties of eligible items or --max-runtime elapses; removes the need for
588    /// an external bash retry loop.
589    #[arg(long)]
590    pub until_empty: bool,
591
592    /// GAP-ENRICH-BACKLOG-CONVERGE: wall-clock ceiling in seconds for
593    /// --until-empty. Defaults to 3600 when omitted.
594    #[arg(long, value_name = "SECONDS")]
595    pub max_runtime: Option<u64>,
596
597    /// GAP-ENRICH-BACKLOG-CONVERGE: attempts per item before it becomes a
598    /// dead-letter (status='dead'). Range 1..=20. Default 8.
599    ///
600    /// GAP-SG-21: the default was raised from 5 to 8 because GAP-SG-09 now
601    /// reclassifies malformed / non-JSON LLM output as TRANSIENT (retryable)
602    /// rather than a permanent HardFailure. A flaky structured-output model
603    /// (e.g. deepseek-v4-flash:nitro) may emit several bad generations in a row
604    /// even after JSON repair (GAP-SG-10) recovers most of them; the extra
605    /// attempts give the backlog room to converge before an item is parked in
606    /// the dead-letter sink. Permanent faults (ProviderError, NotFound) still
607    /// dead-letter on the first attempt regardless of this value.
608    #[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    /// GAP-ENRICH-BACKLOG-CONVERGE: read-only mode — report queue and backlog
612    /// counts without calling the LLM or acquiring the singleton.
613    ///
614    /// Field semantics (v1.1.03 clarification):
615    /// - `scan_backlog` = candidates a fresh scan WOULD select from the database
616    ///   (REAL pending work, same WHERE predicate as the scanners).
617    /// - `queue_pending` = a COMPUTED COUNT over the sidecar queue, NOT a physical
618    ///   queue of rows to process — it stays non-zero even after a clean drain.
619    /// - `eligible_now == 0` WITH `queue_pending > 0` means COOLDOWN (rate-limit
620    ///   backoff), NOT a deadlock: items are parked on `next_retry_at`.
621    /// - `eligible_now > 0` stuck against `state: "draining"` IS a deadlock —
622    ///   stale `processing` claims hold the state. Run `--reset-stale-claims`
623    ///   to clear processing claims older than the threshold.
624    #[arg(long)]
625    pub status: bool,
626
627    /// GAP-SG-23: list every dead-letter item (status='dead') for the current
628    /// operation with its error_class, attempt count and last error message.
629    /// Read-only — no LLM, no singleton. Use it to inspect what `--requeue-dead`
630    /// would resurrect before running it.
631    #[arg(long)]
632    pub list_dead: bool,
633
634    /// GAP-SG-11/14: resurrect dead-letter items — move every `status='dead'`
635    /// row back to `pending`, zeroing `attempt`, `next_retry_at`, `error` and
636    /// `error_class`. Distinct from `--retry-failed`, which only resets the
637    /// legacy `status='failed'` rows; dead-letter rows are the terminal sink of
638    /// the v1.0.96 converge loop and are never re-selected without this flag.
639    /// No LLM call or singleton is taken — it only rewrites queue statuses.
640    #[arg(long)]
641    pub requeue_dead: bool,
642
643    /// GAP-SG-66: prune ORPHAN dead-letter rows — remove every `status='dead'`
644    /// memory row whose `item_key` (the memory name) no longer exists in the
645    /// main DB for this namespace. These are terminal "not found" failures that
646    /// `--requeue-dead` can never recover (re-processing re-fails the same way),
647    /// so they inflate `queue_dead` forever. Read-only on the main DB; deletes
648    /// only confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
649    /// are left untouched. No LLM, no singleton — like `--list-dead`.
650    #[arg(long)]
651    pub prune_dead_orphans: bool,
652
653    /// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
654    /// `item_type='entity'` row from the queue sidecar. Distinct from
655    /// `--prune-dead-orphans` (memory-keyed, consults the main DB): entity dead
656    /// rows are terminal artifacts of re-extraction and have no recovery path,
657    /// so no main-DB check is needed. Required because the v1.1.1 re-embed bug
658    /// left 14680 entity-keyed dead-letter rows that the memory-scoped pruner
659    /// cannot reach. No LLM, no singleton — like `--list-dead`.
660    #[arg(long, conflicts_with = "prune_dead_orphans")]
661    pub prune_dead_entity_orphans: bool,
662
663    /// GAP-SG-16: ignore the per-item backoff cooldown (`next_retry_at`) when
664    /// selecting candidates, so items waiting on exponential backoff are
665    /// processed immediately. Use to drain a backlog whose cooldown windows are
666    /// long but the provider has recovered. Without it, `--status` reports such
667    /// items under `waiting` and they are skipped until their `next_retry_at`.
668    #[arg(long)]
669    pub ignore_backoff: bool,
670
671    /// GAP-SG-28: read-only `body-extract` — extract entities/relationships into
672    /// the graph WITHOUT rewriting (or truncating) the memory body. The default
673    /// `body-extract` restructures the stored body in place; with this flag the
674    /// body is left untouched and only graph bindings are persisted (additive,
675    /// via the same upsert path as `memory-bindings`). Ignored for every other
676    /// operation.
677    #[arg(long)]
678    pub body_extract_graph_only: bool,
679
680    /// GAP-ENRICH-BACKLOG-CONVERGE: REST concurrency for --mode openrouter
681    /// (clamp 1..=16, default 8). Distinct from the legacy --llm-parallelism.
682    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=16))]
683    pub rest_concurrency: Option<u32>,
684
685    // -- body-enrich specific flags (GAP-18) --
686    /// Minimum output character count for body-enrich. Default: 500.
687    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
688    pub min_output_chars: usize,
689
690    /// Maximum output character count for body-enrich. Default: 2000.
691    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
692    pub max_output_chars: usize,
693
694    /// Check that enriched body preserves all facts from the original (LLM judge). Default: true.
695    #[arg(long, default_value_t = true)]
696    pub preserve_check: bool,
697
698    /// Path to a custom prompt template file for body-enrich.
699    #[arg(long, value_name = "PATH")]
700    pub prompt_template: Option<PathBuf>,
701
702    /// Number of parallel LLM workers (default 1 = serial).
703    /// Each worker claims items atomically from the queue DB via UPDATE...RETURNING.
704    /// Range: 1–32. For 2321 entities, --llm-parallelism 4 reduces wall time ~4×.
705    #[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    // -- Output / infra --
709    /// Emit NDJSON output. Always true; flag accepted for compatibility.
710    #[arg(long)]
711    pub json: bool,
712
713    /// Database path override.
714    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
715    pub db: Option<String>,
716
717    /// G30: poll for the job singleton every second for up to N seconds
718    /// when another invocation holds the lock. Default: 0 (fail fast).
719    #[arg(long, value_name = "SECONDS")]
720    pub wait_job_singleton: Option<u64>,
721
722    /// G30: force acquisition of the singleton lock by removing a stale
723    /// lock file from a previously crashed invocation. Use only when you
724    /// are certain no other `enrich`/`ingest` is running.
725    #[arg(long, default_value_t = false)]
726    pub force_job_singleton: bool,
727
728    /// G37: select a specific subset of memory names to enrich instead of
729    /// the full candidate set. Comma-separated, e.g. `--names a,b,c`.
730    /// Empty when omitted (processes all candidates).
731    ///
732    /// GAP-SG-18: also a cooldown remedy — when `--status` shows items under
733    /// `waiting` (parked on `next_retry_at` backoff), pass the exact names here
734    /// to re-enqueue and process just that subset on the next run instead of
735    /// waiting for every cooldown to elapse. REQUIRED for `--operation
736    /// augment-bindings`, which refuses to re-scan the whole namespace.
737    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
738    pub names: Vec<String>,
739
740    /// G37: read the subset of memory names from a file (one per line).
741    /// Lines starting with `#` and empty lines are ignored. Combined with
742    /// `--names` (union) when both are set.
743    #[arg(long, value_name = "PATH")]
744    pub names_file: Option<PathBuf>,
745
746    /// G35: probe the LLM provider with a 1-turn ping before processing
747    /// the batch. Aborts with a clear error if the rate-limit window is
748    /// closed (avoids burning N turns only to fail on item 1).
749    #[arg(long, default_value_t = false)]
750    pub preflight_check: bool,
751
752    /// G35: if a preflight probe or in-flight call hits the Claude rate
753    /// limit, fall back to `--fallback-mode` (typically `codex`) instead
754    /// of failing the batch. Ignored when `--mode` is already `codex`.
755    #[arg(long, value_enum)]
756    pub fallback_mode: Option<EnrichMode>,
757
758    /// G35: number of seconds before the OAuth rate-limit reset at which
759    /// the preflight probe should refuse to start. Default 300 (5 min).
760    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
761    pub rate_limit_buffer: u64,
762
763    /// G28-D: refuse to start when the 1-minute load average exceeds
764    /// `2 × ncpus` (or `SQLITE_GRAPHRAG_MAX_LOAD_PER_NCPU` if set).
765    /// Set to false to skip the check on contended CI runners.
766    #[arg(long, default_value_t = true)]
767    pub max_load_check: bool,
768
769    /// G28-D: when the system is saturated, abort the job after this
770    /// many consecutive HardFailure outcomes. Default 5.
771    #[arg(long, value_name = "N", default_value_t = 5)]
772    pub circuit_breaker_threshold: u32,
773
774    /// G29 Step 4: minimum trigram-Jaccard similarity between the
775    /// original body and the LLM-rewritten body for the rewrite to be
776    /// accepted. Scores below the threshold are rejected and emitted as
777    /// `EnrichItemResult::PreservationFailed`. Default 0.7 (per the G29
778    /// gap specification). Ignored when `--operation` is not
779    /// `body-enrich`.
780    #[arg(long, value_name = "FLOAT", default_value_t = 0.7)]
781    pub preserve_threshold: f64,
782
783    /// G33 Step 3: when set, validate `--codex-model` against the
784    /// ChatGPT Pro OAuth accepted-model list and abort with a
785    /// suggestion when the value is unknown. Default true (fail fast
786    /// to avoid burning OAuth turns). Set to false to opt out.
787    #[arg(long, default_value_t = true)]
788    pub codex_model_validate: bool,
789
790    /// G33 Step 3: when set together with an invalid `--codex-model`,
791    /// automatically substitute the supplied default (e.g. `gpt-5.5`)
792    /// instead of aborting. The substitution is recorded in the NDJSON
793    /// stream as `provider_substituted: true` for traceability.
794    #[arg(long, value_name = "MODEL")]
795    pub codex_model_fallback: Option<String>,
796}
797
798impl EnrichArgs {
799    /// GAP-SG-31: resolved enrichment operation.
800    ///
801    /// `operation` is `Option` so the read-only queue inspectors
802    /// (`--status` / `--list-dead` / `--requeue-dead`) can run without it.
803    /// Write paths always carry a value (enforced by
804    /// `required_unless_present_any` at parse time); the read-only paths fall
805    /// back to `memory-bindings`, the most common queue, when it is omitted.
806    fn operation(&self) -> EnrichOperation {
807        self.operation
808            .clone()
809            .unwrap_or(EnrichOperation::MemoryBindings)
810    }
811
812    /// GAP-SG-31: resolved LLM provider. `mode` is `Option` for the read-only
813    /// inspectors that never call the LLM; write paths always carry a value
814    /// (enforced by `required_unless_present_any`). The fallback is only ever
815    /// observed by read-only code that does not actually invoke the provider.
816    fn mode(&self) -> EnrichMode {
817        self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
818    }
819}
820
821// ---------------------------------------------------------------------------
822// Internal types — raw LLM output structs
823// ---------------------------------------------------------------------------
824
825// ---------------------------------------------------------------------------
826// NDJSON event types emitted to stdout
827// ---------------------------------------------------------------------------
828
829#[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    /// Active parallel LLM worker count (1 = serial). Present only on the "scan" phase event.
841    #[serde(skip_serializing_if = "Option::is_none")]
842    llm_parallelism: Option<u32>,
843}
844
845/// GAP-SG-45: separates the SCAN metric (always serial — a single SQL sweep of
846/// the candidate set) from the DRAIN metric (the parallel worker fan-out). The
847/// legacy "scan" `PhaseEvent` reported `llm_parallelism` on the scan event,
848/// conflating the two; this event makes the distinction explicit.
849#[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 identifier (memory name or entity name).
859    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    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
894    /// ran the re-embedding during enrich. `"claude" | "codex" | "none"`.
895    /// Absent on the wire when `None` (kept for happy-path envelope cleanliness,
896    /// or when the operation did not involve a re-embed).
897    #[serde(skip_serializing_if = "Option::is_none")]
898    backend_invoked: Option<&'static str>,
899    /// GAP-SG-15: items still parked on backoff (`status='pending'` with a future
900    /// `next_retry_at`) when the run ended. Non-zero means the backlog has NOT
901    /// converged — those items are waiting on a cooldown, not done.
902    waiting: i64,
903    /// GAP-SG-15: dead-letter items (`status='dead'`) at the end of the run.
904    /// Non-zero requires `--list-dead` to inspect and `--requeue-dead` to retry.
905    dead: i64,
906}
907
908use crate::output::emit_json_line as emit_json;
909
910// ---------------------------------------------------------------------------
911// Queue DB
912// ---------------------------------------------------------------------------
913
914// Queue functions and structs moved to queue.rs
915
916// LLM call_claude and call_openrouter moved to extraction.rs
917
918// ---------------------------------------------------------------------------
919// Preflight probe (G35) — single-turn ping to verify the LLM provider
920// ---------------------------------------------------------------------------
921
922/// Result of a single preflight ping (G35).
923enum PreflightOutcome {
924    /// The provider accepted the ping without rate-limit or other errors.
925    Healthy,
926    /// The provider rejected the ping due to OAuth rate limit. The
927    /// `suggestion` field is a human hint that callers can embed in the
928    /// user-facing error.
929    RateLimited {
930        reason: String,
931        suggestion: &'static str,
932    },
933    /// Any other provider error (binary missing, auth failure, etc.).
934    Error(AppError),
935}
936
937/// Probes the configured LLM provider with a 1-turn ping.
938///
939/// - Claude: `claude -p "ping" --max-turns 1 --strict-mcp-config --mcp-config '{}'`
940/// - Codex:  `codex exec -c mcp_servers='{}' "ping" --json`
941///
942/// The probe intentionally avoids spawning any MCP server children (G28-A)
943/// to keep its own process footprint at the minimum.
944fn 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            // v1.0.88 (BUG-3 fix, ADR-0046): write the empty MCP config to a
954            // tempfile (Claude Code 2.1.177 rejects the inline `{}`
955            // form) and run the preflight gate before spawn, mirroring
956            // the canonical pattern in `claude_runner::build_claude_command`.
957            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            // v1.0.95: the OpenRouter JUDGE has no subprocess to ping; the
1112            // preflight only confirms a usable API key resolves. The chat
1113            // client singleton is initialised in run() before scan.
1114            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
1124/// Cross-platform wait with timeout (no extra crate dependency).
1125fn 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
1154// Scan functions moved to scan.rs
1155
1156// Persist functions moved to postprocess.rs
1157
1158// ---------------------------------------------------------------------------
1159// Main entry point
1160// ---------------------------------------------------------------------------
1161
1162// ---------------------------------------------------------------------------
1163// G20: mode-conditional flag validation
1164// ---------------------------------------------------------------------------
1165
1166/// True when a scalar value matches its declared default. Used to
1167/// distinguish "operator passed an explicit override" from "clap filled
1168/// the default" for flags with default_value_t.
1169fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1170    value == default
1171}
1172
1173/// G20: validate that flags for one LLM provider were not passed when
1174/// the operator selected a different provider. Flags silently discarded
1175/// by the wrong mode are surfaced as AppError::Validation BEFORE any
1176/// DB work, so the operator gets an actionable error instead of a
1177/// surprise at runtime.
1178///
1179/// Detection rules:
1180/// - For `Option<PathBuf>` / `Option<String>`: is_some() means explicit
1181/// - For scalar fields with default_value_t: value != default means explicit
1182/// - For boolean fields: true means explicit (default is false)
1183///
1184/// Mode-specific matrices:
1185/// - mode=claude-code rejects: codex_binary, codex_model, codex_timeout != 300
1186/// - mode=codex rejects: claude_binary, claude_model, claude_timeout != 300, max_cost_usd
1187fn 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
1298// ---------------------------------------------------------------------------
1299
1300/// Main entry point for the `enrich` command.
1301pub fn run(
1302    args: &EnrichArgs,
1303    llm_backend: crate::cli::LlmBackendChoice,
1304    embedding_backend: crate::cli::EmbeddingBackendChoice,
1305) -> Result<(), AppError> {
1306    // G20: mode-conditional flag validation BEFORE any DB access.
1307    // Surfaces flags that the wrong mode would silently discard.
1308    validate_mode_conditional_flags_enrich(args)?;
1309
1310    // v1.1.1 (P2): --target only means something for re-embed. Fail loud
1311    // instead of silently ignoring it under another operation.
1312    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    // GAP-ENRICH-BACKLOG-CONVERGE: --status is a read-only report. It never
1327    // calls the LLM, never initialises the OpenRouter client, and never
1328    // acquires the job singleton, so it is safe to run while a real enrich is
1329    // in flight (it only reads the queue DB and the unbound backlog).
1330    // GAP-SG-23/11: --list-dead (inspect dead-letter rows) and --requeue-dead
1331    // (resurrect them) are queue-only operations — no LLM, no main-DB write, no
1332    // singleton. Both are scoped to the current --operation so a shared queue is
1333    // not cross-contaminated. Handled before any provider setup.
1334    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        // v1.1.2: prune dead ENTITY orphan rows — terminal artifacts of
1345        // re-extraction; no main-DB check needed (entity dead rows are not
1346        // recoverable, re-running re-fails the same way). Distinct from the
1347        // memory-scoped prune below.
1348        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        // GAP-SG-66: prune orphan dead rows (memory gone) — needs the main DB to
1371        // confirm the referenced memory is truly absent before deleting.
1372        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        // --requeue-dead: move dead -> pending, clearing the failure bookkeeping.
1432        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        // GAP-SG-77: DB-semantics backlog for the queried operation (fixes the
1469        // false pending=0 for entity-descriptions/body-enrich/re-embed).
1470        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        // GAP-SG-42: scope every count to the current operation. Rows migrated
1476        // before the `operation` column (NULL) are still counted so a legacy
1477        // queue is never reported as spuriously empty.
1478        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        // GAP-SG-16: enumerate the items currently in backoff with their ETA.
1507        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        // GAP-SG-15/46: distinguish empty from cooldown from not-yet-scanned.
1533        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    // v1.1.2 (Bug 4): standalone recovery path — reset stale `processing` claims
1563    // left behind by a kill -9, then exit. No scan, no LLM, no singleton: the
1564    // operator runs this after a crash to unblock the queue before re-running
1565    // enrich. Emits a JSON envelope mirroring the other maintenance paths.
1566    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    // v1.0.95 (ADR-0054): when the JUDGE is OpenRouter the model is mandatory
1588    // (no default) and the API key must resolve BEFORE any network or DB work.
1589    // The chat client singleton is initialised here so every per-item dispatch
1590    // fetches it without re-threading the key.
1591    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    // G28-B (v1.0.68) + G30 (v1.0.69): enforce singleton per
1622    // (job_type, namespace, db_hash) so two parallel `enrich` invocations
1623    // on the same DB cannot co-exist, but concurrent enrich on different
1624    // databases works as expected. The force flag (--force) breaks a
1625    // stale lock from a previously crashed invocation.
1626    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    // Validate provider binary upfront only for LLM-backed operations.
1637    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                // v1.0.95: the OpenRouter JUDGE is a REST call, not a spawned
1683                // binary. The chat client singleton was initialised at the top
1684                // of run(); this placeholder path threads through the dispatch
1685                // but is never dereferenced by the OpenRouter arm.
1686                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    // G28-D: refuse to start when the system is saturated. This check
1700    // is BEFORE preflight so we never spend an OAuth turn on a host
1701    // that is already at the limit.
1702    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    // G35: preflight probe — issue a single ping turn to verify the
1712    // provider is healthy before scanning N candidates. If the probe
1713    // fails with a rate-limit error, optionally fall back to a
1714    // different mode (typically codex) instead of failing the entire
1715    // batch. The probe itself consumes 1 OAuth turn, so it stays
1716    // opt-in (default off) to keep --dry-run and CI flows zero-cost.
1717    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                        // G35 (v1.0.69): the mid-batch mode switch is
1730                        // intentionally NOT applied because it would
1731                        // desynchronise the per-item rate-limit wait
1732                        // state (rate-limited items in the worker are
1733                        // timed against the original provider). Instead
1734                        // we abort cleanly so the operator can re-invoke
1735                        // with `--mode {fallback:?}`. This guarantees no
1736                        // OAuth window is wasted and no partial state
1737                        // is left in the queue.
1738                        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    // SCAN phase
1763    let mut scan_result = scan_operation(&conn, &namespace, args)?;
1764    // GAP-SG-69: body-enrich candidates are scanned purely by `LENGTH(body) <
1765    // min_output_chars`, so a short body whose rewrite the preservation guard
1766    // keeps rejecting is re-scanned every pass — items_total never reaches 0 and
1767    // `--until-empty` never converges (the detached worker reported a stuck
1768    // backlog for 30+ min). Exclude memories already vetoed `status='skipped'`
1769    // for this operation in the sidecar queue; `cleanup_queue_entry`
1770    // (remember/edit/forget/purge) clears the veto when the body actually
1771    // changes, so a genuinely updated memory is reconsidered automatically.
1772    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    // Dry-run: emit preview events and summary without calling LLM
1792    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    // All operations in this enum have an execution path.
1827
1828    // Queue setup for resume/retry (GAP-SG-64: sidecar alongside --db).
1829    // v1.1.2 (Bug 4): `mut` is required because the enqueue batch (D5) opens a
1830    // transaction on this connection.
1831    let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
1832    let mut queue_conn = open_queue_db(&queue_path)?;
1833
1834    // v1.1.2 (Bug 4): sweep stale `processing` claims left by a previous kill -9
1835    // BEFORE the singleton/drain starts, on EVERY run (not only --resume). A row
1836    // orphaned mid-LLM-call never clears its claimed_at, so without this sweep the
1837    // next run would never re-select it and the backlog would silently shrink.
1838    {
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    // Populate queue (GAP-SG-12: tag rows with the operation + link memory_id).
1879    // v1.1.2 (Bug 4, D5): batch every INSERT in a single transaction so hundreds
1880    // of candidates commit with one fsync instead of one-per-statement. The
1881    // memory_id resolution SELECT runs against the main DB (read-only here) and
1882    // stays outside the queue transaction.
1883    let op_label = format!("{:?}", args.operation());
1884    let item_type = item_type_for(&args.operation());
1885    {
1886        let tx = queue_conn.transaction()?;
1887        // v1.1.2 (Bug 4): `Transaction` derefs to `Connection`, so `&*tx` yields
1888        // the `&Connection` the existing enqueue_candidate signature expects.
1889        let tx_conn: &Connection = &tx;
1890        for key in scan_result.iter() {
1891            // v1.1.1 (P2): re-embed keys may be prefixed (`entity:` / `chunk:`);
1892            // derive the row item_type from the key so prune-dead-orphans never
1893            // mistakes an entity/chunk row for an orphaned memory.
1894            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    // G19: parallel LLM processing via std::thread::scope when parallelism > 1.
1901    // Clamp enforces the range even if the caller bypasses clap validation.
1902    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    // G28-D (v1.0.68) + G34 (v1.0.69): warn above the recommended parallelism
1929    // ceiling. The threshold and message depend on the LLM mode because
1930    // Claude Code spawns MCP children (G28-A) while Codex does not.
1931    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                // No warning: codex does not spawn MCP children and was
1956                // validated at parallelism 8 in production (1161 items,
1957                // 0 failures) per the 2026-06-04 session audit.
1958            }
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                // No warning: opencode does not spawn MCP children.
1971            }
1972            EnrichMode::OpenRouter => {
1973                // No warning: OpenRouter is a bounded HTTP fan-out (no
1974                // subprocess); --llm-parallelism is respected as-is.
1975            }
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    // GAP-SG-16: when --ignore-backoff is set, drop the per-item cooldown filter
2003    // from candidate selection so items parked on `next_retry_at` are eligible
2004    // immediately. Shared by the parallel workers and the serial loop.
2005    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    // GAP-SG-45: announce the scan-vs-drain concurrency split (scan is always
2012    // serial; drain uses `parallelism` workers).
2013    emit_json(&ConcurrencyEvent {
2014        phase: "concurrency",
2015        scan_parallelism: 1,
2016        drain_parallelism: parallelism as u32,
2017    });
2018
2019    // GAP-ENRICH-BACKLOG-CONVERGE: --until-empty wraps the scan→populate→drain
2020    // cycle in an internal loop so the external bash retry loop is unnecessary.
2021    // Without --until-empty the loop body runs exactly once (legacy behaviour).
2022    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            // Re-scan and re-enqueue eligible candidates each iteration.
2027            // INSERT OR IGNORE never resurrects a dead-letter row (item_key is
2028            // UNIQUE), so the backlog converges instead of looping forever.
2029            let mut rescan = scan_operation(&conn, &namespace, args)?;
2030            // GAP-SG-69: drop memories already vetoed `status='skipped'` so the
2031            // re-scan converges instead of re-enqueuing a non-expandable short
2032            // body every iteration (body-enrich only; the verdict persists in
2033            // the sidecar queue and is cleared by cleanup_queue_entry on edit).
2034            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            // v1.1.2 (Bug 4, D5): batch the re-scan INSERTs in one transaction.
2040            {
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        // G19: when parallelism > 1, spawn bounded worker threads.
2053        // Each worker opens its own DB connections (WAL supports concurrent readers + serialized writers).
2054        // The queue DB claim is atomic via UPDATE...RETURNING — no external lock needed.
2055        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                // GAP-SG-76 fix: distinct signal for "worker aborted because
2071                // SQLITE_BUSY exhausted all bounded retries" so the caller
2072                // fails loud (exit 15) instead of silently treating it like
2073                // an exhausted/empty backlog.
2074                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                        // G28-D: per-worker circuit breaker that aborts the
2112                        // loop after `circuit_breaker_threshold` consecutive
2113                        // HardFailure outcomes (transient/rate-limited errors
2114                        // do NOT count, so a recovering provider is not
2115                        // penalised).
2116                        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                            // GAP-SG-16: --ignore-backoff drops the next_retry_at
2132                            // cooldown filter so items waiting on backoff are
2133                            // claimed immediately.
2134                            // GAP-SG-76: distinguish a genuinely empty backlog
2135                            // (QueryReturnedNoRows) from SQLITE_BUSY lock
2136                            // contention with the main writer or another
2137                            // worker — a busy claim retries briefly instead of
2138                            // breaking the drain loop early.
2139                            // GAP-SG-76/v1.1.00 fix: bounded busy-retry via the
2140                            // shared with_busy_retry helper (5 attempts, exponential
2141                            // half-jitter backoff, kill-switch aware) instead of an
2142                            // unbounded `loop { ... continue; }` on SQLITE_BUSY. When
2143                            // retries are exhausted, with_busy_retry converts to
2144                            // AppError::DbBusy — that is NOT treated as an empty
2145                            // backlog. It sets w_db_busy and stops this worker; the
2146                            // caller (after collecting all workers) fails loud with
2147                            // exit code 15 instead of silently under-reporting a
2148                            // convergent drain.
2149                            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                            // v1.1.2 (Bug 4): refresh claimed_at so a slow LLM
2169                            // call is not mistaken for a stale claim by a
2170                            // concurrent startup sweep. The dequeue already set
2171                            // it; this bumps it right before the long call.
2172                            let _ = heartbeat(&w_queue, queue_id);
2173                            let item_started = Instant::now();
2174                            let current_index = w_completed + w_failed + w_skipped;
2175
2176                            // provider_binary validated upfront (Some for every
2177                            // LLM-backed op; None only for ReEmbed, which ignores
2178                            // it). unwrap_or_default yields "" solely in that
2179                            // unread case, so a broken invariant surfaces as a
2180                            // recoverable per-item error instead of a panic.
2181                            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                            // GAP-SG-72/73: drain UNCONDITIONALLY right after
2198                            // every call_result (success or failure) so a
2199                            // diagnostic never survives past the item that
2200                            // produced it — see the doc comment on
2201                            // OpenRouterFailureDiagnostics.
2202                            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                                    // G28-D: count success; resets breaker.
2215                                    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                                    // G29 Passo 4: worker mirror of the
2228                                    // serial path. Counted as a soft
2229                                    // skip so the queue surface shows
2230                                    // a quality issue rather than a
2231                                    // transport failure.
2232                                    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                                    // GAP-SG-73: prefer the origin-typed verdict
2277                                    // (ChatError::retry_class, computed at the
2278                                    // exact HTTP status / provider code in
2279                                    // chat_api.rs) over the untyped fallback
2280                                    // classifier whenever this item's failure
2281                                    // came from an OpenRouter chat call.
2282                                    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                                    // G28-D: feed the classified outcome to the breaker (transient
2299                                    // failures do not count toward opening it).
2300                                    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            // GAP-SG-76 fix: a worker that aborted due to exhausted
2331            // SQLITE_BUSY retries must fail the whole `enrich` invocation
2332            // loudly (exit code 15 via AppError::DbBusy) rather than being
2333            // folded silently into the completed/failed/skipped counters,
2334            // which would understate a genuinely unfinished drain.
2335            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            // Serial path (parallelism == 1) — original loop
2353            loop {
2354                if crate::shutdown_requested() {
2355                    tracing::info!(target: "enrich", "shutdown requested, stopping enrichment");
2356                    break;
2357                }
2358
2359                // Budget check
2360                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                // Dequeue next pending item (GAP-SG-16: --ignore-backoff drops
2368                // the next_retry_at cooldown filter).
2369                // GAP-SG-76: distinguish a genuinely empty backlog
2370                // (QueryReturnedNoRows) from SQLITE_BUSY lock contention with
2371                // a concurrent writer — a busy claim retries briefly instead
2372                // of breaking the drain loop early.
2373                // GAP-SG-76/v1.1.00 fix: bounded busy-retry via the shared
2374                // with_busy_retry helper (5 attempts, exponential half-jitter
2375                // backoff, kill-switch aware) instead of an unbounded
2376                // `loop { ... continue; }` on SQLITE_BUSY. When retries are
2377                // exhausted, with_busy_retry converts to AppError::DbBusy,
2378                // which we propagate immediately (fail loud, exit code 15)
2379                // instead of silently treating sustained contention as an
2380                // empty backlog — that would end the drain early and under-
2381                // report queue_pending as converged.
2382                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                // v1.1.2 (Bug 4): refresh claimed_at before the long LLM call so
2403                // a startup sweep does not reclaim this row mid-processing.
2404                let _ = heartbeat(&queue_conn, queue_id);
2405
2406                let item_started = Instant::now();
2407                let current_index = completed + failed + skipped;
2408
2409                // See worker note: provider_binary is Some for every LLM-backed
2410                // op; "" here only for ReEmbed, which never reads it.
2411                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                // GAP-SG-72/73: drain UNCONDITIONALLY right after every
2545                // call_result (mirrors the worker loop above).
2546                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                        // Persist depends on the operation
2566                        let persist_err: Option<String> = match args.operation() {
2567                            EnrichOperation::MemoryBindings => {
2568                                // Bindings already persisted inside call_memory_bindings
2569                                None
2570                            }
2571                            EnrichOperation::EntityDescriptions => {
2572                                // Description already persisted inside call_entity_description
2573                                None
2574                            }
2575                            EnrichOperation::BodyEnrich => {
2576                                // Body already persisted inside call_body_enrich
2577                                None
2578                            }
2579                            _ => {
2580                                // All G27 operations persist inside their call_* function
2581                                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                        // G29 Passo 4: the LLM rewrite diverged too far from
2670                        // the original body. Count as a soft failure (not
2671                        // `failed`) so the queue surfaces it as a quality
2672                        // issue, not a transport error. The reason is
2673                        // structured so the operator can audit why a body
2674                        // was rejected.
2675                        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                        // GAP-SG-73: prefer the origin-typed verdict
2727                        // (ChatError::retry_class) over the untyped fallback
2728                        // classifier whenever this item's failure came from
2729                        // an OpenRouter chat call.
2730                        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; // used via queue schema only
2769            }
2770        } // end else (serial path)
2771
2772        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            // Remaining pending items are waiting on backoff; nap and re-check.
2793            std::thread::sleep(std::time::Duration::from_secs(1));
2794        }
2795    } // end until-empty loop
2796
2797    // v1.1.2 (Bug 4 / Omissão 3): SIGTERM graceful cleanup. When a shutdown was
2798    // requested mid-drain (the worker/serial loops already broke out), reset the
2799    // in-flight `processing` claims back to `pending` so the NEXT run re-selects
2800    // them — without this a kill recycles the rows via the startup stale-claim
2801    // sweep (which waits `stale_claim_secs`), delaying recovery. Scoped to the
2802    // enrich run (not the global signal handler) so unrelated code paths keep
2803    // their existing exit semantics. Best-effort: errors are logged, not fatal.
2804    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    // GAP-SG-15: report items still in cooldown (waiting) and dead-lettered
2823    // alongside completed, so `--until-empty` makes the convergence state
2824    // explicit (cooldown vs. dead vs. truly empty) instead of just "done".
2825    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        // GAP-ENRICH-BACKLOG-CONVERGE: keep the queue file when dead-letter rows
2859        // exist so `enrich --status` can still report them on the next run.
2860        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        // GAP-SG-69: keep the sidecar queue while it still holds `skipped`
2866        // verdicts. Those rows tell the next scan which short bodies are
2867        // non-expandable; removing the file would lose the veto and the
2868        // body-enrich backlog would never converge. cleanup_queue_entry clears
2869        // a row when its memory is edited/forgotten, so the veto is not permanent.
2870        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// EnrichItemResult + call_* functions moved to extraction.rs
2886
2887// ---------------------------------------------------------------------------
2888// Tests
2889// ---------------------------------------------------------------------------
2890
2891#[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}