Skip to main content

recall_echo/
serve_capture.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Background transcript capture inside the graph daemon.
6//!
7//! [`crate::serve_extract`] made entity extraction mechanical; this makes
8//! *arrival* mechanical for the CLIs that have no hooks. Claude Code archives
9//! itself on `SessionEnd`; Codex and Grok write a transcript to disk and tell
10//! nobody, so once the machine has been quiet the daemon reads what they wrote.
11//!
12//! It borrows the extraction worker's discipline and none of its schedule:
13//!
14//! - **The extraction worker is untouched.** This worker reads the same
15//!   [`IdleTracker`] to learn whether the machine is quiet and never writes to
16//!   it, so extraction's quiet period cannot be reset by a capture sweep. The
17//!   two are ordinary concurrent users of the daemon's store, like two clients.
18//! - **A hot request wins.** The batch stops at the next transcript boundary
19//!   when a client connects, and yields the runtime between transcripts.
20//! - **Crash-only, per transcript.** One transcript is archived, ingested and
21//!   marked before the next is read, so an interrupted sweep leaves complete
22//!   archives and work left to do.
23//! - **Shutdown wins.** Every wait and every import runs under
24//!   [`ShutdownSignal`].
25
26use std::collections::{HashMap, HashSet};
27use std::path::PathBuf;
28use std::sync::Arc;
29use std::time::{Duration, SystemTime};
30
31use async_trait::async_trait;
32
33use crate::capture::{self, CaptureOptions};
34use crate::error::RecallError;
35use crate::graph::{GraphMemory, IngestContext};
36use crate::serve::{BackgroundGuard, DaemonLog, IdleTracker, ShutdownSignal};
37use crate::transcript::{Source, Transcript, TranscriptRef};
38
39/// Transcripts one sweep imports before going back to waiting.
40const BATCH_SIZE: usize = 5;
41/// Longest the worker sleeps between quiet checks.
42const MAX_POLL: Duration = Duration::from_secs(30);
43/// Shortest it sleeps, so a short quiet period stays responsive without
44/// spinning.
45const MIN_POLL: Duration = Duration::from_millis(100);
46/// Attempts one transcript gets before the sweep moves past it. A transcript
47/// that cannot be parsed twice will not parse on the hundredth try either, and
48/// a stuck one must not block every transcript written after it.
49const MAX_ATTEMPTS: u32 = 2;
50
51// ── The unit of work ─────────────────────────────────────────────────────
52
53/// One CLI-transcript import, as the scheduler sees it.
54///
55/// Behind a trait so the scheduling — when it sweeps, when it yields, when it
56/// gives up on a file — is exercised without a store, a daemon or a CLI.
57#[async_trait]
58pub trait CaptureUnit: Send + Sync {
59    /// Finished, unarchived transcripts across the configured CLIs, oldest
60    /// first, at most `limit` of them.
61    async fn pending(&self, limit: usize) -> Vec<TranscriptRef>;
62
63    /// Archive one transcript and ingest the archive. `Ok(0)` means the
64    /// transcript held no user turn, so there was nothing to archive.
65    async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError>;
66
67    /// Record that the sweep is past this transcript. Called after a successful
68    /// import, and after a transcript is given up on.
69    async fn mark_swept(&self, transcript: &TranscriptRef);
70}
71
72// ── Schedule ─────────────────────────────────────────────────────────────
73
74/// When the worker sweeps.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct Schedule {
77    /// Quiet period before a sweep may start.
78    pub idle_after: Duration,
79    /// How often the worker re-checks whether the daemon is quiet.
80    pub poll_interval: Duration,
81}
82
83impl Schedule {
84    /// A schedule for a given quiet period.
85    ///
86    /// The quiet period is read from `[extraction] idle_after_secs` rather than
87    /// duplicated into `[capture]`: it answers a question about the *machine*
88    /// — is anybody working right now — and there is only one answer to that.
89    #[must_use]
90    pub fn after(idle_after: Duration) -> Self {
91        Self {
92            idle_after,
93            poll_interval: (idle_after / 4).clamp(MIN_POLL, MAX_POLL),
94        }
95    }
96}
97
98/// Whether background capture should run at all in this daemon.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum Plan {
101    /// Sweep these CLIs, on this schedule.
102    Run(Schedule, Vec<Source>),
103    /// Do not sweep, for this reason.
104    Off(String),
105}
106
107/// Decide whether this daemon captures transcripts in the background.
108///
109/// Server mode is refused for the same reason background extraction is: there,
110/// clients talk to SurrealDB directly and may never start a daemon at all, so a
111/// daemon's idea of "quiet" is a fact about an idle socket rather than about
112/// the user. `recall-echo ingest` remains the way to capture there.
113#[must_use]
114pub fn plan(config: &crate::config::Config, graph_mode: &str, sources: Vec<Source>) -> Plan {
115    if !config.capture.enabled {
116        return Plan::Off("[capture] enabled = false".into());
117    }
118    if graph_mode == "server" {
119        return Plan::Off("[graph] mode = \"server\" — use `recall-echo ingest`".into());
120    }
121    if sources.is_empty() {
122        return Plan::Off("no agent CLI transcripts found on this machine".into());
123    }
124    Plan::Run(Schedule::after(config.extraction.idle_after()), sources)
125}
126
127// ── Worker ───────────────────────────────────────────────────────────────
128
129/// Everything the worker shares with the rest of the daemon.
130pub struct WorkerContext {
131    pub idle: Arc<IdleTracker>,
132    pub shutdown: Arc<ShutdownSignal>,
133    pub log: Arc<DaemonLog>,
134}
135
136/// How a sweep ended.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138enum SweepOutcome {
139    /// Nothing to import.
140    NoWork,
141    /// At least one transcript was imported.
142    Worked,
143    /// Shutdown arrived.
144    Stopped,
145}
146
147/// The background capture loop.
148pub struct CaptureWorker {
149    schedule: Schedule,
150    context: WorkerContext,
151    /// Failed attempts per transcript, within this daemon's life.
152    attempts: HashMap<PathBuf, u32>,
153    /// Transcripts this daemon has given up on.
154    skipped: HashSet<PathBuf>,
155}
156
157impl CaptureWorker {
158    #[must_use]
159    pub fn new(schedule: Schedule, context: WorkerContext) -> Self {
160        Self {
161            schedule,
162            context,
163            attempts: HashMap::new(),
164            skipped: HashSet::new(),
165        }
166    }
167
168    /// Capture in the background until the daemon shuts down.
169    pub async fn run(mut self, unit: Arc<dyn CaptureUnit>) {
170        loop {
171            if self
172                .context
173                .shutdown
174                .sleep_until_stopped(self.schedule.poll_interval)
175                .await
176            {
177                return;
178            }
179            if !self.is_quiet() {
180                continue;
181            }
182            match self.run_sweep(unit.as_ref()).await {
183                SweepOutcome::NoWork | SweepOutcome::Worked => {}
184                SweepOutcome::Stopped => return,
185            }
186        }
187    }
188
189    fn is_quiet(&self) -> bool {
190        self.context
191            .idle
192            .is_quiet_at(std::time::Instant::now(), self.schedule.idle_after)
193    }
194
195    /// Import up to [`BATCH_SIZE`] transcripts, yielding between each.
196    async fn run_sweep(&mut self, unit: &dyn CaptureUnit) -> SweepOutcome {
197        let Some(pending) = self.take_pending(unit).await else {
198            return SweepOutcome::Stopped;
199        };
200        if pending.is_empty() {
201            return SweepOutcome::NoWork;
202        }
203
204        // From here on the daemon may not idle-exit: the sweep is in flight.
205        let _busy = BackgroundGuard::new(Arc::clone(&self.context.idle));
206        let mut outcome = SweepOutcome::NoWork;
207
208        for transcript in pending {
209            if self.context.shutdown.is_triggered() {
210                return SweepOutcome::Stopped;
211            }
212            // A hot request beats background work, always.
213            if self.context.idle.has_connections() {
214                break;
215            }
216
217            match self.context.shutdown.guard(unit.import(&transcript)).await {
218                None => return SweepOutcome::Stopped,
219                Some(Ok(log_number)) => {
220                    unit.mark_swept(&transcript).await;
221                    self.attempts.remove(&transcript.path);
222                    if log_number > 0 {
223                        outcome = SweepOutcome::Worked;
224                        self.context.log.log(&format!(
225                            "captured {} session {} as log {log_number:03}",
226                            transcript.source, transcript.session_id
227                        ));
228                    }
229                }
230                Some(Err(err)) => {
231                    // Stop the sweep here: the watermark still points before
232                    // this transcript, so the next quiet period retries it
233                    // rather than silently walking past it.
234                    self.record_failure(unit, &transcript, &err).await;
235                    break;
236                }
237            }
238
239            // Hand the runtime back between transcripts so a request that
240            // arrived mid-sweep is served before the next file is read.
241            tokio::task::yield_now().await;
242        }
243
244        outcome
245    }
246
247    /// Transcripts worth attempting, or `None` when shutdown arrived.
248    async fn take_pending(&mut self, unit: &dyn CaptureUnit) -> Option<Vec<TranscriptRef>> {
249        // Over-fetch, then drop what this daemon has given up on, so a
250        // quarantined transcript cannot occupy a batch slot forever.
251        let limit = BATCH_SIZE + self.skipped.len();
252        let pending = self.context.shutdown.guard(unit.pending(limit)).await?;
253        Some(
254            pending
255                .into_iter()
256                .filter(|transcript| !self.skipped.contains(&transcript.path))
257                .take(BATCH_SIZE)
258                .collect(),
259        )
260    }
261
262    /// Account for a transcript that would not import.
263    async fn record_failure(
264        &mut self,
265        unit: &dyn CaptureUnit,
266        transcript: &TranscriptRef,
267        err: &RecallError,
268    ) {
269        let attempts = self.attempts.entry(transcript.path.clone()).or_insert(0);
270        *attempts += 1;
271        if *attempts >= MAX_ATTEMPTS {
272            self.skipped.insert(transcript.path.clone());
273            unit.mark_swept(transcript).await;
274            self.context.log.log(&format!(
275                "capture gave up on {} session {} after {MAX_ATTEMPTS} attempts: {err}",
276                transcript.source, transcript.session_id
277            ));
278        } else {
279            self.context.log.log(&format!(
280                "capture failed on {} session {}: {err}",
281                transcript.source, transcript.session_id
282            ));
283        }
284    }
285}
286
287// ── The real unit: transcripts on disk, archives, the daemon's own store ──
288
289/// Capture against the daemon's own store.
290///
291/// Archiving is exactly what `recall-echo ingest` does; ingestion is exactly
292/// what a [`crate::serve::Request::IngestArchive`] does, minus the socket —
293/// the daemon does not connect to itself.
294pub struct GraphCaptureUnit {
295    memory_dir: PathBuf,
296    graph: Arc<GraphMemory>,
297    adapters: Vec<Box<dyn Transcript>>,
298    settle: Duration,
299}
300
301impl GraphCaptureUnit {
302    #[must_use]
303    pub fn new(
304        memory_dir: PathBuf,
305        graph: Arc<GraphMemory>,
306        adapters: Vec<Box<dyn Transcript>>,
307        settle: Duration,
308    ) -> Self {
309        Self {
310            memory_dir,
311            graph,
312            adapters,
313            settle,
314        }
315    }
316
317    fn options(&self) -> CaptureOptions {
318        CaptureOptions {
319            settle: self.settle,
320            now: SystemTime::now(),
321        }
322    }
323
324    fn adapter_for(&self, source: Source) -> Option<&dyn Transcript> {
325        self.adapters
326            .iter()
327            .find(|adapter| adapter.source() == source)
328            .map(AsRef::as_ref)
329    }
330}
331
332#[async_trait]
333impl CaptureUnit for GraphCaptureUnit {
334    async fn pending(&self, limit: usize) -> Vec<TranscriptRef> {
335        let archived = capture::archived_sessions(&self.memory_dir);
336        let options = self.options();
337        let mut ready = Vec::new();
338        for adapter in &self.adapters {
339            match capture::pending(&self.memory_dir, adapter.as_ref(), &archived, options) {
340                Ok(found) => ready.extend(found.ready),
341                Err(err) => eprintln!("recall-echo: capture discovery failed: {err}"),
342            }
343        }
344        ready.sort_by_key(|transcript| transcript.modified);
345        ready.truncate(limit);
346        ready
347    }
348
349    async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError> {
350        let adapter = self
351            .adapter_for(transcript.source)
352            .ok_or_else(|| RecallError::Other(format!("no adapter for {}", transcript.source)))?;
353        let archived = capture::archived_sessions(&self.memory_dir);
354        let Some(result) =
355            capture::archive_transcript(&self.memory_dir, adapter, transcript, &archived)?
356        else {
357            return Ok(0);
358        };
359        if result.log_number == 0 {
360            return Ok(0);
361        }
362
363        let context = IngestContext::new(result.session_id.clone(), Some(result.log_number));
364        self.graph
365            .ingest_archive(&result.full_content, &context, None)
366            .await?;
367        Ok(result.log_number)
368    }
369
370    async fn mark_swept(&self, transcript: &TranscriptRef) {
371        capture::write_watermark(&self.memory_dir, transcript.source, transcript.modified);
372    }
373}
374
375// ── Wiring ───────────────────────────────────────────────────────────────
376
377/// Everything [`spawn`] needs from the daemon.
378pub struct Setup {
379    pub memory_dir: PathBuf,
380    pub graph: Arc<GraphMemory>,
381    pub idle: Arc<IdleTracker>,
382    pub shutdown: Arc<ShutdownSignal>,
383    pub log: Arc<DaemonLog>,
384}
385
386/// Start the background capture worker, if this config wants one.
387///
388/// Every refusal is quiet, final and stated once, in the daemon log.
389pub fn spawn(setup: Setup) -> Option<tokio::task::JoinHandle<()>> {
390    let config = crate::config::load_from_dir(&setup.memory_dir);
391    let mode = crate::serve_client::graph_mode(&setup.memory_dir);
392
393    let sources = capture::configured_sources(&config.capture);
394    let (schedule, sources) = match plan(&config, &mode, sources) {
395        Plan::Run(schedule, sources) => (schedule, sources),
396        Plan::Off(reason) => {
397            setup.log.log(&format!("background capture off: {reason}"));
398            return None;
399        }
400    };
401
402    if !setup.memory_dir.join("conversations").exists() {
403        setup
404            .log
405            .log("background capture off: no conversations/ directory to archive into");
406        return None;
407    }
408
409    let adapters: Vec<Box<dyn Transcript>> = sources
410        .iter()
411        .filter_map(|source| crate::transcript::adapter_for(*source))
412        .collect();
413    if adapters.is_empty() {
414        setup
415            .log
416            .log("background capture off: none of the configured CLIs could be located");
417        return None;
418    }
419
420    let names: Vec<String> = adapters
421        .iter()
422        .map(|adapter| adapter.source().to_string())
423        .collect();
424    setup.log.log(&format!(
425        "background capture on: {}, every {}s of quiet, transcripts idle for {}s",
426        names.join(", "),
427        schedule.idle_after.as_secs(),
428        config.capture.settle_secs,
429    ));
430
431    let unit: Arc<dyn CaptureUnit> = Arc::new(GraphCaptureUnit::new(
432        setup.memory_dir.clone(),
433        Arc::clone(&setup.graph),
434        adapters,
435        config.capture.settle(),
436    ));
437    let worker = CaptureWorker::new(
438        schedule,
439        WorkerContext {
440            idle: setup.idle,
441            shutdown: setup.shutdown,
442            log: setup.log,
443        },
444    );
445    Some(tokio::spawn(worker.run(unit)))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::sync::Mutex;
452
453    fn config(enabled: bool) -> crate::config::Config {
454        crate::config::Config {
455            capture: crate::config::CaptureSection {
456                enabled,
457                ..crate::config::CaptureSection::default()
458            },
459            ..crate::config::Config::default()
460        }
461    }
462
463    #[test]
464    fn the_default_plan_sweeps_the_installed_clis() {
465        let Plan::Run(schedule, sources) = plan(&config(true), "embedded", vec![Source::Codex])
466        else {
467            panic!("the default config must sweep");
468        };
469        assert_eq!(schedule.idle_after, Duration::from_secs(120));
470        assert_eq!(schedule.poll_interval, Duration::from_secs(30));
471        assert_eq!(sources, vec![Source::Codex]);
472    }
473
474    #[test]
475    fn opting_out_turns_the_worker_off() {
476        let Plan::Off(reason) = plan(&config(false), "embedded", vec![Source::Codex]) else {
477            panic!("enabled = false must be honored");
478        };
479        assert!(reason.contains("enabled"), "{reason}");
480    }
481
482    #[test]
483    fn server_mode_never_captures_in_the_background() {
484        let Plan::Off(reason) = plan(&config(true), "server", vec![Source::Codex]) else {
485            panic!("server mode has no daemon to schedule against");
486        };
487        assert!(reason.contains("server"), "{reason}");
488    }
489
490    #[test]
491    fn a_machine_with_no_agent_clis_has_nothing_to_sweep() {
492        let Plan::Off(reason) = plan(&config(true), "embedded", Vec::new()) else {
493            panic!("no sources means no worker");
494        };
495        assert!(reason.contains("no agent CLI"), "{reason}");
496    }
497
498    // ── Scheduling, against a fake unit ──────────────────────────────────
499
500    #[derive(Default)]
501    struct FakeState {
502        imported: Vec<String>,
503        swept: Vec<String>,
504        failing: HashSet<String>,
505    }
506
507    struct FakeUnit {
508        transcripts: Vec<TranscriptRef>,
509        state: Mutex<FakeState>,
510    }
511
512    impl FakeUnit {
513        fn new(ids: &[&str], failing: &[&str]) -> Self {
514            let transcripts = ids
515                .iter()
516                .enumerate()
517                .map(|(index, id)| TranscriptRef {
518                    source: Source::Codex,
519                    session_id: (*id).to_string(),
520                    path: PathBuf::from(format!("/tmp/{id}.jsonl")),
521                    modified: SystemTime::UNIX_EPOCH + Duration::from_secs(index as u64),
522                    cwd: None,
523                })
524                .collect();
525            Self {
526                transcripts,
527                state: Mutex::new(FakeState {
528                    failing: failing.iter().map(|id| (*id).to_string()).collect(),
529                    ..FakeState::default()
530                }),
531            }
532        }
533
534        fn imported(&self) -> Vec<String> {
535            self.state.lock().unwrap().imported.clone()
536        }
537
538        fn swept(&self) -> Vec<String> {
539            self.state.lock().unwrap().swept.clone()
540        }
541    }
542
543    #[async_trait]
544    impl CaptureUnit for FakeUnit {
545        async fn pending(&self, limit: usize) -> Vec<TranscriptRef> {
546            let swept = self.state.lock().unwrap().swept.clone();
547            self.transcripts
548                .iter()
549                .filter(|t| !swept.contains(&t.session_id))
550                .take(limit)
551                .cloned()
552                .collect()
553        }
554
555        async fn import(&self, transcript: &TranscriptRef) -> Result<u32, RecallError> {
556            let mut state = self.state.lock().unwrap();
557            if state.failing.contains(&transcript.session_id) {
558                return Err(RecallError::Other("unreadable".into()));
559            }
560            state.imported.push(transcript.session_id.clone());
561            Ok(state.imported.len() as u32)
562        }
563
564        async fn mark_swept(&self, transcript: &TranscriptRef) {
565            self.state
566                .lock()
567                .unwrap()
568                .swept
569                .push(transcript.session_id.clone());
570        }
571    }
572
573    fn worker(idle: &Arc<IdleTracker>, shutdown: &Arc<ShutdownSignal>) -> CaptureWorker {
574        CaptureWorker::new(
575            Schedule::after(Duration::from_secs(0)),
576            WorkerContext {
577                idle: Arc::clone(idle),
578                shutdown: Arc::clone(shutdown),
579                // These tests exercise scheduling, not logging.
580                log: Arc::new(DaemonLog::open(std::path::Path::new("/dev/null"), false)),
581            },
582        )
583    }
584
585    #[tokio::test]
586    async fn a_sweep_imports_and_marks_every_ready_transcript() {
587        let idle = Arc::new(IdleTracker::new(None));
588        let shutdown = Arc::new(ShutdownSignal::new());
589        let unit = FakeUnit::new(&["a", "b"], &[]);
590
591        let mut worker = worker(&idle, &shutdown);
592        assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::Worked);
593
594        assert_eq!(unit.imported(), ["a", "b"]);
595        assert_eq!(unit.swept(), ["a", "b"]);
596    }
597
598    #[tokio::test]
599    async fn nothing_to_import_is_not_work() {
600        let idle = Arc::new(IdleTracker::new(None));
601        let shutdown = Arc::new(ShutdownSignal::new());
602        let unit = FakeUnit::new(&[], &[]);
603
604        let mut worker = worker(&idle, &shutdown);
605        assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::NoWork);
606    }
607
608    /// A failure must not carry the sweep past the transcript that failed —
609    /// the watermark is what would then skip it forever.
610    #[tokio::test]
611    async fn a_failure_stops_the_sweep_without_marking_it_swept() {
612        let idle = Arc::new(IdleTracker::new(None));
613        let shutdown = Arc::new(ShutdownSignal::new());
614        let unit = FakeUnit::new(&["a", "bad", "c"], &["bad"]);
615
616        let mut worker = worker(&idle, &shutdown);
617        worker.run_sweep(&unit).await;
618
619        assert_eq!(unit.imported(), ["a"]);
620        assert_eq!(unit.swept(), ["a"]);
621    }
622
623    /// …but it must not block the queue forever either.
624    #[tokio::test]
625    async fn a_transcript_that_never_imports_is_given_up_on() {
626        let idle = Arc::new(IdleTracker::new(None));
627        let shutdown = Arc::new(ShutdownSignal::new());
628        let unit = FakeUnit::new(&["bad", "c"], &["bad"]);
629
630        let mut worker = worker(&idle, &shutdown);
631        for _ in 0..MAX_ATTEMPTS {
632            worker.run_sweep(&unit).await;
633        }
634        worker.run_sweep(&unit).await;
635
636        assert_eq!(unit.imported(), ["c"]);
637        assert!(unit.swept().contains(&"bad".to_string()));
638    }
639
640    #[tokio::test]
641    async fn shutdown_ends_the_sweep_immediately() {
642        let idle = Arc::new(IdleTracker::new(None));
643        let shutdown = Arc::new(ShutdownSignal::new());
644        shutdown.trigger();
645        let unit = FakeUnit::new(&["a"], &[]);
646
647        let mut worker = worker(&idle, &shutdown);
648        assert_eq!(worker.run_sweep(&unit).await, SweepOutcome::Stopped);
649        assert!(unit.imported().is_empty());
650    }
651
652    /// The whole point of the separation: capture never resets the clock the
653    /// extraction worker schedules against.
654    #[tokio::test]
655    async fn a_sweep_does_not_disturb_the_quiet_clock() {
656        let start = std::time::Instant::now();
657        let idle = Arc::new(IdleTracker::new_at(None, start));
658        let shutdown = Arc::new(ShutdownSignal::new());
659        let unit = FakeUnit::new(&["a", "b"], &[]);
660
661        let mut worker = worker(&idle, &shutdown);
662        worker.run_sweep(&unit).await;
663
664        // Quiet since `start`, still quiet now: nothing in the sweep touched it.
665        assert!(idle.is_quiet_at(start + Duration::from_secs(120), Duration::from_secs(60)));
666    }
667}