Skip to main content

scv_tools/delegate/
conversation.rs

1//! Multi-turn conversations with delegated agents.
2//!
3//! A session's conversations live in one `ConversationStore` shared by its
4//! agents' backends, and end with the session. The model sees only SCV-issued
5//! handles such as `codex-2`; the CLI's own session IDs stay here and are
6//! never accepted from the model. While a conversation exists, a marker file
7//! named after its session ID tells `scv agents gc` to keep its transcript.
8
9use std::{
10    any::Any,
11    collections::HashMap,
12    fmt,
13    path::{Path, PathBuf},
14    sync::{Arc, Mutex},
15    time::{Duration, Instant, SystemTime},
16};
17
18use scv_core::ToolError;
19use serde::{Deserialize, Serialize};
20use tokio::sync::Notify;
21use tokio_util::sync::CancellationToken;
22
23use crate::{
24    delegate::{
25        adapters::{self, ConversationFiles},
26        output::valid_session_id,
27        records::{ProcessIdentity, write_private_json},
28    },
29    sync::lock,
30};
31
32/// Transcripts younger than this are never removed, so a turn that is still
33/// running (and so still writing its transcript) is safe from `gc`.
34pub(crate) const MIN_GC_AGE: Duration = Duration::from_secs(3600);
35
36/// Longest handle accepted from the model.
37const MAX_HANDLE_BYTES: usize = 64;
38
39/// How the error for a session whose conversations are all in use begins
40/// after its count, which a reviewed job reads as "no room for a reviewer".
41pub(crate) const ALL_IN_USE: &str = "conversations of this session are running a turn";
42
43#[derive(Debug, Clone, Copy)]
44pub struct ConversationLimits {
45    /// Conversations remembered per session; starting another forgets the
46    /// least recently used idle one.
47    pub max: usize,
48    /// A conversation unused this long is forgotten.
49    pub idle: Duration,
50}
51
52/// What a live conversation keeps between turns, such as its running child
53/// process. Dropping the last reference (when the conversation is forgotten,
54/// expires, or its session ends) is what shuts the child down.
55#[derive(Clone)]
56pub(crate) struct Attachment(pub(crate) Arc<dyn Any + Send + Sync>);
57
58impl fmt::Debug for Attachment {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str("Attachment")
61    }
62}
63
64#[derive(Debug)]
65struct Conversation {
66    agent: String,
67    cwd: PathBuf,
68    /// The CLI's session ID, once known.
69    vendor: Option<String>,
70    turns: u32,
71    busy: bool,
72    last_used: Instant,
73    attachment: Option<Attachment>,
74    /// Reviewed jobs holding it ([`PinGuard`]): it is neither evicted nor
75    /// expired.
76    pins: u32,
77    /// Released while a turn ran: it goes when that turn ends.
78    released: bool,
79}
80
81#[derive(Debug, Default)]
82struct Inner {
83    conversations: HashMap<String, Conversation>,
84    /// Next handle number per agent.
85    next: HashMap<String, u32>,
86}
87
88/// One session's conversations with its delegated agents.
89#[derive(Debug)]
90pub(crate) struct ConversationStore {
91    limits: ConversationLimits,
92    marker_dir: Option<PathBuf>,
93    owner: Option<ProcessIdentity>,
94    inner: Mutex<Inner>,
95    /// Woken whenever a turn ends.
96    changed: Notify,
97}
98
99/// On-disk marker keeping a live conversation's transcript from `gc`.
100#[derive(Debug, Serialize, Deserialize)]
101struct Marker {
102    owner: ProcessIdentity,
103    agent: String,
104    handle: String,
105}
106
107/// A turn in progress. Finish it with [`TurnGuard::finish`]; dropping it
108/// instead (a cancelled or abandoned call) frees the conversation.
109#[derive(Debug)]
110pub(crate) struct TurnGuard {
111    store: Arc<ConversationStore>,
112    pub(crate) handle: String,
113    pub(crate) turn: u32,
114    /// Continuing: the known session ID. Starting: the ID SCV chose, if any.
115    pub(crate) vendor: Option<String>,
116    /// Continuing: what the conversation kept. Starting: what
117    /// [`TurnGuard::attach`] set, stored when the turn finishes.
118    attachment: Option<Attachment>,
119    finished: bool,
120}
121
122/// A reviewed job's hold on a conversation, keeping it out of eviction and
123/// idle expiry until dropped.
124#[derive(Debug)]
125pub(crate) struct PinGuard {
126    store: Arc<ConversationStore>,
127    handle: String,
128}
129
130impl Drop for PinGuard {
131    fn drop(&mut self) {
132        if let Some(conversation) = lock(&self.store.inner).conversations.get_mut(&self.handle) {
133            conversation.pins = conversation.pins.saturating_sub(1);
134        }
135    }
136}
137
138/// Whether `value` has the shape of an SCV conversation handle
139/// (`<agent>-<number>`). CLI session IDs never do.
140pub(crate) fn is_handle(value: &str) -> bool {
141    handle_agent(value).is_some()
142}
143
144/// The agent whose conversation `handle` names, such as `codex` for
145/// `codex-2`, when it has the shape of a handle.
146pub(crate) fn handle_agent(handle: &str) -> Option<&str> {
147    if handle.len() > MAX_HANDLE_BYTES {
148        return None;
149    }
150    let (agent, number) = handle.rsplit_once('-')?;
151    (!number.is_empty()
152        && number.bytes().all(|byte| byte.is_ascii_digit())
153        && adapters::adapter(agent).is_some())
154    .then_some(agent)
155}
156
157impl ConversationStore {
158    /// `marker_dir` is `$SCV_HOME/state/conversations`; `None` keeps no markers.
159    pub(crate) fn new(limits: ConversationLimits, marker_dir: Option<PathBuf>) -> Self {
160        Self {
161            limits,
162            marker_dir,
163            owner: ProcessIdentity::current(),
164            inner: Mutex::new(Inner::default()),
165            changed: Notify::new(),
166        }
167    }
168
169    /// How many conversations the session remembers, and for how long.
170    pub(crate) fn limits(&self) -> ConversationLimits {
171        self.limits
172    }
173
174    /// Keep conversation `handle` from eviction and idle expiry until the
175    /// guard drops; `None` when the session does not have it.
176    pub(crate) fn pin(self: &Arc<Self>, handle: &str) -> Option<PinGuard> {
177        let mut inner = lock(&self.inner);
178        let conversation = inner.conversations.get_mut(handle)?;
179        conversation.pins += 1;
180        Some(PinGuard {
181            store: Arc::clone(self),
182            handle: handle.to_owned(),
183        })
184    }
185
186    /// End conversation `handle`, as for a reviewer whose round is over:
187    /// its marker goes and its attachment drops, which stops a live ACP
188    /// server or nested SCV. One running a turn goes when the turn ends.
189    pub(crate) fn release(&self, handle: &str) {
190        let mut inner = lock(&self.inner);
191        let Some(conversation) = inner.conversations.get_mut(handle) else {
192            return;
193        };
194        if conversation.busy {
195            conversation.released = true;
196            return;
197        }
198        let removed = inner.conversations.remove(handle);
199        drop(inner);
200        self.remove_marker(
201            removed
202                .and_then(|conversation| conversation.vendor)
203                .as_deref(),
204        );
205    }
206
207    /// Whether conversation `handle` is running a turn.
208    pub(crate) fn is_busy(&self, handle: &str) -> bool {
209        lock(&self.inner)
210            .conversations
211            .get(handle)
212            .is_some_and(|c| c.busy)
213    }
214
215    /// What conversation `handle` keeps between turns, if anything.
216    pub(crate) fn attachment(&self, handle: &str) -> Option<Attachment> {
217        lock(&self.inner)
218            .conversations
219            .get(handle)
220            .and_then(|c| c.attachment.clone())
221    }
222
223    /// Wait until conversation `handle` runs no turn.
224    pub(crate) async fn wait_idle(
225        &self,
226        handle: &str,
227        cancellation: &CancellationToken,
228    ) -> Result<(), ToolError> {
229        loop {
230            let notified = self.changed.notified();
231            if !self.is_busy(handle) {
232                return Ok(());
233            }
234            tokio::select! {
235                () = notified => {},
236                () = cancellation.cancelled() => return Err(ToolError::cancelled("waiting for delegated conversation")),
237            }
238        }
239    }
240
241    /// Start a turn: a new conversation when `handle` is `None`, otherwise
242    /// the next turn of that conversation. `assign_id` has SCV choose the
243    /// CLI's session ID for a new conversation.
244    pub(crate) fn begin(
245        self: &Arc<Self>,
246        agent: &str,
247        handle: Option<&str>,
248        cwd: &Path,
249        assign_id: bool,
250    ) -> Result<TurnGuard, ToolError> {
251        let mut inner = lock(&self.inner);
252        let now = Instant::now();
253        let expired: Vec<String> = inner
254            .conversations
255            .iter()
256            .filter(|(_, conversation)| {
257                !conversation.busy
258                    && conversation.pins == 0
259                    && now.duration_since(conversation.last_used) >= self.limits.idle
260            })
261            .map(|(handle, _)| handle.clone())
262            .collect();
263        for handle in &expired {
264            if let Some(conversation) = inner.conversations.remove(handle) {
265                self.remove_marker(conversation.vendor.as_deref());
266            }
267        }
268        let Some(handle) = handle else {
269            return self.start(&mut inner, agent, cwd, assign_id, now);
270        };
271        if !is_handle(handle) {
272            return Err(ToolError::invalid_arguments(format!(
273                "session {:?} is not a conversation handle; pass the `session` value an \
274                 earlier {agent} call returned, or omit it to start a new conversation",
275                crate::args::bounded(handle, 80)
276            )));
277        }
278        let Some(conversation) = inner.conversations.get_mut(handle) else {
279            let reason = if expired.iter().any(|expired| expired == handle) {
280                format!(
281                    "was forgotten after {} seconds idle",
282                    self.limits.idle.as_secs()
283                )
284            } else {
285                "is unknown in this session".to_owned()
286            };
287            return Err(ToolError::invalid_arguments(format!(
288                "conversation {handle} {reason}; omit session to start a new conversation"
289            )));
290        };
291        if conversation.agent != agent {
292            return Err(ToolError::invalid_arguments(format!(
293                "conversation {handle} belongs to {}, not {agent}",
294                conversation.agent
295            )));
296        }
297        if conversation.busy {
298            return Err(ToolError::failed(format!(
299                "session busy: conversation {handle} is still running a turn"
300            )));
301        }
302        if conversation.cwd != cwd {
303            return Err(ToolError::invalid_arguments(format!(
304                "conversation {handle} runs in {:?}; continue it there or omit session to \
305                 start a new conversation in {:?}",
306                conversation.cwd, cwd
307            )));
308        }
309        let Some(vendor) = conversation.vendor.clone() else {
310            return Err(ToolError::failed(format!(
311                "conversation {handle} cannot be continued: the agent reported no session"
312            )));
313        };
314        conversation.busy = true;
315        conversation.last_used = now;
316        Ok(TurnGuard {
317            store: Arc::clone(self),
318            handle: handle.to_owned(),
319            turn: conversation.turns + 1,
320            vendor: Some(vendor),
321            attachment: conversation.attachment.clone(),
322            finished: false,
323        })
324    }
325
326    fn start(
327        self: &Arc<Self>,
328        inner: &mut Inner,
329        agent: &str,
330        cwd: &Path,
331        assign_id: bool,
332        now: Instant,
333    ) -> Result<TurnGuard, ToolError> {
334        while inner.conversations.len() >= self.limits.max.max(1) {
335            let Some(oldest) = inner
336                .conversations
337                .iter()
338                .filter(|(_, conversation)| !conversation.busy && conversation.pins == 0)
339                .min_by_key(|(_, conversation)| conversation.last_used)
340                .map(|(handle, _)| handle.clone())
341            else {
342                let held = if inner.conversations.values().any(|c| c.pins > 0) {
343                    " or held by a reviewed job"
344                } else {
345                    ""
346                };
347                return Err(ToolError::limit(format!(
348                    "all {} {ALL_IN_USE}{held}",
349                    inner.conversations.len()
350                )));
351            };
352            if let Some(conversation) = inner.conversations.remove(&oldest) {
353                self.remove_marker(conversation.vendor.as_deref());
354            }
355        }
356        let number = inner.next.entry(agent.to_owned()).or_insert(0);
357        *number += 1;
358        let handle = format!("{agent}-{number}");
359        let vendor = assign_id.then(|| uuid::Uuid::new_v4().to_string());
360        inner.conversations.insert(
361            handle.clone(),
362            Conversation {
363                agent: agent.to_owned(),
364                cwd: cwd.to_owned(),
365                vendor: None,
366                turns: 0,
367                busy: true,
368                last_used: now,
369                attachment: None,
370                pins: 0,
371                released: false,
372            },
373        );
374        if let Some(vendor) = &vendor {
375            self.write_marker(vendor, agent, &handle);
376        }
377        Ok(TurnGuard {
378            store: Arc::clone(self),
379            handle,
380            turn: 1,
381            vendor,
382            attachment: None,
383            finished: false,
384        })
385    }
386
387    fn write_marker(&self, vendor: &str, agent: &str, handle: &str) {
388        let (Some(dir), Some(owner)) = (&self.marker_dir, self.owner) else {
389            return;
390        };
391        let marker = Marker {
392            owner,
393            agent: agent.to_owned(),
394            handle: handle.to_owned(),
395        };
396        // A missing marker only makes `gc` less careful; it never fails a turn.
397        let _ = write_private_json(dir, &format!("{vendor}.json"), &marker);
398    }
399
400    fn remove_marker(&self, vendor: Option<&str>) {
401        if let (Some(dir), Some(vendor)) = (&self.marker_dir, vendor) {
402            let _ = std::fs::remove_file(dir.join(format!("{vendor}.json")));
403        }
404    }
405
406    /// Handles this session remembers, for tests.
407    #[cfg(test)]
408    pub(crate) fn handles(&self) -> Vec<String> {
409        let mut handles: Vec<_> = lock(&self.inner).conversations.keys().cloned().collect();
410        handles.sort();
411        handles
412    }
413}
414
415impl Drop for ConversationStore {
416    fn drop(&mut self) {
417        let inner = self.inner.get_mut().expect("conversation lock");
418        let vendors: Vec<_> = inner
419            .conversations
420            .values()
421            .filter_map(|conversation| conversation.vendor.clone())
422            .collect();
423        for vendor in vendors {
424            self.remove_marker(Some(&vendor));
425        }
426    }
427}
428
429impl TurnGuard {
430    /// What the conversation keeps between turns, if anything.
431    pub(crate) fn attachment(&self) -> Option<&Attachment> {
432        self.attachment.as_ref()
433    }
434
435    /// Keep `attachment` with the conversation once this turn finishes.
436    pub(crate) fn attach(&mut self, attachment: Attachment) {
437        self.attachment = Some(attachment);
438    }
439
440    /// End the conversation now, for one that cannot go on (its live child
441    /// exited). Its attachment is dropped.
442    pub(crate) fn forget(mut self) {
443        self.finished = true;
444        let removed = lock(&self.store.inner).conversations.remove(&self.handle);
445        self.store.remove_marker(
446            removed
447                .and_then(|conversation| conversation.vendor)
448                .as_deref(),
449        );
450        self.store.remove_marker(self.vendor.as_deref());
451    }
452
453    /// Settle the turn. `reported` is the session ID the CLI printed. Returns
454    /// the handle when the conversation can be continued; a first turn that
455    /// failed without the CLI reporting a session is forgotten.
456    pub(crate) fn finish(mut self, reported: Option<String>, completed: bool) -> Option<String> {
457        self.finished = true;
458        let reported = reported.filter(|id| valid_session_id(id));
459        let mut inner = lock(&self.store.inner);
460        let first = self.turn == 1;
461        if first && reported.is_none() && !completed {
462            inner.conversations.remove(&self.handle);
463            drop(inner);
464            self.store.remove_marker(self.vendor.as_deref());
465            return None;
466        }
467        let vendor = reported.or_else(|| self.vendor.clone());
468        let conversation = inner.conversations.get_mut(&self.handle)?;
469        if conversation.released {
470            let removed = inner.conversations.remove(&self.handle);
471            drop(inner);
472            self.store.remove_marker(
473                removed
474                    .and_then(|conversation| conversation.vendor)
475                    .as_deref(),
476            );
477            self.store.remove_marker(vendor.as_deref());
478            return None;
479        }
480        if let Some(attachment) = self.attachment.take() {
481            conversation.attachment = Some(attachment);
482        }
483        conversation.busy = false;
484        conversation.turns = self.turn;
485        conversation.last_used = Instant::now();
486        let previous = std::mem::replace(&mut conversation.vendor, vendor.clone());
487        let agent = conversation.agent.clone();
488        drop(inner);
489        if previous != vendor {
490            self.store.remove_marker(previous.as_deref());
491        }
492        match vendor {
493            Some(vendor) => {
494                self.store.write_marker(&vendor, &agent, &self.handle);
495                Some(self.handle.clone())
496            }
497            None => None,
498        }
499    }
500}
501
502impl Drop for TurnGuard {
503    fn drop(&mut self) {
504        if !self.finished {
505            let mut inner = lock(&self.store.inner);
506            if self.turn == 1 {
507                inner.conversations.remove(&self.handle);
508                drop(inner);
509                self.store.remove_marker(self.vendor.as_deref());
510            } else if let Some(conversation) = inner.conversations.get_mut(&self.handle) {
511                conversation.busy = false;
512                if conversation.released {
513                    let removed = inner.conversations.remove(&self.handle);
514                    drop(inner);
515                    self.store.remove_marker(
516                        removed
517                            .and_then(|conversation| conversation.vendor)
518                            .as_deref(),
519                    );
520                }
521            }
522        }
523        // However the turn ended, its conversation runs none now.
524        self.store.changed.notify_waiters();
525    }
526}
527
528/// What `scv agents gc` found for one agent.
529#[derive(Debug, Default, Clone, PartialEq, Eq)]
530pub struct GcReport {
531    /// Transcripts removed, or that would be with `dry_run`.
532    pub removed: Vec<PathBuf>,
533    pub bytes: u64,
534    /// Old transcripts kept because a live conversation still uses them.
535    pub kept_live: usize,
536}
537
538/// Remove transcripts under `adapter_home` last modified at least
539/// `older_than` ago, keeping any whose name carries the session ID of a live
540/// conversation (a marker in `marker_dir` whose owning process runs).
541/// Symlinks are never followed. Markers of dead owners are removed unless
542/// `dry_run`.
543pub fn collect_garbage(
544    adapter_home: &Path,
545    files: ConversationFiles,
546    marker_dir: &Path,
547    older_than: Duration,
548    dry_run: bool,
549) -> std::io::Result<GcReport> {
550    let older_than = older_than.max(MIN_GC_AGE);
551    let live = live_sessions(marker_dir, dry_run);
552    let root = adapter_home.join(files.dir);
553    let mut report = GcReport::default();
554    let Some(cutoff) = SystemTime::now().checked_sub(older_than) else {
555        return Ok(report);
556    };
557    let mut transcripts = Vec::new();
558    walk(&root, files.extension, &mut transcripts)?;
559    transcripts.sort();
560    for (path, modified, bytes) in transcripts {
561        if modified > cutoff {
562            continue;
563        }
564        let relative = path.strip_prefix(&root).unwrap_or(&path);
565        let in_use = relative.components().any(|component| {
566            let name = component.as_os_str().to_string_lossy();
567            live.iter().any(|id| name.contains(id.as_str()))
568        });
569        if in_use {
570            report.kept_live += 1;
571            continue;
572        }
573        if !dry_run {
574            std::fs::remove_file(&path)?;
575        }
576        report.bytes += bytes;
577        report.removed.push(path);
578    }
579    if !dry_run {
580        remove_empty_dirs(&root, &root);
581    }
582    Ok(report)
583}
584
585/// Remove markers whose owning SCV process no longer runs, returning how
586/// many were removed. A short-lived `scv exec` leaves its markers behind when
587/// it exits; the daemon's reconcile pass clears them.
588pub(crate) fn remove_stale_markers(marker_dir: &Path) -> usize {
589    let before = marker_count(marker_dir);
590    let live = live_sessions(marker_dir, false).len();
591    before.saturating_sub(live)
592}
593
594fn marker_count(marker_dir: &Path) -> usize {
595    std::fs::read_dir(marker_dir).map_or(0, |entries| {
596        entries
597            .flatten()
598            .filter(|entry| {
599                entry
600                    .file_name()
601                    .to_str()
602                    .and_then(|name| name.strip_suffix(".json"))
603                    .is_some_and(valid_session_id)
604            })
605            .count()
606    })
607}
608
609/// Session IDs of conversations whose owning SCV process still runs.
610fn live_sessions(marker_dir: &Path, dry_run: bool) -> Vec<String> {
611    let Ok(entries) = std::fs::read_dir(marker_dir) else {
612        return Vec::new();
613    };
614    let mut live = Vec::new();
615    for entry in entries.flatten() {
616        let path = entry.path();
617        let Some(id) = path
618            .file_name()
619            .and_then(|name| name.to_str())
620            .and_then(|name| name.strip_suffix(".json"))
621            .filter(|id| valid_session_id(id))
622            .map(str::to_owned)
623        else {
624            continue;
625        };
626        let alive = std::fs::read(&path)
627            .ok()
628            .and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).ok())
629            .is_some_and(|marker| marker.owner.is_alive());
630        if alive {
631            live.push(id);
632        } else if !dry_run {
633            let _ = std::fs::remove_file(&path);
634        }
635    }
636    live
637}
638
639fn walk(
640    dir: &Path,
641    extension: &str,
642    found: &mut Vec<(PathBuf, SystemTime, u64)>,
643) -> std::io::Result<()> {
644    let entries = match std::fs::read_dir(dir) {
645        Ok(entries) => entries,
646        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
647        Err(error) => return Err(error),
648    };
649    for entry in entries {
650        let entry = entry?;
651        let metadata = std::fs::symlink_metadata(entry.path())?;
652        if metadata.is_dir() {
653            walk(&entry.path(), extension, found)?;
654        } else if metadata.is_file()
655            && entry
656                .path()
657                .extension()
658                .is_some_and(|actual| actual == extension)
659        {
660            found.push((entry.path(), metadata.modified()?, metadata.len()));
661        }
662    }
663    Ok(())
664}
665
666/// Remove directories below `root` left empty, deepest first.
667fn remove_empty_dirs(dir: &Path, root: &Path) {
668    let Ok(entries) = std::fs::read_dir(dir) else {
669        return;
670    };
671    for entry in entries.flatten() {
672        if std::fs::symlink_metadata(entry.path()).is_ok_and(|metadata| metadata.is_dir()) {
673            remove_empty_dirs(&entry.path(), root);
674        }
675    }
676    if dir != root {
677        // Fails, harmlessly, unless the directory is empty.
678        let _ = std::fs::remove_dir(dir);
679    }
680}
681
682/// Parse an age such as `30d`, `12h`, `90m`, `45s`, or plain seconds.
683pub fn parse_age(value: &str) -> Result<Duration, String> {
684    let value = value.trim();
685    let (number, unit) = match value.find(|c: char| !c.is_ascii_digit()) {
686        Some(index) => value.split_at(index),
687        None => (value, "s"),
688    };
689    let number: u64 = number
690        .parse()
691        .map_err(|_| format!("invalid age {value:?}; use a number with s, m, h, or d"))?;
692    let unit = match unit {
693        "s" => 1,
694        "m" => 60,
695        "h" => 3600,
696        "d" => 86400,
697        _ => {
698            return Err(format!(
699                "invalid age {value:?}; use a number with s, m, h, or d"
700            ));
701        }
702    };
703    number
704        .checked_mul(unit)
705        .map(Duration::from_secs)
706        .ok_or_else(|| format!("age {value:?} is too large"))
707}
708
709#[cfg(test)]
710mod tests;