Skip to main content

sqlite_graphrag/commands/enrich/
run.rs

1//! Enrich command orchestrator: scan → enqueue → drain (serial/parallel).
2//! Extracted from mod.rs (Wave C1).
3
4#![allow(unused_assignments)] // counters reassigned across drain modes
5
6use std::path::PathBuf;
7use std::time::Instant;
8
9use rusqlite::Connection;
10
11use super::args::{EnrichArgs, EnrichMode, EnrichOperation, ReEmbedTarget};
12use super::events::*;
13use super::extraction::find_codex_binary;
14use super::postprocess::take_enrich_backend;
15use super::queue::{
16    enqueue_candidate, item_type_for, item_type_for_key,
17    open_queue_db, reset_stale_processing_claims,
18    skipped_item_keys,
19};
20use super::scan::{
21    self as scan, count_operation_backlog,
22};
23use super::scheduler;
24use super::DEFAULT_RATE_LIMIT_WAIT;
25use crate::commands::ingest_claude::find_claude_binary;
26use crate::errors::AppError;
27use crate::output::emit_json_line as emit_json;
28use crate::paths::AppPaths;
29use crate::storage::connection::{ensure_db_ready, open_rw};
30
31/// Run.
32pub fn run(
33    args: &EnrichArgs,
34    llm_backend: crate::cli::LlmBackendChoice,
35    embedding_backend: crate::cli::EmbeddingBackendChoice,
36) -> Result<(), AppError> {
37    // R-AN-01: schema introspection must not open the DB or call the LLM.
38    if args.print_schema {
39        return crate::print_schema::emit(crate::print_schema::SchemaId::EnrichStatus);
40    }
41
42    // GAP-CLI-PRIO-04 / OBS-02: --ops-gate runs quality gate ops first, in order.
43    if args.ops_gate {
44        for op in scheduler::gate_ops_order() {
45            let mut gate_args = args.clone();
46            gate_args.operation = Some(op);
47            gate_args.ops_gate = false; // prevent recursion
48            run(&gate_args, llm_backend, embedding_backend)?;
49        }
50        return Ok(());
51    }
52
53    // G20: mode-conditional flag validation BEFORE any DB access.
54    // Surfaces flags that the wrong mode would silently discard.
55    validate_mode_conditional_flags_enrich(args)?;
56
57    // v1.1.1 (P2): --target only means something for re-embed. Fail loud
58    // instead of silently ignoring it under another operation.
59    if args.target != ReEmbedTarget::Memories
60        && !matches!(args.operation(), EnrichOperation::ReEmbed)
61    {
62        let target_label = match args.target {
63            ReEmbedTarget::Memories => "memories",
64            ReEmbedTarget::Entities => "entities",
65            ReEmbedTarget::Chunks => "chunks",
66            ReEmbedTarget::All => "all",
67        };
68        return Err(AppError::Validation(
69            crate::i18n::validation::reembed_target_only(target_label),
70        ));
71    }
72
73    if super::status::try_handle_maintenance(args)? {
74        return Ok(());
75    }
76
77    // v1.0.95 (ADR-0054): when the JUDGE is OpenRouter the model is mandatory
78    // (no default) and the API key must resolve BEFORE any network or DB work.
79    // The chat client singleton is initialised here so every per-item dispatch
80    // fetches it without re-threading the key.
81    //
82    // GAP-CLI-DRY-01 (v1.1.8): dry-run never calls the LLM — skip provider
83    // key/model resolution so offline agents can preview candidates without
84    // credentials. `--status` already returns earlier above.
85    if args.mode() == EnrichMode::OpenRouter && !args.dry_run {
86        let model = args.openrouter_model.as_deref().ok_or_else(|| {
87            AppError::Validation(crate::i18n::validation::openrouter_model_required())
88        })?;
89        let resolved =
90            crate::config::resolve_api_key("openrouter", args.openrouter_api_key.as_deref())
91                .ok_or_else(|| {
92                    AppError::Validation(crate::i18n::validation::openrouter_api_key_not_found())
93                })?;
94        crate::embedder::get_openrouter_chat_client(
95            resolved.value,
96            model,
97            args.openrouter_timeout,
98        )?;
99    }
100
101    let started = Instant::now();
102
103    let paths = AppPaths::resolve(args.db.as_deref())?;
104    ensure_db_ready(&paths)?;
105    let conn = open_rw(&paths.db)?;
106    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
107
108    // G28-B (v1.0.68) + G30 (v1.0.69): enforce singleton per
109    // (job_type, namespace, db_hash) so two parallel `enrich` invocations
110    // on the same DB cannot co-exist, but concurrent enrich on different
111    // databases works as expected. The force flag (--force) breaks a
112    // stale lock from a previously crashed invocation.
113    let wait_secs = args.wait_job_singleton;
114    let force_flag = args.force_job_singleton;
115    let _singleton = crate::lock::acquire_job_singleton(
116        crate::lock::JobType::Enrich,
117        &namespace,
118        &paths.db,
119        wait_secs,
120        force_flag,
121    )?;
122
123    // Validate provider binary upfront only for LLM-backed write operations.
124    // GAP-CLI-DRY-01: dry-run never spawns a provider — skip binary resolution.
125    let provider_binary = if args.dry_run || matches!(args.operation(), EnrichOperation::ReEmbed) {
126        None
127    } else {
128        Some(match args.mode() {
129            EnrichMode::ClaudeCode => {
130                let bin = find_claude_binary(args.claude_binary.as_deref())?;
131                let version = crate::commands::claude_runner::validate_claude_version(&bin)?;
132                tracing::info!(target: "enrich", binary = %bin.display(), version = %version, "Claude Code binary validated");
133                emit_json(&PhaseEvent {
134                    phase: "validate",
135                    binary_path: bin.to_str(),
136                    version: Some(&version),
137                    items_total: None,
138                    items_pending: None,
139                    llm_parallelism: None,
140                });
141                bin
142            }
143            EnrichMode::Codex => {
144                let bin = find_codex_binary(args.codex_binary.as_deref())?;
145                emit_json(&PhaseEvent {
146                    phase: "validate",
147                    binary_path: bin.to_str(),
148                    version: None,
149                    items_total: None,
150                    items_pending: None,
151                    llm_parallelism: None,
152                });
153                bin
154            }
155            EnrichMode::Opencode => {
156                let bin = crate::commands::opencode_runner::find_opencode_binary_with_override(
157                    args.opencode_binary.as_deref(),
158                )?;
159                emit_json(&PhaseEvent {
160                    phase: "validate",
161                    binary_path: bin.to_str(),
162                    version: None,
163                    items_total: None,
164                    items_pending: None,
165                    llm_parallelism: None,
166                });
167                bin
168            }
169            EnrichMode::OpenRouter => {
170                // v1.0.95: the OpenRouter JUDGE is a REST call, not a spawned
171                // binary. The chat client singleton was initialised at the top
172                // of run(); this placeholder path threads through the dispatch
173                // but is never dereferenced by the OpenRouter arm.
174                emit_json(&PhaseEvent {
175                    phase: "validate",
176                    binary_path: None,
177                    version: None,
178                    items_total: None,
179                    items_pending: None,
180                    llm_parallelism: None,
181                });
182                PathBuf::new()
183            }
184        })
185    };
186
187    // G28-D: refuse to start when the system is saturated. This check
188    // is BEFORE preflight so we never spend an OAuth turn on a host
189    // that is already at the limit.
190    if args.max_load_check && !args.dry_run && crate::system_load::is_system_saturated() {
191        let load = crate::system_load::load_average_one();
192        let n = crate::system_load::ncpus();
193        return Err(AppError::Validation(
194            crate::i18n::validation::system_load_exceeded(load, n),
195        ));
196    }
197
198    // G35: preflight probe — issue a single ping turn to verify the
199    // provider is healthy before scanning N candidates. If the probe
200    // fails with a rate-limit error, optionally fall back to a
201    // different mode (typically codex) instead of failing the entire
202    // batch. The probe itself consumes 1 OAuth turn, so it stays
203    // opt-in (default off) to keep --dry-run and CI flows zero-cost.
204    if args.preflight_check
205        && !args.dry_run
206        && !matches!(args.operation(), EnrichOperation::ReEmbed)
207    {
208        let preflight_result = run_preflight_probe(args);
209        match preflight_result {
210            PreflightOutcome::Healthy => {
211                tracing::info!(target: "enrich", mode = ?args.mode(), "preflight probe healthy");
212            }
213            PreflightOutcome::RateLimited { reason, suggestion } => {
214                if let Some(fallback) = args.fallback_mode.clone() {
215                    if fallback != args.mode() {
216                        // G35 (v1.0.69): the mid-batch mode switch is
217                        // intentionally NOT applied because it would
218                        // desynchronise the per-item rate-limit wait
219                        // state (rate-limited items in the worker are
220                        // timed against the original provider). Instead
221                        // we abort cleanly so the operator can re-invoke
222                        // with `--mode {fallback:?}`. This guarantees no
223                        // OAuth window is wasted and no partial state
224                        // is left in the queue.
225                        return Err(AppError::Validation(
226                            crate::i18n::validation::preflight_rate_limit_fallback(
227                                &format!("{:?}", args.mode()),
228                                &reason,
229                                &format!("{fallback:?}"),
230                            ),
231                        ));
232                    }
233                    return Err(AppError::Validation(
234                        crate::i18n::validation::preflight_rate_limit_same_mode(
235                            &format!("{:?}", args.mode()),
236                            &reason,
237                        ),
238                    ));
239                }
240                return Err(AppError::Validation(
241                    crate::i18n::validation::preflight_rate_limit_suggestion(
242                        &format!("{:?}", args.mode()),
243                        &reason,
244                        suggestion,
245                    ),
246                ));
247            }
248            PreflightOutcome::Error(e) => {
249                return Err(e);
250            }
251        }
252    }
253
254    // v1.1.06 (GAP-ENTITY-CONNECT-SCAN-CARTESIAN): wall-clock deadline covers
255    // the **first** scan (and later rescans), not only the drain loop tail.
256    // Default 3600s matches --max-runtime; entity-connect also gets a soft
257    // ceiling so a hung SQL cannot pin the singleton forever when the operator
258    // omits --max-runtime without --until-empty.
259    let max_runtime_secs = args.max_runtime.unwrap_or(3600);
260    let until_deadline = Instant::now() + std::time::Duration::from_secs(max_runtime_secs);
261    let pair_scan_ops = matches!(
262        args.operation(),
263        EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges
264    );
265    // Soft ceiling for pair scans when no explicit short budget is set.
266    const ENTITY_CONNECT_SCAN_SOFT_CEILING_SECS: u64 = 120;
267    let scan_deadline = if pair_scan_ops {
268        let soft =
269            Instant::now() + std::time::Duration::from_secs(ENTITY_CONNECT_SCAN_SOFT_CEILING_SECS);
270        Some(soft.min(until_deadline))
271    } else if args.until_empty || args.max_runtime.is_some() {
272        Some(until_deadline)
273    } else {
274        None
275    };
276
277    let pair_op_cli = enrich_operation_cli_name(&args.operation());
278    let mut backlog_degree0_proxy: Option<i64> = None;
279    if pair_scan_ops {
280        let entities_in_namespace: i64 = conn
281            .query_row(
282                "SELECT COUNT(*) FROM entities WHERE namespace = ?1",
283                rusqlite::params![namespace],
284                |r| r.get(0),
285            )
286            .unwrap_or(0);
287        // Distinct from pairs_enqueued_this_scan: status proxy of islands with NER.
288        backlog_degree0_proxy =
289            count_operation_backlog(&conn, &args.operation(), &namespace, args.target).ok();
290        emit_json(&ScanStartEvent {
291            phase: "scan_start",
292            operation: pair_op_cli,
293            entities_in_namespace,
294            backlog_degree0_proxy,
295            pair_algorithm: Some("cooccurrence+hub_island"),
296            limit: args.limit,
297            scan_deadline_secs: scan_deadline
298                .map(|d| d.saturating_duration_since(Instant::now()).as_secs()),
299        });
300    }
301
302    // SCAN phase
303    let scan_started = Instant::now();
304    let mut scan_result = scan_operation_with_deadline(&conn, &namespace, args, scan_deadline)?;
305    // GAP-SG-69: body-enrich candidates are scanned purely by `LENGTH(body) <
306    // min_output_chars`, so a short body whose rewrite the preservation guard
307    // keeps rejecting is re-scanned every pass — items_total never reaches 0 and
308    // `--until-empty` never converges (the detached worker reported a stuck
309    // backlog for 30+ min). Exclude memories already vetoed `status='skipped'`
310    // for this operation in the sidecar queue; `cleanup_queue_entry`
311    // (remember/edit/forget/purge) clears the veto when the body actually
312    // changes, so a genuinely updated memory is reconsidered automatically.
313    if matches!(args.operation(), EnrichOperation::BodyEnrich) {
314        let q_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
315        if let Ok(q) = open_queue_db(&q_path) {
316            if let Ok(vetoed) = skipped_item_keys(&q, &format!("{:?}", args.operation())) {
317                scan_result.retain(|k| !vetoed.contains(k));
318            }
319        }
320    }
321    let total = scan_result.len();
322    let scan_elapsed_ms = scan_started.elapsed().as_millis() as u64;
323
324    emit_json(&PhaseEvent {
325        phase: "scan",
326        binary_path: None,
327        version: None,
328        items_total: Some(total),
329        items_pending: Some(total),
330        llm_parallelism: Some(args.llm_parallelism),
331    });
332    if pair_scan_ops {
333        emit_json(&serde_json::json!({
334            "phase": "scan_meta",
335            "operation": pair_op_cli,
336            "pair_algorithm": "cooccurrence+hub_island",
337            "items_total": total,
338            "pairs_enqueued_this_scan": total,
339            "backlog_degree0_proxy": backlog_degree0_proxy,
340            "scan_elapsed_ms": scan_elapsed_ms,
341            "scan_aborted_reason": serde_json::Value::Null,
342        }));
343    }
344
345    // Dry-run: emit preview events and summary without calling LLM
346    if args.dry_run {
347        // GAP-CLI-NAMES-03 / G-T-ONESHOT-01: explicit empty-match when a name
348        // filter was provided but no candidates matched.
349        if total == 0 {
350            let name_filter = scan::resolve_name_filter(args).unwrap_or_default();
351            if !name_filter.is_empty() {
352                emit_json(&serde_json::json!({
353                    "matched": 0,
354                    "hint": "no candidates matched --names/--entity-names/--memory-names for this operation; verify name space (entity vs memory) and predicates (e.g. empty description unless --force-redescribe)",
355                    "operation": format!("{:?}", args.operation()),
356                    "names_requested": name_filter,
357                }));
358            }
359        }
360        for (idx, key) in scan_result.iter().enumerate() {
361            emit_json(&ItemEvent {
362                item: key,
363                status: "preview",
364                memory_id: None,
365                entity_id: None,
366                entities: None,
367                rels: None,
368                chars_before: None,
369                chars_after: None,
370                cost_usd: None,
371                elapsed_ms: None,
372                error: None,
373                index: idx,
374                total,
375            });
376        }
377        emit_json(&EnrichSummary {
378            summary: true,
379            operation: format!("{:?}", args.operation()),
380            items_total: total,
381            completed: 0,
382            failed: 0,
383            skipped: 0,
384            cost_usd: 0.0,
385            elapsed_ms: started.elapsed().as_millis() as u64,
386            backend_invoked: take_enrich_backend(),
387            waiting: 0,
388            dead: 0,
389            budget_exhausted: None,
390            pairs_remaining_estimate: None,
391            yields: None,
392            preempted_for_gate: None,
393        });
394        return Ok(());
395    }
396
397    // All operations in this enum have an execution path.
398
399    // Queue setup for resume/retry (GAP-SG-64: sidecar alongside --db).
400    // v1.1.2 (Bug 4): `mut` is required because the enqueue batch (D5) opens a
401    // transaction on this connection.
402    let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
403    let mut queue_conn = open_queue_db(&queue_path)?;
404
405    // v1.1.2 (Bug 4): sweep stale `processing` claims left by a previous kill -9
406    // BEFORE the singleton/drain starts, on EVERY run (not only --resume). A row
407    // orphaned mid-LLM-call never clears its claimed_at, so without this sweep the
408    // next run would never re-select it and the backlog would silently shrink.
409    {
410        let stale_reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
411        if stale_reset > 0 {
412            tracing::info!(
413                target: "enrich",
414                count = stale_reset,
415                max_age_secs = args.stale_claim_secs,
416                "reset stale processing claims (older than threshold)"
417            );
418        }
419    }
420
421    if args.resume {
422        let reset = queue_conn
423            .execute(
424                "UPDATE queue SET status='pending' WHERE status='processing'",
425                [],
426            )
427            .map_err(|e| {
428                AppError::Validation(crate::i18n::validation::queue_resume_failed(&e))
429            })?;
430        if reset > 0 {
431            tracing::info!(target: "enrich", count = reset, "reset stuck processing items to pending");
432        }
433    }
434
435    if args.retry_failed {
436        let count = queue_conn
437            .execute(
438                "UPDATE queue SET status='pending', attempt=0 WHERE status='failed'",
439                [],
440            )
441            .map_err(|e| {
442                AppError::Validation(crate::i18n::validation::queue_retry_failed_reset_failed(&e))
443            })?;
444        tracing::info!(target: "enrich", count, "retrying failed items");
445    }
446
447    // GAP-SG-97: never wipe the whole sidecar — scope clear to this operation
448    // (and prefer namespace when the column is present).
449    let op_label = format!("{:?}", args.operation());
450    if !args.resume && !args.retry_failed && !args.until_empty {
451        queue_conn
452            .execute(
453                "DELETE FROM queue WHERE operation = ?1 \
454                 AND (namespace = ?2 OR namespace = '' OR namespace IS NULL)",
455                rusqlite::params![op_label, namespace],
456            )
457            .map_err(|e| {
458                AppError::Validation(crate::i18n::validation::queue_clear_failed(&e))
459            })?;
460    }
461
462    // Populate queue (GAP-SG-12: tag rows with the operation + link memory_id).
463    // v1.1.2 (Bug 4, D5): batch every INSERT in a single transaction so hundreds
464    // of candidates commit with one fsync instead of one-per-statement. The
465    // memory_id resolution SELECT runs against the main DB (read-only here) and
466    // stays outside the queue transaction.
467    let item_type = item_type_for(&args.operation());
468    {
469        let tx = queue_conn.transaction()?;
470        // v1.1.2 (Bug 4): `Transaction` derefs to `Connection`, so `&*tx` yields
471        // the `&Connection` the existing enqueue_candidate signature expects.
472        let tx_conn: &Connection = &tx;
473        for key in scan_result.iter() {
474            // v1.1.1 (P2): re-embed keys may be prefixed (`entity:` / `chunk:`);
475            // derive the row item_type from the key so prune-dead-orphans never
476            // mistakes an entity/chunk row for an orphaned memory.
477            let it = item_type_for_key(key, item_type);
478            enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
479        }
480        tx.commit()?;
481    }
482
483    let parallelism = super::events::resolve_drain_parallelism(args);
484
485    let mut completed = 0usize;
486    #[allow(unused_assignments)]
487    let mut failed = 0usize;
488    #[allow(unused_assignments)]
489    let mut skipped = 0usize;
490    #[allow(unused_assignments)]
491    let mut cost_total = 0.0f64;
492    #[allow(unused_assignments, unused_variables)]
493    let mut oauth_detected = false;
494    let mut counters = super::drain_parallel::DrainCounters::default();
495    let backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
496    let rate_limit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3600);
497    let enrich_started = std::time::Instant::now();
498
499    let provider_timeout = match args.mode() {
500        EnrichMode::ClaudeCode => args.claude_timeout,
501        EnrichMode::Codex => args.codex_timeout,
502        EnrichMode::Opencode => args.opencode_timeout,
503        EnrichMode::OpenRouter => args.openrouter_timeout,
504    };
505
506    let provider_model: Option<&str> = match args.mode() {
507        EnrichMode::ClaudeCode => args.claude_model.as_deref(),
508        EnrichMode::Codex => args.codex_model.as_deref(),
509        EnrichMode::Opencode => args.opencode_model.as_deref(),
510        EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
511    };
512
513    // GAP-SG-16: when --ignore-backoff is set, drop the per-item cooldown filter
514    // from candidate selection so items parked on `next_retry_at` are eligible
515    // immediately. Shared by the parallel workers and the serial loop.
516    let backoff_clause: &str = if args.ignore_backoff {
517        ""
518    } else {
519        "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
520    };
521
522    // GAP-SG-45: announce the scan-vs-drain concurrency split (scan is always
523    // serial; drain uses `parallelism` workers).
524    emit_json(&ConcurrencyEvent {
525        phase: "concurrency",
526        scan_parallelism: 1,
527        drain_parallelism: parallelism as u32,
528    });
529
530    // GAP-ENRICH-BACKLOG-CONVERGE: --until-empty wraps the scan→populate→drain
531    // cycle in an internal loop so the external bash retry loop is unnecessary.
532    // Without --until-empty the loop body runs exactly once (legacy behaviour).
533    //
534    // v1.1.06: `until_deadline` was already computed before the first scan so
535    // --max-runtime covers scan+drain. Skip the identical re-scan on the first
536    // until-empty iteration (candidates were just enqueued above).
537    let mut until_empty_iter: u32 = 0;
538    let yield_every = scheduler::resolve_yield_every_n(args.yield_every_n_items);
539    let mut yield_count: u64 = 0;
540    let mut items_since_yield: usize = 0;
541    // Wave 3: set when EC breaks to let HOT entity-descriptions run.
542    let mut preempted_for_gate = false;
543    // Workload: mixed — SQLite queue I/O is serial; LLM fan-out is bounded
544    // by host semaphore elsewhere. Yield/preempt keep gate ops responsive.
545    loop {
546        if args.until_empty {
547            until_empty_iter = until_empty_iter.saturating_add(1);
548            if until_empty_iter > 1 {
549                // Re-scan and re-enqueue eligible candidates each iteration.
550                // INSERT OR IGNORE never resurrects a dead-letter row (item_key is
551                // UNIQUE), so the backlog converges instead of looping forever.
552                let mut rescan =
553                    scan_operation_with_deadline(&conn, &namespace, args, Some(until_deadline))?;
554                // GAP-SG-69: drop memories already vetoed `status='skipped'` so the
555                // re-scan converges instead of re-enqueuing a non-expandable short
556                // body every iteration (body-enrich only; the verdict persists in
557                // the sidecar queue and is cleared by cleanup_queue_entry on edit).
558                if matches!(args.operation(), EnrichOperation::BodyEnrich) {
559                    if let Ok(vetoed) = skipped_item_keys(&queue_conn, &op_label) {
560                        rescan.retain(|k| !vetoed.contains(k));
561                    }
562                }
563                // v1.1.2 (Bug 4, D5): batch the re-scan INSERTs in one transaction.
564                {
565                    let tx = queue_conn.transaction()?;
566                    let tx_conn: &Connection = &tx;
567                    for key in &rescan {
568                        let it = item_type_for_key(key, item_type);
569                        enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
570                    }
571                    tx.commit()?;
572                }
573            }
574        }
575        let completed_before = completed;
576
577        // G19: when parallelism > 1, spawn bounded worker threads.
578        // Each worker opens its own DB connections (WAL supports concurrent readers + serialized writers).
579        // The queue DB claim is atomic via UPDATE...RETURNING — no external lock needed.
580        if parallelism > 1 {
581            super::drain_parallel::drain_parallel(
582                args,
583                &paths,
584                &queue_path,
585                &namespace,
586                provider_binary.as_deref(),
587                provider_model,
588                provider_timeout,
589                &op_label,
590                backoff_clause,
591                parallelism,
592                total,
593                llm_backend,
594                embedding_backend,
595                &mut counters,
596            )?;
597            completed = counters.completed;
598            failed = counters.failed;
599            skipped = counters.skipped;
600            cost_total = counters.cost_total;
601            oauth_detected = counters.oauth_detected;
602        } else {
603            super::drain_serial::drain_serial(
604                args,
605                &paths,
606                &conn,
607                &queue_conn,
608                &namespace,
609                provider_binary.as_deref(),
610                provider_model,
611                provider_timeout,
612                &op_label,
613                backoff_clause,
614                item_type,
615                total,
616                llm_backend,
617                embedding_backend,
618                yield_every,
619                &mut counters,
620                &mut items_since_yield,
621                &mut yield_count,
622                &mut preempted_for_gate,
623                enrich_started,
624                until_deadline,
625                rate_limit_deadline,
626                backoff_secs,
627            )?;
628            completed = counters.completed;
629            failed = counters.failed;
630            skipped = counters.skipped;
631            cost_total = counters.cost_total;
632            oauth_detected = counters.oauth_detected;
633        }
634
635        if !args.until_empty {
636            break;
637        }
638        let eligible_remaining: i64 = queue_conn
639            .query_row(
640                &format!("SELECT COUNT(*) FROM queue WHERE status='pending' {backoff_clause}"),
641                [],
642                |r| r.get(0),
643            )
644            .unwrap_or(0);
645        let progressed = completed > completed_before;
646        if std::time::Instant::now() >= until_deadline {
647            tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
648            break;
649        }
650        if !progressed && eligible_remaining == 0 {
651            tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
652            break;
653        }
654        if eligible_remaining == 0 {
655            // Remaining pending items are waiting on backoff; nap and re-check.
656            std::thread::sleep(std::time::Duration::from_secs(1));
657        }
658    } // end until-empty loop
659
660    // v1.1.2 (Bug 4 / Omissão 3): SIGTERM graceful cleanup. When a shutdown was
661    // requested mid-drain (the worker/serial loops already broke out), reset the
662    // in-flight `processing` claims back to `pending` so the NEXT run re-selects
663    // them — without this a kill recycles the rows via the startup stale-claim
664    // sweep (which waits `stale_claim_secs`), delaying recovery. Scoped to the
665    // enrich run (not the global signal handler) so unrelated code paths keep
666    // their existing exit semantics. Best-effort: errors are logged, not fatal.
667    if crate::shutdown_requested() {
668        let reset = queue_conn
669            .execute(
670                "UPDATE queue SET status='pending', claimed_at=NULL WHERE status='processing'",
671                [],
672            )
673            .unwrap_or(0);
674        let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
675        tracing::info!(
676            target: "enrich",
677            reset,
678            "graceful shutdown: WAL checkpointed, processing claims reset"
679        );
680    }
681
682    let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
683    let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
684
685    // GAP-SG-15: report items still in cooldown (waiting) and dead-lettered
686    // alongside completed, so `--until-empty` makes the convergence state
687    // explicit (cooldown vs. dead vs. truly empty) instead of just "done".
688    let waiting_final: i64 = queue_conn
689        .query_row(
690            "SELECT COUNT(*) FROM queue WHERE status='pending' \
691             AND (operation = ?1 OR operation IS NULL) \
692             AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
693            rusqlite::params![op_label],
694            |r| r.get(0),
695        )
696        .unwrap_or(0);
697    let dead_final: i64 = queue_conn
698        .query_row(
699            "SELECT COUNT(*) FROM queue WHERE status='dead' \
700             AND (operation = ?1 OR operation IS NULL)",
701            rusqlite::params![op_label],
702            |r| r.get(0),
703        )
704        .unwrap_or(0);
705
706    emit_json(&EnrichSummary {
707        summary: true,
708        operation: format!("{:?}", args.operation()),
709        items_total: total,
710        completed,
711        failed,
712        skipped,
713        cost_usd: cost_total,
714        elapsed_ms: started.elapsed().as_millis() as u64,
715        backend_invoked: take_enrich_backend(),
716        waiting: waiting_final,
717        dead: dead_final,
718        budget_exhausted: if pair_scan_ops && std::time::Instant::now() >= until_deadline {
719            Some(true)
720        } else {
721            None
722        },
723        pairs_remaining_estimate: backlog_degree0_proxy,
724        yields: if yield_count > 0 { Some(yield_count) } else { None },
725        preempted_for_gate: if preempted_for_gate {
726            Some(true)
727        } else {
728            None
729        },
730    });
731
732    if failed == 0 {
733        // GAP-ENRICH-BACKLOG-CONVERGE: keep the queue file when dead-letter rows
734        // exist so `enrich --status` can still report them on the next run.
735        let dead: i64 = queue_conn
736            .query_row("SELECT COUNT(*) FROM queue WHERE status='dead'", [], |r| {
737                r.get(0)
738            })
739            .unwrap_or(0);
740        // GAP-SG-69: keep the sidecar queue while it still holds `skipped`
741        // verdicts. Those rows tell the next scan which short bodies are
742        // non-expandable; removing the file would lose the veto and the
743        // body-enrich backlog would never converge. cleanup_queue_entry clears
744        // a row when its memory is edited/forgotten, so the veto is not permanent.
745        let skipped_remaining: i64 = queue_conn
746            .query_row(
747                "SELECT COUNT(*) FROM queue WHERE status='skipped'",
748                [],
749                |r| r.get(0),
750            )
751            .unwrap_or(0);
752        if dead == 0 && skipped_remaining == 0 {
753            let _ = std::fs::remove_file(&queue_path);
754        }
755    }
756
757    Ok(())
758}
759
760// EnrichItemResult + call_* functions moved to extraction.rs