1use 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
32pub(crate) const MIN_GC_AGE: Duration = Duration::from_secs(3600);
35
36const MAX_HANDLE_BYTES: usize = 64;
38
39pub(crate) const ALL_IN_USE: &str = "conversations of this session are running a turn";
42
43#[derive(Debug, Clone, Copy)]
44pub struct ConversationLimits {
45 pub max: usize,
48 pub idle: Duration,
50}
51
52#[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 vendor: Option<String>,
70 turns: u32,
71 busy: bool,
72 last_used: Instant,
73 attachment: Option<Attachment>,
74 pins: u32,
77 released: bool,
79}
80
81#[derive(Debug, Default)]
82struct Inner {
83 conversations: HashMap<String, Conversation>,
84 next: HashMap<String, u32>,
86}
87
88#[derive(Debug)]
90pub(crate) struct ConversationStore {
91 limits: ConversationLimits,
92 marker_dir: Option<PathBuf>,
93 owner: Option<ProcessIdentity>,
94 inner: Mutex<Inner>,
95 changed: Notify,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
101struct Marker {
102 owner: ProcessIdentity,
103 agent: String,
104 handle: String,
105}
106
107#[derive(Debug)]
110pub(crate) struct TurnGuard {
111 store: Arc<ConversationStore>,
112 pub(crate) handle: String,
113 pub(crate) turn: u32,
114 pub(crate) vendor: Option<String>,
116 attachment: Option<Attachment>,
119 finished: bool,
120}
121
122#[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
138pub(crate) fn is_handle(value: &str) -> bool {
141 handle_agent(value).is_some()
142}
143
144pub(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 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 pub(crate) fn limits(&self) -> ConversationLimits {
171 self.limits
172 }
173
174 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 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 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 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 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 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 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 #[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 pub(crate) fn attachment(&self) -> Option<&Attachment> {
432 self.attachment.as_ref()
433 }
434
435 pub(crate) fn attach(&mut self, attachment: Attachment) {
437 self.attachment = Some(attachment);
438 }
439
440 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 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 self.store.changed.notify_waiters();
525 }
526}
527
528#[derive(Debug, Default, Clone, PartialEq, Eq)]
530pub struct GcReport {
531 pub removed: Vec<PathBuf>,
533 pub bytes: u64,
534 pub kept_live: usize,
536}
537
538pub 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
585pub(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
609fn 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
666fn 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 let _ = std::fs::remove_dir(dir);
679 }
680}
681
682pub 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;